Creo que este ejemplo os va a mostrar lo fácil o difícil, que es trabajar en esta plataforma, de acuerdo a vuestros conocimientos.
Es un ejemplo típico de LIST, ADD, EDIT, VIEW y DELETE, de una tabla «Películas», con la, también, gestión de las talas auxiliares de «Temas» y «Soportes».
El desarrollo lo ha hecho GEMINI (la IA de Google), con mi revisión. El proceso que he seguido es facilitarle el modelo de datos, que he hecho con MySQL Workbench y le he especificado que de backend quería que utilizara SLIM 4.0 y MySQL. Y como Frontend quería Svelte 5 y Sveltestrap (que utiliza internamente Bootstrap 5.3. Hay que solicitar que establezca las FASES que vamos a utilizar para construir la solución y tener mucha paciencia, pues aunque el resultado final es bueno, son muchas las confusiones y pérdidas de información que tiene, con lo que hay que recordarle continuamente las soluciones que se van adoptando, porque tiende al olvidar muchos de los detalles.
Objetivo
Obtener una aplicación CRUD completa con la arquitectura de Svelte 5 + Bootstrap 5.3 + SLIM 4.0 + MySQL.
DEMO: https://fhumanes.com/my-movie-app1
Solución Técnica
Como os he comentado hice el modelo de datos, que corresponde a esta imagen.
Podéis que es simple, pero dispone de los elementos habituales, a saber:
- Claves primarias en cada tabla.
- Claves únicas en los nombre para que el propio modelo no admita datos duplicados.
- Calves ajenas o Foreing-key, para que no se pueda eliminar Tema o Soporte, si está en uso en Películas.
Podemos ver que en la aplicación de PHP tiene en cuenta estas características, para notificarlas (mensaje controlado) al usuario.
La aplicación PHP Slim, gestiona todos los accesos a los datos y su código es:
<?php // public/index.php use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; use Psr\Http\Server\RequestHandlerInterface as RequestHandler; use Slim\Factory\AppFactory; use DI\Container; use Slim\Routing\RouteCollectorProxy; use Slim\Middleware\BodyParsingMiddleware; // <-- ¡AÑADIR ESTA LÍNEA! require __DIR__ . '/../vendor/autoload.php'; // --- Configuración de la base de datos --- $host_con_puerto = $_SERVER['HTTP_HOST']; $hostName = ''; $parts = $parts = explode(':', $host_con_puerto); $hostName = $parts[0]; if ($hostName == 'localhost') { $dbSettings = [ 'host' => 'localhost', 'dbname' => 'svelte-app1', 'user' => 'root', 'pass' => 'humanes' ]; } else { $dbSettings = [ 'host' => 'localhost', 'dbname' => 'u637977917_svelteApp1', 'user' => 'u637977917_svelteApp1', 'pass' => 'Pereira654321#' ]; } // --- Configuración de CORS --- $corsSettings = [ 'allow_origins' => ['http://localhost:5173','https://fhumanes.com'], 'allow_headers' => ['Content-Type', 'Authorization', 'Accept', 'Origin'], 'allow_methods' => ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], 'allow_credentials' => true, 'expose_headers' => [], 'max_age' => 86400 ]; // --- 1. Configuración del Contenedor de Dependencias (PHP-DI) --- $container = new Container(); $container->set('dbSettings', function () use ($dbSettings) { return $dbSettings; }); $container->set('db', function (Container $c) { $settings = $c->get('dbSettings'); $dsn = "mysql:host={$settings['host']};dbname={$settings['dbname']};charset=utf8mb4"; $options = [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, PDO::ATTR_EMULATE_PREPARES => false, ]; try { $pdo = new PDO($dsn, $settings['user'], $settings['pass'], $options); return $pdo; } catch (PDOException $e) { throw new PDOException($e->getMessage(), (int)$e->getCode()); } }); $container->set('MovieController', function (Container $c) { return new App\MovieController($c->get('db')); }); // --- 2. Crear la aplicación Slim --- AppFactory::setContainer($container); $app = AppFactory::create(); // --- Configurar el Base Path para subdirectorios --- $scriptName = $_SERVER['SCRIPT_NAME']; $basePath = dirname($scriptName); $app->setBasePath($basePath); // --- Middleware de CORS (Debe ir antes del BodyParsingMiddleware y del enrutamiento) --- $app->options('/{routes:.+}', function (Request $request, Response $response) { return $response; }); $app->add(function (Request $request, RequestHandler $next) use ($corsSettings): Response { $response = $next->handle($request); $origin = $request->getHeaderLine('Origin'); if (in_array($origin, $corsSettings['allow_origins'])) { $response = $response ->withHeader('Access-Control-Allow-Origin', $origin) ->withHeader('Access-Control-Allow-Headers', implode(', ', $corsSettings['allow_headers'])) ->withHeader('Access-Control-Allow-Methods', implode(', ', $corsSettings['allow_methods'])) ->withHeader('Access-Control-Allow-Credentials', 'true') ->withHeader('Access-Control-Max-Age', (string)$corsSettings['max_age']); } if ($request->getMethod() === 'OPTIONS') { return $response->withStatus(200); } return $response; }); // --- ¡AÑADIR ESTE MIDDLEWARE! --- // Debe ir después de CORS y antes del addRoutingMiddleware $app->addBodyParsingMiddleware(); // <-- ¡LÍNEA CLAVE AÑADIDA! // --- Middleware de enrutamiento --- $app->addRoutingMiddleware(); // --- Middleware de errores (Siempre al final) --- $errorMiddleware = $app->addErrorMiddleware(true, true, true); $errorMiddleware->setDefaultErrorHandler(function ( Request $request, Throwable $exception, bool $displayErrorDetails, bool $logErrors, bool $logErrorDetails ) use ($app) { $statusCode = 500; if ($exception instanceof \Slim\Exception\HttpNotFoundException) { $statusCode = 404; } elseif ($exception instanceof \Slim\Exception\HttpMethodNotAllowedException) { $statusCode = 405; } $payload = [ 'error' => $exception->getMessage(), //'file' => $exception->getFile(), // Descomentar para depuración si es necesario //'line' => $exception->getLine() // Descomentar para depuración si es necesario ]; $response = $app->getResponseFactory()->createResponse(); $response->getBody()->write( json_encode($payload, JSON_UNESCAPED_UNICODE) ); return $response->withHeader('Content-Type', 'application/json')->withStatus($statusCode); }); // --- Rutas de la API --- // Grupo de Rutas para Películas $app->group('/movies', function (RouteCollectorProxy $group) { $group->get('', 'MovieController:getAllMovies'); $group->get('/{id}', 'MovieController:getMovieById'); $group->post('', 'MovieController:createMovie'); $group->put('/{id}', 'MovieController:updateMovie'); $group->delete('/{id}', 'MovieController:deleteMovie'); }); // Grupo de Rutas para Temas $app->group('/themes', function (RouteCollectorProxy $group) { // Ya tienes este: $group->get('', function (Request $request, Response $response, $args) { $db = $this->get('db'); $stmt = $db->query("SELECT id_theme, theme FROM theme"); $themes = $stmt->fetchAll(); $response->getBody()->write(json_encode($themes, JSON_UNESCAPED_UNICODE)); return $response->withHeader('Content-Type', 'application/json'); }); // --- ¡AÑADIR ESTAS RUTAS! --- $group->post('', 'MovieController:createTheme'); $group->put('/{id}', 'MovieController:updateTheme'); $group->delete('/{id}', 'MovieController:deleteTheme'); }); // Grupo de Rutas para Soportes $app->group('/supports', function (RouteCollectorProxy $group) { // Ya tienes este: $group->get('', function (Request $request, Response $response, $args) { $db = $this->get('db'); $stmt = $db->query("SELECT id_support, support FROM support"); $supports = $stmt->fetchAll(); $response->getBody()->write(json_encode($supports, JSON_UNESCAPED_UNICODE)); return $response->withHeader('Content-Type', 'application/json'); }); // --- ¡AÑADIR ESTAS RUTAS! --- $group->post('', 'MovieController:createSupport'); $group->put('/{id}', 'MovieController:updateSupport'); $group->delete('/{id}', 'MovieController:deleteSupport'); }); // --- Ejecutar la aplicación --- $app->run();
<?php // src/MovieController.php namespace App; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; use PDO; use PDOException; class MovieController { private $db; public function __construct(PDO $db) { $this->db = $db; } // --- Obtener todas las películas --- public function getAllMovies(Request $request, Response $response, array $args): Response { $stmt = $this->db->query(" SELECT m.id_movie, m.name, m.price, m.startDate, m.rating, t.theme, m.theme_id, s.support, m.support_id FROM movie m JOIN theme t ON m.theme_id = t.id_theme JOIN support s ON m.support_id = s.id_support ORDER BY m.id_movie ASC "); $movies = $stmt->fetchAll(PDO::FETCH_ASSOC); $response->getBody()->write(json_encode($movies, JSON_UNESCAPED_UNICODE)); return $response->withHeader('Content-Type', 'application/json'); } // --- Obtener una película por ID --- public function getMovieById(Request $request, Response $response, array $args): Response { $id = $args['id']; $stmt = $this->db->prepare(" SELECT m.id_movie, m.name, m.price, m.startDate, m.rating, t.theme, m.theme_id, s.support, m.support_id FROM movie m JOIN theme t ON m.theme_id = t.id_theme JOIN support s ON m.support_id = s.id_support WHERE m.id_movie = :id "); $stmt->bindParam(':id', $id, PDO::PARAM_INT); $stmt->execute(); $movie = $stmt->fetch(PDO::FETCH_ASSOC); if (!$movie) { $response->getBody()->write(json_encode(['message' => 'Película no encontrada'], JSON_UNESCAPED_UNICODE)); return $response->withHeader('Content-Type', 'application/json')->withStatus(404); } $response->getBody()->write(json_encode($movie, JSON_UNESCAPED_UNICODE)); return $response->withHeader('Content-Type', 'application/json'); } // --- Crear una nueva película --- public function createMovie(Request $request, Response $response, array $args): Response { $data = $request->getParsedBody(); // Validar datos de entrada (simple) if (!isset($data['name'], $data['price'], $data['startDate'], $data['rating'], $data['theme_id'], $data['support_id'])) { $response->getBody()->write(json_encode(['message' => 'Faltan campos obligatorios'], JSON_UNESCAPED_UNICODE)); return $response->withHeader('Content-Type', 'application/json')->withStatus(400); } $stmt = $this->db->prepare(" INSERT INTO movie (name, price, startDate, rating, theme_id, support_id) VALUES (:name, :price, :startDate, :rating, :theme_id, :support_id) "); $stmt->bindParam(':name', $data['name']); $stmt->bindParam(':price', $data['price']); $stmt->bindParam(':startDate', $data['startDate']); $stmt->bindParam(':rating', $data['rating'], PDO::PARAM_INT); $stmt->bindParam(':theme_id', $data['theme_id'], PDO::PARAM_INT); $stmt->bindParam(':support_id', $data['support_id'], PDO::PARAM_INT); $stmt->execute(); $newId = $this->db->lastInsertId(); // Obtén la película completa recién creada si es necesario, o simplemente devuelve el nombre. // Para simplificar, devolvemos el nombre que se envió, y el nuevo ID. $response->getBody()->write(json_encode([ 'message' => 'Película creada', 'id' => $newId, 'name' => $data['name'] // <-- ¡AÑADIR ESTA LÍNEA! ], JSON_UNESCAPED_UNICODE)); return $response->withHeader('Content-Type', 'application/json')->withStatus(201); } // --- Actualizar una película existente --- public function updateMovie(Request $request, Response $response, array $args): Response { $id = $args['id']; $data = $request->getParsedBody(); // Validar datos de entrada (simple) if (!isset($data['name'], $data['price'], $data['startDate'], $data['rating'], $data['theme_id'], $data['support_id'])) { $response->getBody()->write(json_encode(['message' => 'Faltan campos obligatorios para la actualización'], JSON_UNESCAPED_UNICODE)); return $response->withHeader('Content-Type', 'application/json')->withStatus(400); } $stmt = $this->db->prepare(" UPDATE movie SET name = :name, price = :price, startDate = :startDate, rating = :rating, theme_id = :theme_id, support_id = :support_id WHERE id_movie = :id "); $stmt->bindParam(':name', $data['name']); $stmt->bindParam(':price', $data['price']); $stmt->bindParam(':startDate', $data['startDate']); $stmt->bindParam(':rating', $data['rating'], PDO::PARAM_INT); $stmt->bindParam(':theme_id', $data['theme_id'], PDO::PARAM_INT); $stmt->bindParam(':support_id', $data['support_id'], PDO::PARAM_INT); $stmt->bindParam(':id', $id, PDO::PARAM_INT); $stmt->execute(); if ($stmt->rowCount() === 0) { $response->getBody()->write(json_encode(['message' => 'Película no encontrada o sin cambios'], JSON_UNESCAPED_UNICODE)); return $response->withHeader('Content-Type', 'application/json')->withStatus(404); } $response->getBody()->write(json_encode([ 'message' => 'Película actualizada', 'name' => $data['name'] // <-- ¡AÑADIR ESTA LÍNEA! ], JSON_UNESCAPED_UNICODE)); return $response->withHeader('Content-Type', 'application/json'); } // --- Eliminar una película --- public function deleteMovie(Request $request, Response $response, array $args): Response { $id = $args['id']; $stmt = $this->db->prepare("DELETE FROM movie WHERE id_movie = :id"); $stmt->bindParam(':id', $id, PDO::PARAM_INT); $stmt->execute(); if ($stmt->rowCount() === 0) { $response->getBody()->write(json_encode(['message' => 'Película no encontrada'], JSON_UNESCAPED_UNICODE)); return $response->withHeader('Content-Type', 'application/json')->withStatus(404); } $response->getBody()->write(json_encode(['message' => 'Película eliminada'], JSON_UNESCAPED_UNICODE)); return $response->withHeader('Content-Type', 'application/json'); } // --- CRUD para Temas --- public function createTheme(Request $request, Response $response, array $args): Response { $data = $request->getParsedBody(); if (!isset($data['theme']) || empty($data['theme'])) { $response->getBody()->write(json_encode(['message' => 'El nombre del tema es obligatorio.'], JSON_UNESCAPED_UNICODE)); return $response->withHeader('Content-Type', 'application/json')->withStatus(400); } try { $stmt = $this->db->prepare("INSERT INTO theme (theme) VALUES (:theme)"); $stmt->bindParam(':theme', $data['theme']); $stmt->execute(); $newId = $this->db->lastInsertId(); $response->getBody()->write(json_encode([ 'message' => 'Tema creado con éxito.', 'id' => $newId, 'theme' => $data['theme'] ], JSON_UNESCAPED_UNICODE)); return $response->withHeader('Content-Type', 'application/json')->withStatus(201); } catch (PDOException $e) { // Error si el tema ya existe (ej. columna 'theme' es UNIQUE) if ($e->getCode() === '23000') { // Código de error SQL para violación de unicidad $response->getBody()->write(json_encode(['message' => 'El tema ya existe.'], JSON_UNESCAPED_UNICODE)); return $response->withHeader('Content-Type', 'application/json')->withStatus(409); // 409 Conflict } $response->getBody()->write(json_encode(['message' => 'Error interno del servidor al crear el tema.', 'error' => $e->getMessage()], JSON_UNESCAPED_UNICODE)); return $response->withHeader('Content-Type', 'application/json')->withStatus(500); } } public function updateTheme(Request $request, Response $response, array $args): Response { $id = $args['id']; $data = $request->getParsedBody(); if (!isset($data['theme']) || empty($data['theme'])) { $response->getBody()->write(json_encode(['message' => 'El nombre del tema es obligatorio para la actualización.'], JSON_UNESCAPED_UNICODE)); return $response->withHeader('Content-Type', 'application/json')->withStatus(400); } try { $stmt = $this->db->prepare("UPDATE theme SET theme = :theme WHERE id_theme = :id"); $stmt->bindParam(':theme', $data['theme']); $stmt->bindParam(':id', $id, PDO::PARAM_INT); $stmt->execute(); if ($stmt->rowCount() === 0) { $response->getBody()->write(json_encode(['message' => 'Tema no encontrado o sin cambios.'], JSON_UNESCAPED_UNICODE)); return $response->withHeader('Content-Type', 'application/json')->withStatus(404); } $response->getBody()->write(json_encode([ 'message' => 'Tema actualizado con éxito.', 'id' => $id, 'theme' => $data['theme'] ], JSON_UNESCAPED_UNICODE)); return $response->withHeader('Content-Type', 'application/json'); } catch (PDOException $e) { if ($e->getCode() === '23000') { $response->getBody()->write(json_encode(['message' => 'El tema ya existe.'], JSON_UNESCAPED_UNICODE)); return $response->withHeader('Content-Type', 'application/json')->withStatus(409); } $response->getBody()->write(json_encode(['message' => 'Error interno del servidor al actualizar el tema.', 'error' => $e->getMessage()], JSON_UNESCAPED_UNICODE)); return $response->withHeader('Content-Type', 'application/json')->withStatus(500); } } public function deleteTheme(Request $request, Response $response, array $args): Response { $id = $args['id']; // --- Manejo de Integridad Referencial (FASE POSTERIOR, AHORA SOLO ELIMINA) --- // Para la fase 5, aquí iría la verificación de si hay películas usando este tema. // Por ahora, simplemente intentamos eliminar. Si hay una restricción FOREIGN KEY, // la base de datos lanzará una excepción PDO. try { $stmt = $this->db->prepare("DELETE FROM theme WHERE id_theme = :id"); $stmt->bindParam(':id', $id, PDO::PARAM_INT); $stmt->execute(); if ($stmt->rowCount() === 0) { $response->getBody()->write(json_encode(['message' => 'Tema no encontrado.'], JSON_UNESCAPED_UNICODE)); return $response->withHeader('Content-Type', 'application/json')->withStatus(404); } $response->getBody()->write(json_encode(['message' => 'Tema eliminado con éxito.'], JSON_UNESCAPED_UNICODE)); return $response->withHeader('Content-Type', 'application/json'); } catch (PDOException $e) { // Código de error 23000 es para violación de integridad (Foreign Key Constraint) if ($e->getCode() === '23000') { $response->getBody()->write(json_encode(['message' => 'No se puede eliminar el tema porque está asignado a una o más películas.'], JSON_UNESCAPED_UNICODE)); return $response->withHeader('Content-Type', 'application/json')->withStatus(409); // 409 Conflict } $response->getBody()->write(json_encode(['message' => 'Error interno del servidor al eliminar el tema.', 'error' => $e->getMessage()], JSON_UNESCAPED_UNICODE)); return $response->withHeader('Content-Type', 'application/json')->withStatus(500); } } // --- CRUD para Soportes --- public function createSupport(Request $request, Response $response, array $args): Response { $data = $request->getParsedBody(); if (!isset($data['support']) || empty($data['support'])) { $response->getBody()->write(json_encode(['message' => 'El nombre del soporte es obligatorio.'], JSON_UNESCAPED_UNICODE)); return $response->withHeader('Content-Type', 'application/json')->withStatus(400); } try { $stmt = $this->db->prepare("INSERT INTO support (support) VALUES (:support)"); $stmt->bindParam(':support', $data['support']); $stmt->execute(); $newId = $this->db->lastInsertId(); $response->getBody()->write(json_encode([ 'message' => 'Soporte creado con éxito.', 'id' => $newId, 'support' => $data['support'] ], JSON_UNESCAPED_UNICODE)); return $response->withHeader('Content-Type', 'application/json')->withStatus(201); } catch (PDOException $e) { if ($e->getCode() === '23000') { $response->getBody()->write(json_encode(['message' => 'El soporte ya existe.'], JSON_UNESCAPED_UNICODE)); return $response->withHeader('Content-Type', 'application/json')->withStatus(409); } $response->getBody()->write(json_encode(['message' => 'Error interno del servidor al crear el soporte.', 'error' => $e->getMessage()], JSON_UNESCAPED_UNICODE)); return $response->withHeader('Content-Type', 'application/json')->withStatus(500); } } public function updateSupport(Request $request, Response $response, array $args): Response { $id = $args['id']; $data = $request->getParsedBody(); if (!isset($data['support']) || empty($data['support'])) { $response->getBody()->write(json_encode(['message' => 'El nombre del soporte es obligatorio para la actualización.'], JSON_UNESCAPED_UNICODE)); return $response->withHeader('Content-Type', 'application/json')->withStatus(400); } try { $stmt = $this->db->prepare("UPDATE support SET support = :support WHERE id_support = :id"); $stmt->bindParam(':support', $data['support']); $stmt->bindParam(':id', $id, PDO::PARAM_INT); $stmt->execute(); if ($stmt->rowCount() === 0) { $response->getBody()->write(json_encode(['message' => 'Soporte no encontrado o sin cambios.'], JSON_UNESCAPED_UNICODE)); return $response->withHeader('Content-Type', 'application/json')->withStatus(404); } $response->getBody()->write(json_encode([ 'message' => 'Soporte actualizado con éxito.', 'id' => $id, 'support' => $data['support'] ], JSON_UNESCAPED_UNICODE)); return $response->withHeader('Content-Type', 'application/json'); } catch (PDOException $e) { if ($e->getCode() === '23000') { $response->getBody()->write(json_encode(['message' => 'El soporte ya existe.'], JSON_UNESCAPED_UNICODE)); return $response->withHeader('Content-Type', 'application/json')->withStatus(409); } $response->getBody()->write(json_encode(['message' => 'Error interno del servidor al actualizar el soporte.', 'error' => $e->getMessage()], JSON_UNESCAPED_UNICODE)); return $response->withHeader('Content-Type', 'application/json')->withStatus(500); } } public function deleteSupport(Request $request, Response $response, array $args): Response { $id = $args['id']; // --- Manejo de Integridad Referencial (FASE POSTERIOR, AHORA SOLO ELIMINA) --- // Para la fase 5, aquí iría la verificación de si hay películas usando este soporte. // Por ahora, simplemente intentamos eliminar. try { $stmt = $this->db->prepare("DELETE FROM support WHERE id_support = :id"); $stmt->bindParam(':id', $id, PDO::PARAM_INT); $stmt->execute(); if ($stmt->rowCount() === 0) { $response->getBody()->write(json_encode(['message' => 'Soporte no encontrado.'], JSON_UNESCAPED_UNICODE)); return $response->withHeader('Content-Type', 'application/json')->withStatus(404); } $response->getBody()->write(json_encode(['message' => 'Soporte eliminado con éxito.'], JSON_UNESCAPED_UNICODE)); return $response->withHeader('Content-Type', 'application/json'); } catch (PDOException $e) { if ($e->getCode() === '23000') { $response->getBody()->write(json_encode(['message' => 'No se puede eliminar el soporte porque está asignado a una o más películas.'], JSON_UNESCAPED_UNICODE)); return $response->withHeader('Content-Type', 'application/json')->withStatus(409); } $response->getBody()->write(json_encode(['message' => 'Error interno del servidor al eliminar el soporte.', 'error' => $e->getMessage()], JSON_UNESCAPED_UNICODE)); return $response->withHeader('Content-Type', 'application/json')->withStatus(500); } } }
Lo único que he cambiado en este código es la codificación de si la APP se ejecuta en «localhost» o en el Server de Linux. Mi idea es ajustarlo a mi estilo y control de mis desarrollos en SLIM. He querido dejarlo tal cuál me lo entregó porque hay codificación muy interesante (Estoy aprendiendo muchas cosas con las codificaciones de las IA’s, además, éstas te explican el por qué de los errores y de los cambios, con lo que además del código, están las explicaciones -enseñanza- que te facilitan).
Con respecto al desarrollo del Frontend, os comentaré que lo hemos echo en muchas fases, pues le facilité un desarrollo CRUD que no tenía Bootstrap y que os dejaré con el nombre «my-movie-app» y le solicité que lo pasara a Bootstrap 5 y os la dejo con el nombre «my-movie-app2» y posteriormente solicite que lo pasara a Sveltestrap, que os la dejo con nombre «my-monie-app3». Estas versiones no trabajan con el Server y trabajan con un conjunto fijo de datos.
Cuando ya tenía la versión 3, es cuando le facilité el modelo de datos y le dije que trabajaríamos por fases.
Aquí os dejo parte del código de la versión «my-movie-app1», que es la que está publicada y la que trabaja con el Server de PHP.
<script> import Router from 'svelte-spa-router'; // Importa el export por defecto const { Link, Route } = Router; // Desestructura los componentes necesarios import { Navbar, NavbarBrand, Nav, NavItem, NavLink } from '@sveltestrap/sveltestrap'; // Componentes de Navbar // Importar tus nuevos componentes import MovieManager from './MovieManager.svelte'; import ThemaManager from './ThemaManager.svelte'; import SupportManager from './SupportManager.svelte'; // Definir las rutas const routes = { '/': MovieManager, // Ruta raíz para el gestor de películas '/movies': MovieManager, // Ruta explícita para el gestor de películas '/themes': ThemaManager, // Ruta para el gestor de temas '/supports': SupportManager // Ruta para el gestor de soportes }; </script> <main> <Navbar color="dark" dark expand="md"> <NavbarBrand href="#/">Gestión de Cine</NavbarBrand> <Nav class="me-auto" navbar> <NavItem> <NavLink href="#/movies">Películas</NavLink> </NavItem> <NavItem> <NavLink href="#/themes">Temas</NavLink> </NavItem> <NavItem> <NavLink href="#/supports">Soportes</NavLink> </NavItem> </Nav> </Navbar> <div class="container mt-4"> <Router {routes} /> </div> </main> <style> /* Estilos básicos si los necesitas, aunque Sveltestrap ya hace mucho */ main { min-height: 100vh; /* Asegura que la página ocupe al menos la altura de la vista */ background-color: #f8f9fa; /* Un color de fondo suave */ } </style>
// src/lib/api.js (o src/api/index.js) import axios from 'axios'; let appHost = ''; appHost = window.location.hostname; // Para obtener el host de la URL console.log("estoy conectado al Host: ",appHost); // URL base de tu API REST de PHP // Asegúrate de que esta URL sea la correcta para tu PUBLIC folder let API_BASE_URL = 'http://localhost/movie-php-app/public'; if (appHost == 'localhost') { API_BASE_URL = 'http://localhost/movie-php-app/public'; } else { API_BASE_URL = 'https://fhumanes.com/movie-php-app/public'; } const api = axios.create({ baseURL: API_BASE_URL, headers: { 'Content-Type': 'application/json', }, }); export default api;
<script> import * as XLSX from "xlsx"; import { onMount } from 'svelte'; import api from './lib/api.js'; // Asegúrate de que esta ruta sea correcta para tu api.js // --- IMPORTACIÓN CORRECTA DE COMPONENTES DE SVELTSTRAP --- import { Container, Row, Col, Button, Input, Table, Pagination, PaginationItem, PaginationLink, Modal, ModalHeader, ModalBody, ModalFooter } from '@sveltestrap/sveltestrap'; import Swal from 'sweetalert2'; // --- DATOS DEL BACKEND --- let movies = []; // Aquí se cargarán las películas de la API let themes = []; // Aquí se cargarán los temas de la API let supports = []; // Aquí se cargarán los soportes de la API let loading = true; let errorMessage = null; // Para mostrar errores de la API // --- Estado filtro, orden y paginación --- let filter = ''; let sortColumn = null; let sortDirection = 1; let page = 1; let pageSize = 3; const pageSizes = [3, 5, 10]; // --- Estado para el modal de acciones (Añadir/Editar/Visualizar) --- let selectedMovie = {}; let showModal = false; // Controla la visibilidad del modal let modalMode = 'view'; // Puede ser 'view', 'add', o 'edit' // --- Función para cargar todos los datos de la API --- async function fetchAllData() { loading = true; errorMessage = null; try { const [moviesResponse, themesResponse, supportsResponse] = await Promise.all([ api.get('/movies'), api.get('/themes'), api.get('/supports') ]); // Mapear datos de películas para coincidir con tu estructura de frontend // Asumimos que theme_id y support_id vienen como números de la API, // y que los nombres de theme/support están en propiedades 'theme' y 'support' dentro del objeto movie movies = moviesResponse.data.map(m => ({ id: m.id_movie, name: m.name, price: parseFloat(m.price), date: new Date(m.startDate), // Convertir a objeto Date rating: parseInt(m.rating, 10), theme: m.theme, // Nombre del tema (cadena) theme_id: String(m.theme_id), // <-- ¡Aquí! Convertir a string support: m.support, // Nombre del soporte (cadena) support_id: String(m.support_id) // <-- ¡Aquí! Convertir a string })); themes = themesResponse.data; supports = supportsResponse.data; } catch (err) { console.error('Error al cargar datos:', err); errorMessage = 'No se pudieron cargar los datos. Por favor, intente de nuevo más tarde.'; } finally { loading = false; } } // Cargar datos al iniciar el componente onMount(fetchAllData); // --- Funciones para las acciones --- function handleView(movie) { modalMode = 'view'; // Clonar la película y formatear la fecha para el input[type="date"] selectedMovie = { ...movie, date: movie.date.toISOString().split('T')[0] }; showModal = true; } function handleAdd() { modalMode = 'add'; selectedMovie = { id: null, // El backend generará el ID name: '', price: 0.00, date: new Date().toISOString().split('T')[0], // Fecha actual en formato YYYY-MM-DD rating: 1, // Valoración inicial por defecto // Asegúrate de que sean strings. Si themes/supports están vacíos, pon '' theme_id: themes.length > 0 ? String(themes[0].id_theme) : '', // <-- ¡Aquí! support_id: supports.length > 0 ? String(supports[0].id_support) : '' // <-- ¡Aquí! }; showModal = true; } function handleEdit(movie) { modalMode = 'edit'; selectedMovie = { ...movie, date: movie.date.toISOString().split('T')[0], // Asegúrate de que theme_id y support_id sean strings al editar theme_id: String(movie.theme_id), // <-- ¡Aquí! support_id: String(movie.support_id) // <-- ¡Aquí! }; showModal = true; } async function handleDelete(id, name) { // Usar SweetAlert2 para la confirmación también const result = await Swal.fire({ // <-- CAMBIO AQUÍ title: '¿Estás seguro?', text: `¡Vas a eliminar la película "${name}"! ¡Esta acción no se puede deshacer!`, icon: 'warning', showCancelButton: true, confirmButtonColor: '#d33', cancelButtonColor: '#3085d6', confirmButtonText: 'Sí, ¡eliminar!', cancelButtonText: 'Cancelar' }); if (result.isConfirmed) { // Comprobar si el usuario confirmó try { await api.delete(`/movies/${id}`); Swal.fire({ // <-- CAMBIO AQUÍ icon: 'success', title: '¡Eliminada!', text: `Película "${name}" eliminada con éxito.` }); await fetchAllData(); } catch (err) { console.error('Error al eliminar película:', err.response?.data?.message || err.message); Swal.fire({ // <-- CAMBIO AQUÍ icon: 'error', title: 'Error al Eliminar', text: `No se pudo eliminar la película: ${err.response?.data?.message || 'Error desconocido'}.` }); } } } async function handleSave() { // Validación básica if (!selectedMovie.name || selectedMovie.price === undefined || selectedMovie.date === undefined || selectedMovie.rating === undefined || !selectedMovie.theme_id || !selectedMovie.support_id) { Swal.fire({ // Mensaje al Usuario icon: 'warning', title: 'Campos Obligatorios', text: 'Por favor, rellena todos los campos obligatorios.' }); return; } try { // Prepara los datos para enviar a la API const movieData = { name: selectedMovie.name, price: parseFloat(selectedMovie.price), startDate: selectedMovie.date, // Esto ya es 'YYYY-MM-DD' rating: parseInt(selectedMovie.rating, 10), // Convertir de nuevo a número para el backend theme_id: parseInt(selectedMovie.theme_id, 10), // <-- ¡Aquí! Convertir a número support_id: parseInt(selectedMovie.support_id, 10) // <-- ¡Aquí! Convertir a número }; // Puedes añadir estos console.log temporales para verificar justo antes de enviar: // console.log("Payload enviado al backend:", movieData); // console.log("Tipo theme_id final:", typeof movieData.theme_id, "Valor:", movieData.theme_id); // console.log("Tipo support_id final:", typeof movieData.support_id, "Valor:", movieData.support_id); let response; if (modalMode === 'add') { response = await api.post('/movies', movieData); Swal.fire({ // Mensaje al Usuario icon: 'success', title: '¡Éxito!', text: `Película "${response.data.name}" añadida con éxito.` }); } else if (modalMode === 'edit') { if (!selectedMovie.id) { console.error("Error: Intentando editar una película sin ID. selectedMovie:", selectedMovie); Swal.fire({ // Mensaje al Usuario icon: 'error', title: 'Error de Edición', text: 'Error al editar: ID de película no encontrado.' }); return; } response = await api.put(`/movies/${selectedMovie.id}`, movieData); Swal.fire({ // Mensaje al Usuario icon: 'success', title: '¡Actualizado!', text: `Película "${response.data.name}" actualizada con éxito.` }); } closeModal(); await fetchAllData(); // Recargar la lista después de guardar/actualizar } catch (err) { console.error('Error al guardar película:', err.response?.data?.message || err.message); Swal.fire({ icon: 'error', title: 'Error al Guardar', text: `No se pudo guardar la película: ${err.response?.data?.message || 'Error desconocido'}.` }); } } function closeModal() { showModal = false; selectedMovie = {}; // Limpiar el objeto de la película seleccionada errorMessage = null; // Limpiar cualquier mensaje de error } // --- Lógica de la tabla (sin cambios significativos aquí) --- function toggleSort(column) { if (sortColumn === column) { sortDirection = -sortDirection; } else { sortColumn = column; sortDirection = 1; } page = 1; } function normalize(str) { return String(str).normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase(); } // --- Función para formatear el precio --- const formatPrice = (price) => { return new Intl.NumberFormat('es-ES', { style: 'currency', currency: 'EUR', minimumFractionDigits: 2, maximumFractionDigits: 2, useGrouping: true }).format(price); }; $: filteredData = movies // Ahora filtramos 'movies' cargadas de la API .filter(row => { const normalizedFilter = normalize(filter); return ( normalize(row.name).includes(normalizedFilter) || normalize(row.id).includes(normalizedFilter) || normalize(row.price).includes(normalizedFilter) || normalize(row.rating).includes(normalizedFilter) || normalize(row.theme).includes(normalizedFilter) || // Usar row.theme (cadena) normalize(row.support).includes(normalizedFilter) || // Usar row.support (cadena) normalize(row.date.toLocaleDateString()).includes(normalizedFilter) ); }) .sort((a, b) => { if (!sortColumn) return 0; // Para ordenar por theme o support, usa la cadena de texto if (sortColumn === 'theme' || sortColumn === 'support' || sortColumn === 'name') { return a[sortColumn].localeCompare(b[sortColumn]) * sortDirection; } // Para el resto de columnas, usa la comparación numérica o de fecha if (a[sortColumn] < b[sortColumn]) return -1 * sortDirection; if (a[sortColumn] > b[sortColumn]) return 1 * sortDirection; return 0; }); $: totalPages = Math.ceil(filteredData.length / pageSize); $: pagedData = filteredData.slice((page - 1) * pageSize, page * pageSize); $: { if (page > totalPages && totalPages > 0) { page = totalPages; } else if (totalPages === 0) { page = 1; } } function prevPage() { if (page > 1) page--; } function nextPage() { if (page < totalPages) page++; } function goToPage(n) { if (n >= 1 && n <= totalPages) page = n; } // --- Exportar (sin cambios significativos aquí, pero ahora usan `movies` de la API) --- function exportToCSV() { const rows = [Object.keys(filteredData[0])].concat(filteredData.map(Object.values)); const csvContent = rows.map(e => e.join(",")).join("\n"); const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" }); const link = document.createElement("a"); link.href = URL.createObjectURL(blob); link.setAttribute("download", "peliculas-filtradas.csv"); document.body.appendChild(link); link.click(); document.body.removeChild(link); } function exportToExcel() { const worksheet = XLSX.utils.json_to_sheet(filteredData); const workbook = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(workbook, worksheet, "Datos"); XLSX.writeFile(workbook, "peliculas-filtradas.xlsx"); } </script> <style> /* --- Estilos personalizados para las estrellas --- */ .star-rating { font-size: 1.5rem; display: flex; align-items: center; justify-content: center; user-select: none; margin-top: 0.25rem; } .star-rating .star { cursor: pointer; color: gold; transition: color 0.2s; } .star-rating .star.disabled { cursor: default; } /* Cursor para columnas que se pueden ordenar */ .cursor-pointer { cursor: pointer; } </style> <Container class="mt-4"> <h2 class="text-center mb-4"><i class="bi bi-camera-reels"></i> Gestión de Películas</h2> <div class="d-flex justify-content-between align-items-center mb-3"> <div style="width: 25%;"> <Input type="text" placeholder="🔎 Filtrar..." bind:value={filter} /> </div> <Button color="success" on:click={handleAdd}> <i class="bi bi-plus-circle-fill me-1"></i> Añadir Película </Button> </div> <div class="d-flex justify-content-between align-items-center mb-3"> <div class="d-flex align-items-center"> <label for="pageSizeSelect" class="me-2">Registros por página:</label> <Input id="pageSizeSelect" type="select" class="w-auto" bind:value={pageSize} on:change={() => (page = 1)}> {#each pageSizes as size} <option value={size}>{size}</option> {/each} </Input> </div> <div class="d-flex align-items-center gap-2"> <Button outline color="secondary" on:click={exportToCSV}> <i class="bi bi-file-earmark-spreadsheet-fill me-1"></i> Exportar CSV </Button> <Button outline color="success" on:click={exportToExcel}> <i class="bi bi-file-earmark-excel-fill me-1"></i> Exportar Excel </Button> <span class="fw-bold ms-3">Total de películas: {filteredData.length}</span> </div> </div> {#if loading} <p class="text-center">Cargando películas...</p> {:else if errorMessage} <p class="alert alert-danger text-center">{errorMessage}</p> {:else} <Table bordered hover responsive class="align-middle"> <thead class="table-light"> <tr> <th scope="col"><i class="bi bi-gear"></i> Acciones</th> <th scope="col" class="cursor-pointer text-end" on:click={() => toggleSort('id')}> ID {#if sortColumn === 'id'} {sortDirection === 1 ? '▲' : '▼'} {/if} </th> <th scope="col" class="cursor-pointer" on:click={() => toggleSort('name')}> Nombre {#if sortColumn === 'name'} {sortDirection === 1 ? '▲' : '▼'} {/if} </th> <th scope="col" class="cursor-pointer text-end" on:click={() => toggleSort('price')}> Precio (EUR) {#if sortColumn === 'price'} {sortDirection === 1 ? '▲' : '▼'} {/if} </th> <th scope="col" class="cursor-pointer text-center" on:click={() => toggleSort('date')}> Fecha {#if sortColumn === 'date'} {sortDirection === 1 ? '▲' : '▼'} {/if} </th> <th scope="col" class="cursor-pointer text-center" on:click={() => toggleSort('rating')}> Valoración {#if sortColumn === 'rating'} {sortDirection === 1 ? '▲' : '▼'} {/if} </th> <th scope="col" class="cursor-pointer" on:click={() => toggleSort('theme')}> Tema {#if sortColumn === 'theme'} {sortDirection === 1 ? '▲' : '▼'} {/if} </th> <th scope="col" class="cursor-pointer" on:click={() => toggleSort('support')}> Soporte {#if sortColumn === 'support'} {sortDirection === 1 ? '▲' : '▼'} {/if} </th> </tr> </thead> <tbody> {#if pagedData.length === 0} <tr><td colspan="8" class="text-center">No hay películas que coincidan con los filtros.</td></tr> {:else} {#each pagedData as row (row.id)} <tr> <td> <div class="d-flex gap-2"> <Button size="sm" color="info" on:click={() => handleView(row)} title="Visualizar"> <i class="bi bi-eye"></i> </Button> <Button size="sm" color="warning" on:click={() => handleEdit(row)} title="Editar"> <i class="bi bi-pencil"></i> </Button> <Button size="sm" color="danger" on:click={() => handleDelete(row.id, row.name)} title="Eliminar"> <i class="bi bi-trash"></i> </Button> </div> </td> <td class="text-end">{row.id}</td> <td>{row.name}</td> <td class="text-end">{formatPrice(row.price)}</td> <td class="text-center">{row.date.toLocaleDateString()}</td> <td class="text-center"> <div class="star-rating"> {#each Array(5) as _, i} <i class="bi star disabled" class:bi-star-fill={i < row.rating} class:bi-star={i >= row.rating}></i> {/each} </div> </td> <td>{row.theme}</td> <td>{row.support}</td> </tr> {/each} {/if} </tbody> </Table> {/if} {#if totalPages > 1} <div class="d-flex justify-content-center"> <Pagination aria-label="Navegación de páginas"> <PaginationItem disabled={page === 1}> <PaginationLink previous href="#" on:click={(e) => { e.preventDefault(); prevPage(); }} /> </PaginationItem> {#each Array(totalPages) as _, i} <PaginationItem active={i + 1 === page}> <PaginationLink href="#" on:click={(e) => { e.preventDefault(); goToPage(i + 1); }}> {i + 1} </PaginationLink> </PaginationItem> {/each} <PaginationItem disabled={page === totalPages}> <PaginationLink next href="#" on:click={(e) => { e.preventDefault(); nextPage(); }} /> </PaginationItem> </Pagination> </div> {/if} <Modal isOpen={showModal} toggle={closeModal} centered scrollable size="md"> <ModalHeader toggle={closeModal}> {#if modalMode === 'add'}Añadir Película{:else if modalMode === 'edit'}Editar Película{:else}Detalles de la película{/if} </ModalHeader> <ModalBody> {#if modalMode === 'add' || modalMode === 'edit'} <form on:submit|preventDefault={handleSave}> <Row> <Col xs="12" md="6" class="mb-3"> <label for="name" class="form-label">Nombre</label> <Input id="name" type="text" bind:value={selectedMovie.name} required /> </Col> <Col xs="12" md="6" class="mb-3"> <label for="price" class="form-label">Precio (EUR)</label> <Input id="price" type="number" step="0.01" bind:value={selectedMovie.price} required /> </Col> <Col xs="12" md="6" class="mb-3"> <label for="date" class="form-label">Fecha</label> <Input id="date" type="date" bind:value={selectedMovie.date} required /> </Col> <Col xs="12" md="6" class="mb-3"> <label class="form-label" for="star-rating-input">Valoración (1-5)</label> <div id="star-rating-input" class="star-rating"> {#each Array(5) as _, i} <i class="bi star" class:bi-star-fill={i < selectedMovie.rating} class:bi-star={i >= selectedMovie.rating} role="button" tabindex="0" on:click={() => selectedMovie = { ...selectedMovie, rating: i + 1 }} on:keydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { selectedMovie = { ...selectedMovie, rating: i + 1 }; e.preventDefault(); } }} ></i> {/each} </div> </Col> <Col xs="12" md="6" class="mb-3"> <label for="theme" class="form-label">Tema</label> <Input id="theme" type="select" bind:value={selectedMovie.theme_id} required> {#each themes as theme (theme.id_theme)} <option value={String(theme.id_theme)}>{theme.theme}</option> {/each} </Input> </Col> <Col xs="12" md="6" class="mb-3"> <label for="support" class="form-label">Soporte</label> <Input id="support" type="select" bind:value={selectedMovie.support_id} required> {#each supports as support (support.id_support)} <option value={String(support.id_support)}>{support.support}</option> {/each} </Input> </Col> </Row> </form> {:else if modalMode === 'view'} {#if selectedMovie} <Row> <Col xs="12" md="6" class="mb-3"> <label for="id-view" class="form-label">ID</label> <Input id="id-view" type="text" value={selectedMovie.id} readonly disabled /> </Col> <Col xs="12" md="6" class="mb-3"> <label for="name-view" class="form-label">Nombre</label> <Input id="name-view" type="text" value={selectedMovie.name} readonly disabled /> </Col> <Col xs="12" md="6" class="mb-3"> <label for="price-view" class="form-label">Precio (EUR)</label> <Input id="price-view" type="number" value={selectedMovie.price} readonly disabled /> </Col> <Col xs="12" md="6" class="mb-3"> <label for="date-view" class="form-label">Fecha</label> <Input id="date-view" type="date" value={selectedMovie.date} readonly disabled /> </Col> <Col xs="12" md="6" class="mb-3"> <label for="rating-view" class="form-label">Valoración (1-5)</label> <div id="rating-view" class="star-rating"> {#each Array(5) as _, i} <i class="bi star disabled" class:bi-star-fill={i < selectedMovie.rating} class:bi-star={i >= selectedMovie.rating}></i> {/each} </div> </Col> <Col xs="12" md="6" class="mb-3"> <label for="theme-view" class="form-label">Tema</label> <Input id="theme-view" type="text" value={selectedMovie.theme} readonly disabled /> </Col> <Col xs="12" md="6" class="mb-3"> <label for="support-view" class="form-label">Soporte</label> <Input id="support-view" type="text" value={selectedMovie.support} readonly disabled /> </Col> </Row> {/if} {/if} </ModalBody> <ModalFooter> {#if modalMode === 'add' || modalMode === 'edit'} <Button color="success" on:click={handleSave}> {#if modalMode === 'add'}Guardar{:else}Actualizar{/if} </Button> {/if} <Button color="secondary" on:click={closeModal}>Cerrar</Button> </ModalFooter> </Modal> </Container>
Espero que lo disfrutéis, igual que he hecho yo y para cualquier cosa, estoy a vuestra disposición.
Os dejo los ejercicios que os he comentado, para que los descarguéis, probéis y cambiéis para ajustarlos a vuestros requisitos.