He vuelto a realizar, otra vez, el ejemplo de la Gestión de Videos, que ya he realizado muchas otras veces.
En este caso, sólo he utilizado Daisy UI + Tailwind CSS, porque creo que es una de las combinaciones que más se utilizan en los desarrollos de framework de JavaScript y que mejor se ajustan al aprendizaje de los que se inician en ellos.
También, quería resolver el uso de «drawer» (ventanas emergentes que aparecen por los bordes de la pantalla), que incorpora Daisy UI.
Objetivo
Obtener un ejemplo claro, potente y sencillo de utilización de Daisy UI + Tailwind CSS, utilice los «drawer» de Daisy UI.
Deseo utilizar esta base para realizar ejemplos de WEB Components y Tailwind CSS es excelente para la realización de APP de escritorio que se adapten perfectamente al uso en móvil.
DEMO: https://fhumanes.com/daisy-svelte/
Solución Técnica
Los productos instalados son:
En el artículo S-018 explico cómo se crea un proyecto y sobre todo cómo se instala Daisy UI + Tailwind CSS (muy importante para que funcione todo en MS Visual Studio).
Siempre utiliza AXIOS para acceder al BackEnd (este código está descrito en el artículo S-002 y es una aplicación SLIM-4 PHP), sencilla que estoy utilizando en todos los ejemplos que hacen la Gestión de Películas.
He tenido problemas en el uso de la última versión de «svelte-spa-router» versión 5.0.1. Por eso está instalada la versión 4.0.1. Creo que el problema es que no utilizo TypeScript y por eso, debe tener ese BUG.
Para mensaje y «Toad» utilizo «sweetalert2». Es muy potente y como ya se utiliza en PHPRunner, parece la solución más sencilla de utilizar.
El ejemplo son 3 CRUD (Películas, Soportes y Temas). El CRUD de Películas tiene mucho más desarrollo en el GRID de mostrado y selección de registros y por eso, os mostraré el código de este ejemplo.
import { defineConfig } from 'vite'
import { svelte } from '@sveltejs/vite-plugin-svelte'
import tailwindcss from '@tailwindcss/vite'
// https://vite.dev/config/
export default defineConfig({
plugins: [svelte(), tailwindcss()],
base: '/daisy-svelte/',
build: {
outDir: 'dist',
assetsDir: 'assets',
assetsInlineLimit: 0 // Asegura que los assets no se conviertan en base64
}
});
<!doctype html>
<!-- Definición del Tema de DaisyUI -->
<html lang="es" data-theme="corporate">
<!-- <html lang="es" data-theme="light"> -->
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>DaisyUI-svelte</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
import { mount } from 'svelte'
import './app.css'
import App from './App.svelte'
// document.documentElement.setAttribute("data-theme", "esmerald"); // Establece el tema al cargar la aplicación
const app = mount(App, {
target: document.getElementById('app'),
})
export default app
@import "tailwindcss";
@plugin "daisyui" {
themes:
corporate --default,
dark --prefersdark,
light,
business,
cupcake,
garden,
synthwave,
emerald,
dracula;
}
<script>
import Router from 'svelte-spa-router';
import MovieList from './MovieList.svelte';
import ThemeManager from './ThemeManager.svelte';
import SupportManager from './SupportManager.svelte';
const routes = {
'/': MovieList,
'/movies': MovieList,
'/themes': ThemeManager,
'/supports': SupportManager
};
</script>
<!-- NAVBAR DAISYUI -->
<div class="navbar bg-neutral text-neutral-content px-2 lg:px-4">
<div class="flex-1">
<a href="#/" class="text-xl font-bold">Gestión de Cine</a>
</div>
<div class="flex gap-4">
<a href="#/movies" class="btn btn-ghost btn-sm">Películas</a>
<a href="#/themes" class="btn btn-ghost btn-sm">Temas</a>
<a href="#/supports" class="btn btn-ghost btn-sm">Soportes</a>
</div>
</div>
<!-- CONTENIDO PRINCIPAL -->
<div class="p-1 bg-gray-50 min-h-screen">
<Router {routes} />
</div>
<script>
import { onMount } from "svelte";
import * as XLSX from "xlsx";
import Swal from "sweetalert2";
import api from "./lib/api.js";
import MovieDrawer from "./MovieDrawer.svelte";
let movies = $state([]);
let themes = $state([]);
let supports = $state([]);
let loading = $state(true);
let errorMessage = $state(null);
let filter = $state("");
let sortColumn = $state(null);
let sortDirection = $state(1);
let page = $state(1);
let pageSize = $state(3);
const pageSizes = [3, 5, 10, 20, 50, 100];
let selectedMovie = $state({});
let showDrawer = $state(false);
let drawerMode = $state("view");
function normalize(str) {
return String(str)
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase();
}
function formatPrice(price) {
return new Intl.NumberFormat("es-ES", {
style: "currency",
currency: "EUR",
minimumFractionDigits: 2,
maximumFractionDigits: 2
}).format(price);
}
let filteredData = $state([]);
let totalPages = $derived(1);
let pagedData = $state([]);
onMount(() => {
fetchAllData();
});
async function fetchAllData() {
loading = true;
errorMessage = null;
try {
const [moviesRes, themesRes, supportsRes] = await Promise.all([
api.get("/movies"),
api.get("/themes"),
api.get("/supports")
]);
movies = moviesRes.data.map((m) => ({
id: m.id_movie,
name: m.name,
price: parseFloat(m.price),
date: new Date(m.startDate),
rating: parseInt(m.rating, 10),
theme: m.theme,
theme_id: String(m.theme_id),
support: m.support,
support_id: String(m.support_id)
}));
themes = themesRes.data;
supports = supportsRes.data;
filteredData = filterData();
calculationPage();
} catch (err) {
errorMessage = "No se pudieron cargar los datos.";
} finally {
loading = false;
}
}
function filterData() {
const f = normalize(filter);
const filtered = movies.filter((row) => {
return (
normalize(row.name).includes(f) ||
normalize(row.id).includes(f) ||
normalize(row.price).includes(f) ||
normalize(row.rating).includes(f) ||
normalize(row.theme).includes(f) ||
normalize(row.support).includes(f) ||
normalize(row.date.toLocaleDateString()).includes(f)
);
});
if (!sortColumn) return filtered;
return filtered.slice().sort((a, b) => {
const col = sortColumn;
const dir = sortDirection;
if (["theme", "support", "name"].includes(col)) {
return a[col].localeCompare(b[col]) * dir;
}
if (col === "date") {
return (new Date(a.date) - new Date(b.date)) * dir;
}
if (a[col] < b[col]) return -1 * dir;
if (a[col] > b[col]) return 1 * dir;
return 0;
});
}
function calculationPage() {
totalPages = Math.ceil(filteredData.length / pageSize);
pagedData = filteredData.slice((page - 1) * pageSize, page * pageSize);
if (totalPages === 0) page = 1;
if (page > totalPages && totalPages > 0) page = totalPages;
}
function handleView(movie) {
drawerMode = "view";
selectedMovie = {
...movie,
dateText: movie.date.toLocaleDateString("es-ES")
};
showDrawer = true;
}
function handleAdd() {
drawerMode = "add";
selectedMovie = {
id: null,
name: "",
price: "",
date: "",
rating: "1",
theme_id: themes.length ? String(themes[0].id_theme) : "",
support_id: supports.length ? String(supports[0].id_support) : ""
};
showDrawer = true;
}
function handleEdit(movie) {
drawerMode = "edit";
selectedMovie = {
...movie,
price: String(movie.price),
date: movie.date.toISOString().split("T")[0],
rating: String(movie.rating),
theme_id: String(movie.theme_id),
support_id: String(movie.support_id)
};
showDrawer = true;
}
async function handleDelete(id, name) {
const result = await Swal.fire({
title: "¿Estás seguro?",
text: `Eliminar "${name}" es irreversible.`,
icon: "warning",
showCancelButton: true
});
if (!result.isConfirmed) return;
try {
await api.delete(`/movies/${id}`);
Swal.fire("Eliminada", `"${name}" eliminada.`, "success");
fetchAllData();
} catch (err) {
Swal.fire("Error", "No se pudo eliminar.", "error");
}
}
function closeDrawer() {
showDrawer = false;
selectedMovie = {};
}
async function saveMovie(values) {
const normalizedName = values.name.trim().toLowerCase();
const currentId = selectedMovie?.id != null ? Number(selectedMovie.id) : null;
const duplicate = movies.find((m) => {
const sameName = m.name.trim().toLowerCase() === normalizedName;
const differentId = currentId == null ? true : m.id !== currentId;
return sameName && differentId;
});
if (duplicate) {
Swal.fire(
"Duplicado",
`Ya existe una película con ese nombre (ID ${duplicate.id})`,
"warning"
);
return;
}
const movieData = {
name: values.name,
price: parseFloat(values.price.replace(",", ".")),
startDate: values.date,
rating: values.rating,
theme_id: parseInt(values.theme_id),
support_id: parseInt(values.support_id)
};
try {
if (drawerMode === "add") {
await api.post("/movies", movieData);
Swal.fire({
title: "Guardada",
text: "Película añadida correctamente",
icon: "success",
timer: 1500
});
} else {
await api.put(`/movies/${selectedMovie.id}`, movieData);
Swal.fire({
title: "Actualizada",
text: "Película actualizada correctamente",
icon: "success",
timer: 1500
});
}
closeDrawer();
fetchAllData();
} catch (err) {
Swal.fire({
title: "Sin cambios",
text: "No se ha variado ningún dato",
icon: "info",
timer: 1500
});
}
}
function toggleSort(col) {
if (sortColumn === col) {
sortDirection = sortDirection * -1;
} else {
sortColumn = col;
sortDirection = 1;
}
page = 1;
filteredData = filterData();
calculationPage();
}
function prevPage() {
if (page > 1) {
page--;
calculationPage();
}
}
function nextPage() {
if (page < totalPages) {
page++;
calculationPage();
}
}
function goToPage(n) {
if (n >= 1 && n <= totalPages) {
page = n;
calculationPage();
}
}
function exportToCSV() {
if (!filteredData.length) return;
const rows = [Object.keys(filteredData[0])].concat(
filteredData.map((r) => Object.values(r))
);
const csv = rows.map((r) => r.join(",")).join("\n");
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
const link = document.createElement("a");
link.href = URL.createObjectURL(blob);
link.download = "peliculas.csv";
link.click();
}
function exportToExcel() {
if (!filteredData.length) return;
const ws = XLSX.utils.json_to_sheet(filteredData);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, "Datos");
XLSX.writeFile(wb, "peliculas.xlsx");
}
</script>
<!-- ========================================================= -->
<!-- DRAWER DAISYUI ENVOLVIENDO TODO EL MANAGER -->
<!-- ========================================================= -->
<div class="drawer drawer-end">
<input
id="movie-drawer"
type="checkbox"
class="drawer-toggle"
bind:checked={showDrawer}
/>
<!-- ===================== -->
<!-- CONTENIDO PRINCIPAL -->
<!-- ===================== -->
<div class="drawer-content">
<div class="max-w-5xl mt-6 px-2">
<h2 class="text-2xl font-bold mb-4 flex items-center gap-2">
<span class="text-primary">🎬</span> Gestión de Películas
</h2>
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-3 mb-4">
<div class="w-full md:w-1/3 relative">
<input
class="input input-bordered w-full pr-8"
placeholder="🔎 Filtrar..."
value={filter}
oninput={(e) => {
filter = e.target.value;
page = 1;
filteredData = filterData();
calculationPage();
}}
/>
{#if filter}
<button
class="absolute right-2 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-700"
onclick={() => {
filter = "";
filteredData = filterData();
calculationPage();
}}
>
✖
</button>
{/if}
</div>
<div class="gap-2 flex align-items-end">
<button class="btn btn-outline btn-success" onclick={handleAdd}>
➕ Añadir Película
</button>
<button class="btn btn-outline btn-neutral" onclick={exportToCSV}>CSV</button>
<button class="btn btn-outline btn-success" onclick={exportToExcel}>Excel</button>
</div>
</div>
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-3 mb-4">
<div class="flex items-center gap-2">
<label class="font-medium" for="page-size-select">Registros:</label>
<select
id="page-size-select"
class="select select-bordered w-16"
value={pageSize}
onchange={(e) => {
pageSize = parseInt(e.target.value);
page = 1;
filteredData = filterData();
calculationPage();
}}
>
{#each pageSizes as size}
<option value={size}>{size}</option>
{/each}
</select>
<span class="font-semibold ml-2">Total: {filteredData.length}</span>
</div>
</div>
{#if loading}
<p class="text-center py-8">Cargando...</p>
{:else if errorMessage}
<div class="alert alert-error justify-center">{errorMessage}</div>
{:else}
<div class="overflow-x-auto bg-base-100 rounded-xl shadow">
<table class="table table-zebra w-full">
<thead class="bg-gray-200">
<tr>
<th>Acciones</th>
<th class="cursor-pointer text-right" onclick={() => toggleSort("id")}>
ID {sortColumn === "id" ? (sortDirection === 1 ? "▲" : "▼") : ""}
</th>
<th class="cursor-pointer" onclick={() => toggleSort("name")}>
Nombre {sortColumn === "name" ? (sortDirection === 1 ? "▲" : "▼") : ""}
</th>
<th class="cursor-pointer text-right" onclick={() => toggleSort("price")}>
Precio {sortColumn === "price" ? (sortDirection === 1 ? "▲" : "▼") : ""}
</th>
<th class="cursor-pointer text-center" onclick={() => toggleSort("date")}>
Fecha {sortColumn === "date" ? (sortDirection === 1 ? "▲" : "▼") : ""}
</th>
<th class="cursor-pointer text-center" onclick={() => toggleSort("rating")}>
Rating {sortColumn === "rating" ? (sortDirection === 1 ? "▲" : "▼") : ""}
</th>
<th class="cursor-pointer" onclick={() => toggleSort("theme")}>
Tema {sortColumn === "theme" ? (sortDirection === 1 ? "▲" : "▼") : ""}
</th>
<th class="cursor-pointer" onclick={() => toggleSort("support")}>
Soporte {sortColumn === "support" ? (sortDirection === 1 ? "▲" : "▼") : ""}
</th>
</tr>
</thead>
<tbody>
{#if pagedData.length === 0}
<tr>
<td colspan="8" class="text-center py-4">
No hay resultados.
</td>
</tr>
{:else}
{#each pagedData as row (row.id)}
<tr>
<td>
<div class="flex gap-2">
<button class="btn btn-info btn-xs btn-soft" onclick={() => handleView(row)}>
👁
</button>
<button class="btn btn-warning btn-xs btn-soft" onclick={() => handleEdit(row)}>
✏️
</button>
<button class="btn btn-error btn-xs btn-soft" onclick={() => handleDelete(row.id, row.name)}>
🗑
</button>
</div>
</td>
<td class="text-right">{row.id}</td>
<td>{row.name}</td>
<td class="text-right">{formatPrice(row.price)}</td>
<td class="text-center">{row.date.toLocaleDateString()}</td>
<td class="text-center">
<div class="flex justify-center gap-1 text-yellow-400 text-2xl">
{#each Array(5) as _, i}
<span>{i < row.rating ? "★" : "☆"}</span>
{/each}
</div>
</td>
<td>{row.theme}</td>
<td>{row.support}</td>
</tr>
{/each}
{/if}
</tbody>
</table>
</div>
{/if}
{#if totalPages > 1}
<div class="flex justify-center mt-6">
<div class="join shadow">
<button class="join-item btn btn-sm" disabled={page === 1} onclick={prevPage}>«</button>
{#each Array(totalPages) as _, i}
<button
class="join-item btn btn-sm {page === i + 1 ? 'btn-primary btn-active text-white' : ''}"
onclick={() => goToPage(i + 1)}
>
{i + 1}
</button>
{/each}
<button class="join-item btn btn-sm" disabled={page === totalPages} onclick={nextPage}>»</button>
</div>
</div>
{/if}
</div>
</div>
<!-- ===================== -->
<!-- PANEL LATERAL -->
<!-- ===================== -->
<div class="drawer-side">
<label for="movie-drawer" class="drawer-overlay"></label>
<MovieDrawer
mode={drawerMode}
movie={selectedMovie}
{themes}
{supports}
onSave={saveMovie}
onClose={closeDrawer}
/>
</div>
</div>
<style>
.btn-soft {
opacity: 0.8;
}
.btn-soft:hover {
opacity: 1;
}
</style>
<script>
const {
mode = "view",
movie = {},
themes = [],
supports = [],
onSave,
onClose
} = $props();
let formValues = $state({
name: "",
price: "",
date: "",
rating: "1",
theme_id: "",
support_id: ""
});
let errors = $state({});
$effect(() => {
if (mode !== "view" && movie) {
formValues.name = movie.name ?? "";
formValues.price = movie.price != null ? String(movie.price).replace(".", ",") : "";
formValues.date = movie.date ?? "";
formValues.rating = movie.rating != null ? String(movie.rating) : "1";
formValues.theme_id = movie.theme_id != null ? String(movie.theme_id) : (themes[0] ? String(themes[0].id_theme) : "");
formValues.support_id = movie.support_id != null ? String(movie.support_id) : (supports[0] ? String(supports[0].id_support) : "");
errors = {};
}
});
function validateMovie(values) {
const e = {};
if (!values.name.trim()) e.name = "El nombre es obligatorio";
if (!values.price.trim()) {
e.price = "El precio es obligatorio";
} else if (!/^\d+([.,]\d{1,2})?$/.test(values.price)) {
e.price = "Formato de precio inválido";
}
if (!values.date) {
e.date = "La fecha es obligatoria";
} else if (isNaN(Date.parse(values.date))) {
e.date = "Fecha inválida";
}
if (!["1", "2", "3", "4", "5"].includes(values.rating)) {
e.rating = "Valoración inválida";
}
if (!values.theme_id) e.theme_id = "Tema obligatorio";
if (!values.support_id) e.support_id = "Soporte obligatorio";
return e;
}
function handleSubmit(e) {
e.preventDefault();
errors = validateMovie(formValues);
if (Object.keys(errors).length) return;
if (onSave) onSave(formValues);
}
function formatPrice(price) {
return new Intl.NumberFormat("es-ES", {
style: "currency",
currency: "EUR",
minimumFractionDigits: 2,
maximumFractionDigits: 2
}).format(price);
}
</script>
<!-- ========================================================= -->
<!-- SOLO CONTENIDO DEL PANEL LATERAL (SIN drawer, SIN input) -->
<!-- ========================================================= -->
<div class="menu bg-base-200 text-base-content w-full max-w-xl p-6">
<h3 class="font-bold text-lg mb-4">
{mode === "add"
? "Añadir Película"
: mode === "edit"
? "Editar Película"
: "Detalles de la Película"}
</h3>
{#if mode === "view"}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<span class="font-semibold">Nombre</span>
<input class="input input-bordered w-full" value={movie.name} readonly disabled />
</div>
<div class="form-control">
<span class="font-semibold">Precio</span>
<input class="input input-bordered w-full" value={formatPrice(movie.price)} readonly disabled />
</div>
<div class="form-control">
<span class="font-semibold">Fecha</span>
<input class="input input-bordered w-full" value={movie.dateText} readonly disabled />
</div>
<div class="form-control">
<span class="font-semibold">Valoración</span>
<div class="input input-bordered bg-star w-full">
<div class="flex gap-1 text-yellow-400 mt-1 text-2xl">
{#each Array(5) as _, i}
<span>{i < movie.rating ? "★" : "☆"}</span>
{/each}
</div>
</div>
</div>
<div class="form-control">
<span class="font-semibold">Tema</span>
<input
class="input input-bordered w-full"
value={themes.find(t => t.id_theme == movie.theme_id)?.theme}
readonly
disabled
/>
</div>
<div class="form-control">
<span class="font-semibold">Soporte</span>
<input
class="input input-bordered w-full"
value={supports.find(s => s.id_support == movie.support_id)?.support}
readonly
disabled
/>
</div>
</div>
<div class="flex justify-end gap-2 mt-4">
<button class="btn" type="button" onclick={onClose}>Cerrar</button>
</div>
{:else}
<form class="grid grid-cols-1 md:grid-cols-2 gap-4" onsubmit={handleSubmit}>
<div class="form-control">
<label class="label" for="name">Nombre</label>
<input id="name" class="input input-bordered" bind:value={formValues.name} />
{#if errors.name}<p class="text-red-500 text-sm">{errors.name}</p>{/if}
</div>
<div class="form-control">
<label class="label" for="price">Precio</label>
<input id="price" class="input input-bordered" bind:value={formValues.price} />
{#if errors.price}<p class="text-red-500 text-sm">{errors.price}</p>{/if}
</div>
<div class="form-control">
<label class="label" for="date">Fecha</label>
<input id="date" type="date" class="input input-bordered" bind:value={formValues.date} />
{#if errors.date}<p class="text-red-500 text-sm">{errors.date}</p>{/if}
</div>
<div class="form-control">
<label class="label" for="rating">Valoración</label>
<select id="rating" class="select select-bordered" bind:value={formValues.rating}>
{#each [1, 2, 3, 4, 5] as r}
<option value={String(r)}>{r}</option>
{/each}
</select>
{#if errors.rating}<p class="text-red-500 text-sm">{errors.rating}</p>{/if}
</div>
<div class="form-control">
<label class="label" for="theme_id">Tema</label>
<select id="theme_id" class="select select-bordered" bind:value={formValues.theme_id}>
<option value="">-- Selecciona tema --</option>
{#each themes as t}
<option value={String(t.id_theme)}>{t.theme}</option>
{/each}
</select>
{#if errors.theme_id}<p class="text-red-500 text-sm">{errors.theme_id}</p>{/if}
</div>
<div class="form-control">
<label class="label" for="support_id">Soporte</label>
<select id="support_id" class="select select-bordered" bind:value={formValues.support_id}>
<option value="">-- Selecciona soporte --</option>
{#each supports as s}
<option value={String(s.id_support)}>{s.support}</option>
{/each}
</select>
{#if errors.support_id}<p class="text-red-500 text-sm">{errors.support_id}</p>{/if}
</div>
<div class="col-span-1 md:col-span-2 flex justify-end gap-2 mt-4">
<button class="btn btn-primary" type="submit">
{mode === "add" ? "Guardar" : "Actualizar"}
</button>
<button class="btn" type="button" onclick={onClose}>Cancelar</button>
</div>
</form>
{/if}
</div>
<style>
input[readonly] {
background-color: #f9fafb;
border-color: #e5e7eb;
cursor: not-allowed;
}
.bg-star {
background-color: #f9fafb;
border: #e7e9eb;
}
</style>
El fichero (1) lo he puesto porque es un punto de fallo muy común y sin una correcta configuración Daisy UI y Tailwind CSS, no funciona.
En fichero (2), en este caso, se está indicando el Tema de Daisy UI que está activo para la aplicación.Hay muchas otras formas de hacer esta configuración (en documentación de Daisy UI).
En el fichero (3) lo único relevante es que importa el fichero apps.css (4).
En el fichero (4), se define los temas de Daisy UI que se instalan en la aplicación.
En fichero (5) se define el «menú» de la aplicación, la barra superior «negra», con las opciones que tiene la app.
En el fichero (6) se define la lógica y lo que se muestra en el GRID de Películas. Como veréis está todo codificado a «mano» y creo que es un buen ejemplo para ver cómo se codifica en Svelte 5.
En el fichero (7) se muestra el «drawer» que se muestra para las acciones ADD, VIEW y EDIT. Es bastante simple, pero a su vez, tiene todos los tipos de datos habituales.
Como siempre, os dejo el proyecto para que lo podáis descargar a vuestros PC’s y hacer todfos los cambios que necesitéis. Acordaros que tenéis que tener la aplicación de BackEnd que está en el artículo S-002.
Para cualqueir duda, podéis contactar conmigo a través de mi email.

