diff --git a/hifi-src/src/App.jsx b/hifi-src/src/App.jsx index 59137c4..682c5e4 100644 --- a/hifi-src/src/App.jsx +++ b/hifi-src/src/App.jsx @@ -10,6 +10,9 @@ import Home from './pages/public/Home.jsx'; import VehicleSelect from './pages/public/VehicleSelect.jsx'; import BrandPage from './pages/public/BrandPage.jsx'; import ModelPage from './pages/public/ModelPage.jsx'; +import GalleryOverview from './pages/public/GalleryOverview.jsx'; +import GalleryBrandPage from './pages/public/GalleryBrandPage.jsx'; +import GalleryProjectPage from './pages/public/GalleryProjectPage.jsx'; import Leistungen from './pages/public/Leistungen.jsx'; import ContactPage from './pages/public/ContactPage.jsx'; import Impressum from './pages/public/Impressum.jsx'; @@ -21,6 +24,9 @@ import Login from './pages/admin/Login.jsx'; import Dashboard from './pages/admin/Dashboard.jsx'; import Brands from './pages/admin/Brands.jsx'; import Models from './pages/admin/Models.jsx'; +import GalleryBrands from './pages/admin/GalleryBrands.jsx'; +import GalleryProjects from './pages/admin/GalleryProjects.jsx'; +import GalleryPhotos from './pages/admin/GalleryPhotos.jsx'; import Packages from './pages/admin/Packages.jsx'; import Services from './pages/admin/Services.jsx'; import PackageProducts from './pages/admin/PackageProducts.jsx'; @@ -49,6 +55,9 @@ export default function App() { } /> } /> } /> + } /> + } /> + } /> } /> } /> } /> @@ -64,6 +73,9 @@ export default function App() { } /> } /> } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/hifi-src/src/components/AdminLayout.jsx b/hifi-src/src/components/AdminLayout.jsx index fbc6c7f..f7e1a4d 100644 --- a/hifi-src/src/components/AdminLayout.jsx +++ b/hifi-src/src/components/AdminLayout.jsx @@ -16,6 +16,7 @@ const ICONS = { lock: 'M6 10V8a6 6 0 1 1 12 0v2m-13 0h14a1 1 0 0 1 1 1v9a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1v-9a1 1 0 0 1 1-1Zm7 5v2', briefcase: 'M4 7h16a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V8a1 1 0 0 1 1-1Zm4 0V5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M3 12h18', sliders: 'M4 6h16M4 6a2 2 0 1 0 4 0 2 2 0 0 0-4 0Zm16 6H4m10 0a2 2 0 1 0 4 0 2 2 0 0 0-4 0ZM4 18h16m-12 0a2 2 0 1 0 4 0 2 2 0 0 0-4 0Z', + image: 'M4 5h16a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1Zm2 12 4.5-5.5L14 16l2.5-3L20 17H6ZM8 9.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0Z', }; const Icon = ({ path }) => ( @@ -57,6 +58,13 @@ export default function AdminLayout() { { to: '/admin/services', label: 'Leistungen', icon: 'briefcase', permission: 'services.manage' }, ], }, + { + title: 'Bildergalerie', + links: [ + { to: '/admin/gallery-brands', label: 'Marken', icon: 'tag', permission: 'gallery.manage' }, + { to: '/admin/gallery-projects', label: 'Projekte', icon: 'image', permission: 'gallery.manage' }, + ], + }, { title: 'Kunden', links: [ diff --git a/hifi-src/src/components/Lightbox.jsx b/hifi-src/src/components/Lightbox.jsx new file mode 100644 index 0000000..c29f421 --- /dev/null +++ b/hifi-src/src/components/Lightbox.jsx @@ -0,0 +1,71 @@ +import { useEffect } from 'react'; +import { createPortal } from 'react-dom'; + +export default function Lightbox({ photos, index, onClose, onNavigate }) { + useEffect(() => { + if (index == null) return undefined; + const handleKey = (e) => { + if (e.key === 'Escape') onClose(); + if (e.key === 'ArrowRight') onNavigate(1); + if (e.key === 'ArrowLeft') onNavigate(-1); + }; + window.addEventListener('keydown', handleKey); + return () => window.removeEventListener('keydown', handleKey); + }, [index, onClose, onNavigate]); + + if (index == null) return null; + const photo = photos[index]; + const stop = (e) => e.stopPropagation(); + + return createPortal( +
+ + + {photos.length > 1 && ( + + )} + + {photo.caption + + {photos.length > 1 && ( + + )} + + {photo.caption && ( +

+ {photo.caption} +

+ )} +
, + document.body + ); +} diff --git a/hifi-src/src/components/Navbar.jsx b/hifi-src/src/components/Navbar.jsx index 967f291..d6adbee 100644 --- a/hifi-src/src/components/Navbar.jsx +++ b/hifi-src/src/components/Navbar.jsx @@ -87,6 +87,7 @@ export default function Navbar() { @@ -125,14 +126,17 @@ export default function Navbar() { setOpen(false)} className={`text-3xl font-extrabold tracking-tight text-neutral-900 hover:text-brand-600 ${flyIn()}`} style={flyInStyle(1)}> Leistungen - setOpen(false)} className={`text-3xl font-extrabold tracking-tight text-neutral-900 hover:text-brand-600 ${flyIn()}`} style={flyInStyle(2)}> + setOpen(false)} className={`text-3xl font-extrabold tracking-tight text-neutral-900 hover:text-brand-600 ${flyIn()}`} style={flyInStyle(2)}> + Galerie + + setOpen(false)} className={`text-3xl font-extrabold tracking-tight text-neutral-900 hover:text-brand-600 ${flyIn()}`} style={flyInStyle(3)}> Kontakt -
+
{phone && ( - + {phone} @@ -143,7 +147,7 @@ export default function Navbar() { target="_blank" rel="noopener noreferrer" className={`flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-neutral-600 hover:text-brand-600 ${flyIn()}`} - style={flyInStyle(5)} + style={flyInStyle(6)} > WhatsApp @@ -154,7 +158,7 @@ export default function Navbar() { target="_blank" rel="noopener noreferrer" className={`flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-neutral-600 hover:text-brand-600 ${flyIn()}`} - style={flyInStyle(whatsapp ? 6 : 5)} + style={flyInStyle(whatsapp ? 7 : 6)} > Zum Shop diff --git a/hifi-src/src/pages/admin/GalleryBrands.jsx b/hifi-src/src/pages/admin/GalleryBrands.jsx new file mode 100644 index 0000000..912f322 --- /dev/null +++ b/hifi-src/src/pages/admin/GalleryBrands.jsx @@ -0,0 +1,137 @@ +import { useEffect, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { api } from '../../api/client.js'; +import ImageUploadField from '../../components/ImageUploadField.jsx'; + +const emptyForm = { name: '', cover_image_path: '', sort_order: 0 }; + +export default function GalleryBrands() { + const [brands, setBrands] = useState([]); + const [form, setForm] = useState(emptyForm); + const [editingId, setEditingId] = useState(null); + const [error, setError] = useState(''); + + const load = () => api.get('/gallery-brands').then(setBrands); + + useEffect(() => { + load(); + }, []); + + const startEdit = (brand) => { + setEditingId(brand.id); + setForm({ name: brand.name, cover_image_path: brand.cover_image_path || '', sort_order: brand.sort_order }); + }; + + const resetForm = () => { + setEditingId(null); + setForm(emptyForm); + }; + + const handleSubmit = async (e) => { + e.preventDefault(); + setError(''); + try { + if (editingId) { + await api.put(`/gallery-brands/${editingId}`, form); + } else { + await api.post('/gallery-brands', form); + } + resetForm(); + load(); + } catch (err) { + setError(err.message); + } + }; + + const handleDelete = async (id) => { + if (!confirm('Marke inkl. aller Projekte/Fotos wirklich löschen?')) return; + await api.delete(`/gallery-brands/${id}`); + load(); + }; + + return ( +
+
+

Galerie-Marken

+
+ + + + + + + + + + + {brands.map((brand) => ( + + + + + + + ))} + {brands.length === 0 && ( + + )} + +
BildNameSortierungAktionen
+ {brand.cover_image_path ? ( + + ) : ( + + )} + + + {brand.name} + + {brand.sort_order} + + +
Noch keine Galerie-Marken angelegt.
+
+
+ +
+

{editingId ? 'Marke bearbeiten' : 'Neue Marke'}

+
+ + setForm({ ...form, name: e.target.value })} + className="w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-sm dark:border-neutral-700 dark:bg-neutral-900" + /> +
+ setForm({ ...form, cover_image_path: path })} + label="Titelbild (optional)" + /> +
+ + setForm({ ...form, sort_order: Number(e.target.value) })} + className="w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-sm dark:border-neutral-700 dark:bg-neutral-900" + /> +
+ + {error &&

{error}

} + +
+ + {editingId && ( + + )} +
+ +
+ ); +} diff --git a/hifi-src/src/pages/admin/GalleryPhotos.jsx b/hifi-src/src/pages/admin/GalleryPhotos.jsx new file mode 100644 index 0000000..1271770 --- /dev/null +++ b/hifi-src/src/pages/admin/GalleryPhotos.jsx @@ -0,0 +1,121 @@ +import { useEffect, useRef, useState } from 'react'; +import { Link, useParams } from 'react-router-dom'; +import { api } from '../../api/client.js'; + +export default function GalleryPhotos() { + const { projectId } = useParams(); + const [photos, setPhotos] = useState([]); + const [error, setError] = useState(''); + const [uploading, setUploading] = useState(false); + const [uploadProgress, setUploadProgress] = useState(''); + const fileInputRef = useRef(null); + + const load = () => api.get(`/gallery-projects/${projectId}/photos`).then(setPhotos); + + useEffect(() => { + load(); + }, [projectId]); + + const handleFiles = async (e) => { + const files = Array.from(e.target.files || []); + if (files.length === 0) return; + setError(''); + setUploading(true); + try { + for (let i = 0; i < files.length; i++) { + setUploadProgress(`Lade Bild ${i + 1} von ${files.length} hoch…`); + const formData = new FormData(); + formData.append('file', files[i]); + const result = await api.post('/uploads', formData); + await api.post('/gallery-photos', { + gallery_project_id: Number(projectId), + image_path: result.path, + sort_order: photos.length + i, + }); + } + load(); + } catch (err) { + setError(err.message); + } finally { + setUploading(false); + setUploadProgress(''); + if (fileInputRef.current) fileInputRef.current.value = ''; + } + }; + + const handleCaptionChange = (photo, caption) => { + setPhotos((prev) => prev.map((p) => (p.id === photo.id ? { ...p, caption } : p))); + }; + + const handleSortChange = (photo, sort_order) => { + setPhotos((prev) => prev.map((p) => (p.id === photo.id ? { ...p, sort_order } : p))); + }; + + const handleSave = async (photo) => { + await api.put(`/gallery-photos/${photo.id}`, { caption: photo.caption, sort_order: photo.sort_order }); + load(); + }; + + const handleDelete = async (id) => { + if (!confirm('Foto wirklich entfernen?')) return; + await api.delete(`/gallery-photos/${id}`); + load(); + }; + + return ( +
+

+ ← Zurück zu den Projekten +

+

Fotos im Projekt

+ +
+ + + {uploading &&

{uploadProgress}

} +
+ + {error &&

{error}

} + +
+ {photos.map((photo) => ( +
+ {photo.caption +
+ handleCaptionChange(photo, e.target.value)} + onBlur={() => handleSave(photo)} + placeholder="Bildunterschrift (optional)" + className="w-full rounded-md border border-neutral-300 bg-white px-2 py-1 text-xs dark:border-neutral-700 dark:bg-neutral-900" + /> +
+ handleSortChange(photo, Number(e.target.value))} + onBlur={() => handleSave(photo)} + className="w-16 rounded-md border border-neutral-300 bg-white px-2 py-1 text-xs dark:border-neutral-700 dark:bg-neutral-900" + /> + +
+
+
+ ))} + {photos.length === 0 && ( +

Noch keine Fotos in diesem Projekt.

+ )} +
+
+ ); +} diff --git a/hifi-src/src/pages/admin/GalleryProjects.jsx b/hifi-src/src/pages/admin/GalleryProjects.jsx new file mode 100644 index 0000000..48a26a4 --- /dev/null +++ b/hifi-src/src/pages/admin/GalleryProjects.jsx @@ -0,0 +1,174 @@ +import { useEffect, useMemo, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { api } from '../../api/client.js'; +import ImageUploadField from '../../components/ImageUploadField.jsx'; + +const emptyForm = { name: '', gallery_brand_id: '', cover_image_path: '', sort_order: 0 }; + +export default function GalleryProjects() { + const [projects, setProjects] = useState([]); + const [brands, setBrands] = useState([]); + const [form, setForm] = useState(emptyForm); + const [editingId, setEditingId] = useState(null); + const [error, setError] = useState(''); + const [brandFilter, setBrandFilter] = useState('all'); + + const filteredProjects = useMemo( + () => (brandFilter === 'all' ? projects : projects.filter((p) => String(p.gallery_brand_id) === brandFilter)), + [projects, brandFilter] + ); + + const load = () => { + api.get('/gallery-projects').then(setProjects); + api.get('/gallery-brands').then(setBrands); + }; + + useEffect(load, []); + + const startEdit = (project) => { + setEditingId(project.id); + setForm({ + name: project.name, + gallery_brand_id: project.gallery_brand_id, + cover_image_path: project.cover_image_path || '', + sort_order: project.sort_order, + }); + }; + + const resetForm = () => { + setEditingId(null); + setForm(emptyForm); + }; + + const handleSubmit = async (e) => { + e.preventDefault(); + setError(''); + const payload = { ...form, gallery_brand_id: Number(form.gallery_brand_id) }; + try { + if (editingId) { + await api.put(`/gallery-projects/${editingId}`, payload); + } else { + await api.post('/gallery-projects', payload); + } + resetForm(); + load(); + } catch (err) { + setError(err.message); + } + }; + + const handleDelete = async (id) => { + if (!confirm('Projekt inkl. aller Fotos wirklich löschen?')) return; + await api.delete(`/gallery-projects/${id}`); + load(); + }; + + return ( +
+
+

Galerie-Projekte

+ +
+ + + {brandFilter !== 'all' && ( + + )} + {filteredProjects.length} von {projects.length} +
+ +
+ + + + + + + + + + {filteredProjects.map((project) => ( + + + + + + ))} + {filteredProjects.length === 0 && ( + + )} + +
MarkeProjektAktionen
{project.brand_name}{project.name} + Fotos + + +
+ {projects.length === 0 ? 'Noch keine Projekte angelegt.' : 'Keine Projekte für diese Marke.'} +
+
+
+ +
+

{editingId ? 'Projekt bearbeiten' : 'Neues Projekt'}

+
+ + +
+
+ + setForm({ ...form, name: e.target.value })} + className="w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-sm dark:border-neutral-700 dark:bg-neutral-900" + /> +
+ setForm({ ...form, cover_image_path: path })} + label="Titelbild (optional)" + /> +
+ + setForm({ ...form, sort_order: Number(e.target.value) })} + className="w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-sm dark:border-neutral-700 dark:bg-neutral-900" + /> +
+ + {error &&

{error}

} + +
+ + {editingId && ( + + )} +
+ +
+ ); +} diff --git a/hifi-src/src/pages/public/GalleryBrandPage.jsx b/hifi-src/src/pages/public/GalleryBrandPage.jsx new file mode 100644 index 0000000..e80b79f --- /dev/null +++ b/hifi-src/src/pages/public/GalleryBrandPage.jsx @@ -0,0 +1,65 @@ +import { useEffect, useState } from 'react'; +import { Link, useParams } from 'react-router-dom'; +import { api } from '../../api/client.js'; +import usePageMeta from '../../hooks/usePageMeta.js'; + +export default function GalleryBrandPage() { + const { brandSlug } = useParams(); + const [data, setData] = useState(null); + const [error, setError] = useState(''); + + useEffect(() => { + api.get(`/gallery-brands/${brandSlug}/projects`).then(setData).catch((e) => setError(e.message)); + }, [brandSlug]); + + usePageMeta({ + title: data ? `${data.brand.name} Umbauten` : 'Bildergalerie', + description: data ? `Einblicke in unsere ${data.brand.name} Car-Hifi Umbauten.` : undefined, + path: `/galerie/${brandSlug}`, + }); + + if (error) { + return

{error}

; + } + + if (!data) { + return

Lädt…

; + } + + return ( +
+

+ Galerie / {data.brand.name} +

+

+ {data.brand.name} – Projekt wählen +

+ + {data.projects.length === 0 && ( +

Für diese Marke sind noch keine Projekte hinterlegt.

+ )} + +
+ {data.projects.map((project) => ( + + {project.cover_image_path ? ( + {project.name} + ) : ( +
+ )} +
+ {project.name} + + ))} +
+
+ ); +} diff --git a/hifi-src/src/pages/public/GalleryOverview.jsx b/hifi-src/src/pages/public/GalleryOverview.jsx new file mode 100644 index 0000000..a372bd6 --- /dev/null +++ b/hifi-src/src/pages/public/GalleryOverview.jsx @@ -0,0 +1,56 @@ +import { useEffect, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { api } from '../../api/client.js'; +import usePageMeta from '../../hooks/usePageMeta.js'; + +export default function GalleryOverview() { + const [brands, setBrands] = useState(null); + const [error, setError] = useState(''); + + usePageMeta({ + title: 'Bildergalerie', + description: 'Einblicke in unsere Car-Hifi Umbauten – nach Marke sortiert. Wähle eine Marke und entdecke die Projekte.', + path: '/galerie', + }); + + useEffect(() => { + api.get('/gallery-brands').then(setBrands).catch((e) => setError(e.message)); + }, []); + + return ( +
+

Bildergalerie

+

+ Wähle eine Marke und entdecke unsere Umbauten im Detail. +

+ + {error &&

{error}

} + {!brands && !error &&

Lädt…

} + {brands && brands.length === 0 && ( +

Es sind noch keine Galerie-Marken hinterlegt.

+ )} + +
+ {brands?.map((brand) => ( + + {brand.cover_image_path ? ( + {brand.name} + ) : ( +
+ )} +
+ {brand.name} + + ))} +
+
+ ); +} diff --git a/hifi-src/src/pages/public/GalleryProjectPage.jsx b/hifi-src/src/pages/public/GalleryProjectPage.jsx new file mode 100644 index 0000000..2fc1a21 --- /dev/null +++ b/hifi-src/src/pages/public/GalleryProjectPage.jsx @@ -0,0 +1,75 @@ +import { useState, useEffect } from 'react'; +import { Link, useParams } from 'react-router-dom'; +import { api } from '../../api/client.js'; +import usePageMeta from '../../hooks/usePageMeta.js'; +import Reveal from '../../components/Reveal.jsx'; +import Lightbox from '../../components/Lightbox.jsx'; + +export default function GalleryProjectPage() { + const { brandSlug, projectSlug } = useParams(); + const [data, setData] = useState(null); + const [error, setError] = useState(''); + const [openIndex, setOpenIndex] = useState(null); + + useEffect(() => { + api + .get(`/gallery-brands/${brandSlug}/${projectSlug}/photos`) + .then(setData) + .catch((e) => setError(e.message)); + }, [brandSlug, projectSlug]); + + usePageMeta({ + title: data ? `${data.project.name} – ${data.project.brand_name}` : 'Bildergalerie', + description: data ? `Fotos unseres ${data.project.name} Umbaus.` : undefined, + path: `/galerie/${brandSlug}/${projectSlug}`, + }); + + const navigate = (delta) => { + setOpenIndex((i) => { + const total = data.photos.length; + return (i + delta + total) % total; + }); + }; + + if (error) { + return

{error}

; + } + + if (!data) { + return

Lädt…

; + } + + return ( +
+

+ Galerie /{' '} + {data.project.brand_name} / {data.project.name} +

+

{data.project.name}

+ + {data.photos.length === 0 && ( +

Für dieses Projekt sind noch keine Fotos hinterlegt.

+ )} + +
+ {data.photos.map((photo, i) => ( + + + + ))} +
+ + setOpenIndex(null)} onNavigate={navigate} /> +
+ ); +} diff --git a/hifi/.htaccess b/hifi/.htaccess index 7f3bdef..4efb384 100644 --- a/hifi/.htaccess +++ b/hifi/.htaccess @@ -1,6 +1,13 @@ RewriteEngine On RewriteBase / +# Ohne dies leitet Apache (mod_dir) Anfragen an /api/uploads mit einem 301 auf +# /api/uploads/ um, bevor mod_rewrite greifen kann - weil api/uploads/ als +# echtes Verzeichnis existiert. Bei einem POST mit Datei-Upload geht dabei der +# Body verloren (der Browser sendet den redirect als GET nach), wodurch der +# Upload mit "Methode nicht erlaubt" fehlschlägt. +DirectorySlash Off + # API-Requests an den PHP-Front-Controller weiterleiten RewriteCond %{REQUEST_URI} ^/api/ RewriteCond %{REQUEST_FILENAME} !-f diff --git a/hifi/api/database/schema.sql b/hifi/api/database/schema.sql index 209966f..5f21c78 100644 --- a/hifi/api/database/schema.sql +++ b/hifi/api/database/schema.sql @@ -91,6 +91,40 @@ CREATE TABLE car_models ( CONSTRAINT fk_car_models_brand FOREIGN KEY (brand_id) REFERENCES brands(id) ON DELETE CASCADE ) ENGINE=InnoDB; +CREATE TABLE gallery_brands ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(100) NOT NULL, + slug VARCHAR(120) NOT NULL UNIQUE, + cover_image_path VARCHAR(255) NULL, + sort_order INT NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +) ENGINE=InnoDB; + +CREATE TABLE gallery_projects ( + id INT AUTO_INCREMENT PRIMARY KEY, + gallery_brand_id INT NOT NULL, + name VARCHAR(100) NOT NULL, + slug VARCHAR(150) NOT NULL, + cover_image_path VARCHAR(255) NULL, + sort_order INT NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uniq_gallery_brand_slug (gallery_brand_id, slug), + CONSTRAINT fk_gallery_projects_brand FOREIGN KEY (gallery_brand_id) REFERENCES gallery_brands(id) ON DELETE CASCADE +) ENGINE=InnoDB; + +CREATE TABLE gallery_photos ( + id INT AUTO_INCREMENT PRIMARY KEY, + gallery_project_id INT NOT NULL, + image_path VARCHAR(255) NOT NULL, + caption VARCHAR(255) NULL, + sort_order INT NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_gallery_photos_project FOREIGN KEY (gallery_project_id) REFERENCES gallery_projects(id) ON DELETE CASCADE +) ENGINE=InnoDB; + CREATE TABLE packages ( id INT AUTO_INCREMENT PRIMARY KEY, car_model_id INT NOT NULL, diff --git a/hifi/api/public/index.php b/hifi/api/public/index.php index 2901048..0f1f471 100644 --- a/hifi/api/public/index.php +++ b/hifi/api/public/index.php @@ -13,6 +13,9 @@ use App\Controllers\AuthController; use App\Controllers\BrandController; use App\Controllers\ContactController; use App\Controllers\DatabaseConfigController; +use App\Controllers\GalleryBrandController; +use App\Controllers\GalleryPhotoController; +use App\Controllers\GalleryProjectController; use App\Controllers\MaintenanceController; use App\Controllers\ModelController; use App\Controllers\PackageController; @@ -120,12 +123,29 @@ $router->put('/products/{id}', $perm('packages.manage', fn($p) => ProductControl $router->delete('/products/{id}', $perm('packages.manage', fn($p) => ProductController::destroy($p))); $router->post('/products/{id}/refresh-price', $perm('packages.manage', fn($p) => ProductController::refreshPrice($p))); +$router->get('/gallery-brands', fn($p) => GalleryBrandController::index()); +$router->post('/gallery-brands', $perm('gallery.manage', fn($p) => GalleryBrandController::store())); +$router->put('/gallery-brands/{id}', $perm('gallery.manage', fn($p) => GalleryBrandController::update($p))); +$router->delete('/gallery-brands/{id}', $perm('gallery.manage', fn($p) => GalleryBrandController::destroy($p))); +$router->get('/gallery-brands/{slug}/projects', fn($p) => GalleryBrandController::projectsForBrand($p)); + +$router->get('/gallery-projects', $perm('gallery.manage', fn($p) => GalleryProjectController::index())); +$router->post('/gallery-projects', $perm('gallery.manage', fn($p) => GalleryProjectController::store())); +$router->put('/gallery-projects/{id}', $perm('gallery.manage', fn($p) => GalleryProjectController::update($p))); +$router->delete('/gallery-projects/{id}', $perm('gallery.manage', fn($p) => GalleryProjectController::destroy($p))); +$router->get('/gallery-projects/{id}/photos', $perm('gallery.manage', fn($p) => GalleryProjectController::photosAdmin($p))); +$router->get('/gallery-brands/{brand_slug}/{project_slug}/photos', fn($p) => GalleryProjectController::photosForProject($p)); + +$router->post('/gallery-photos', $perm('gallery.manage', fn($p) => GalleryPhotoController::store())); +$router->put('/gallery-photos/{id}', $perm('gallery.manage', fn($p) => GalleryPhotoController::update($p))); +$router->delete('/gallery-photos/{id}', $perm('gallery.manage', fn($p) => GalleryPhotoController::destroy($p))); + $router->post('/contact', $maint(null, fn($p) => ContactController::store())); $router->get('/contact', $perm('contact.manage', fn($p) => ContactController::index())); $router->patch('/contact/{id}', $perm('contact.manage', fn($p) => ContactController::updateStatus($p))); $router->delete('/contact/{id}', $perm('contact.delete', fn($p) => ContactController::destroy($p))); -$router->post('/uploads', $anyPerm(['brands.manage', 'models.manage', 'services.manage', 'settings.manage'], fn($p) => UploadController::store())); +$router->post('/uploads', $anyPerm(['brands.manage', 'models.manage', 'services.manage', 'settings.manage', 'gallery.manage'], fn($p) => UploadController::store())); $router->get('/services', $maint('services', fn($p) => ServiceController::index())); $router->post('/services', $perm('services.manage', fn($p) => ServiceController::store())); diff --git a/hifi/api/src/Controllers/GalleryBrandController.php b/hifi/api/src/Controllers/GalleryBrandController.php new file mode 100644 index 0000000..d986d41 --- /dev/null +++ b/hifi/api/src/Controllers/GalleryBrandController.php @@ -0,0 +1,90 @@ +query( + 'SELECT id, name, slug, cover_image_path, sort_order FROM gallery_brands ORDER BY sort_order, name' + ); + Http::send($stmt->fetchAll()); + } + + public static function store(): void + { + $body = Http::jsonBody(); + $name = trim($body['name'] ?? ''); + if ($name === '') { + Http::error('Name erforderlich', 422); + } + + $slug = Slug::make($name); + $db = Database::connection(); + $stmt = $db->prepare( + 'INSERT INTO gallery_brands (name, slug, cover_image_path, sort_order) VALUES (?, ?, ?, ?)' + ); + $stmt->execute([ + $name, + $slug, + trim($body['cover_image_path'] ?? '') ?: null, + (int) ($body['sort_order'] ?? 0), + ]); + + Http::send(['id' => (int) $db->lastInsertId(), 'name' => $name, 'slug' => $slug], 201); + } + + public static function update(array $params): void + { + $id = (int) $params['id']; + $body = Http::jsonBody(); + $name = trim($body['name'] ?? ''); + if ($name === '') { + Http::error('Name erforderlich', 422); + } + + $stmt = Database::connection()->prepare( + 'UPDATE gallery_brands SET name = ?, slug = ?, cover_image_path = ?, sort_order = ? WHERE id = ?' + ); + $stmt->execute([ + $name, + Slug::make($name), + trim($body['cover_image_path'] ?? '') ?: null, + (int) ($body['sort_order'] ?? 0), + $id, + ]); + + Http::send(['ok' => true]); + } + + public static function destroy(array $params): void + { + $stmt = Database::connection()->prepare('DELETE FROM gallery_brands WHERE id = ?'); + $stmt->execute([(int) $params['id']]); + Http::send(['ok' => true]); + } + + public static function projectsForBrand(array $params): void + { + $db = Database::connection(); + $brandStmt = $db->prepare('SELECT id, name, slug, cover_image_path FROM gallery_brands WHERE slug = ?'); + $brandStmt->execute([$params['slug']]); + $brand = $brandStmt->fetch(); + + if (!$brand) { + Http::error('Marke nicht gefunden', 404); + } + + $stmt = $db->prepare( + 'SELECT id, name, slug, cover_image_path, sort_order FROM gallery_projects WHERE gallery_brand_id = ? ORDER BY sort_order, name' + ); + $stmt->execute([$brand['id']]); + + Http::send(['brand' => $brand, 'projects' => $stmt->fetchAll()]); + } +} diff --git a/hifi/api/src/Controllers/GalleryPhotoController.php b/hifi/api/src/Controllers/GalleryPhotoController.php new file mode 100644 index 0000000..24992a6 --- /dev/null +++ b/hifi/api/src/Controllers/GalleryPhotoController.php @@ -0,0 +1,57 @@ +prepare( + 'INSERT INTO gallery_photos (gallery_project_id, image_path, caption, sort_order) VALUES (?, ?, ?, ?)' + ); + $stmt->execute([ + $projectId, + $imagePath, + trim($body['caption'] ?? '') ?: null, + (int) ($body['sort_order'] ?? 0), + ]); + + Http::send(['id' => (int) $db->lastInsertId()], 201); + } + + public static function update(array $params): void + { + $id = (int) $params['id']; + $body = Http::jsonBody(); + + $stmt = Database::connection()->prepare( + 'UPDATE gallery_photos SET caption = ?, sort_order = ? WHERE id = ?' + ); + $stmt->execute([ + trim($body['caption'] ?? '') ?: null, + (int) ($body['sort_order'] ?? 0), + $id, + ]); + + Http::send(['ok' => true]); + } + + public static function destroy(array $params): void + { + $stmt = Database::connection()->prepare('DELETE FROM gallery_photos WHERE id = ?'); + $stmt->execute([(int) $params['id']]); + Http::send(['ok' => true]); + } +} diff --git a/hifi/api/src/Controllers/GalleryProjectController.php b/hifi/api/src/Controllers/GalleryProjectController.php new file mode 100644 index 0000000..05aaf3e --- /dev/null +++ b/hifi/api/src/Controllers/GalleryProjectController.php @@ -0,0 +1,113 @@ +query( + 'SELECT p.id, p.name, p.slug, p.cover_image_path, p.sort_order, p.gallery_brand_id, + b.name AS brand_name, b.slug AS brand_slug + FROM gallery_projects p JOIN gallery_brands b ON b.id = p.gallery_brand_id + ORDER BY b.name, p.sort_order, p.name' + ); + Http::send($stmt->fetchAll()); + } + + public static function store(): void + { + $body = Http::jsonBody(); + $name = trim($body['name'] ?? ''); + $brandId = (int) ($body['gallery_brand_id'] ?? 0); + + if ($name === '' || $brandId <= 0) { + Http::error('Name und Marke erforderlich', 422); + } + + $db = Database::connection(); + $stmt = $db->prepare( + 'INSERT INTO gallery_projects (gallery_brand_id, name, slug, cover_image_path, sort_order) VALUES (?, ?, ?, ?, ?)' + ); + $stmt->execute([ + $brandId, + $name, + Slug::make($name), + trim($body['cover_image_path'] ?? '') ?: null, + (int) ($body['sort_order'] ?? 0), + ]); + + Http::send(['id' => (int) $db->lastInsertId()], 201); + } + + public static function update(array $params): void + { + $id = (int) $params['id']; + $body = Http::jsonBody(); + $name = trim($body['name'] ?? ''); + $brandId = (int) ($body['gallery_brand_id'] ?? 0); + + if ($name === '' || $brandId <= 0) { + Http::error('Name und Marke erforderlich', 422); + } + + $stmt = Database::connection()->prepare( + 'UPDATE gallery_projects SET gallery_brand_id = ?, name = ?, slug = ?, cover_image_path = ?, sort_order = ? WHERE id = ?' + ); + $stmt->execute([ + $brandId, + $name, + Slug::make($name), + trim($body['cover_image_path'] ?? '') ?: null, + (int) ($body['sort_order'] ?? 0), + $id, + ]); + + Http::send(['ok' => true]); + } + + public static function destroy(array $params): void + { + $stmt = Database::connection()->prepare('DELETE FROM gallery_projects WHERE id = ?'); + $stmt->execute([(int) $params['id']]); + Http::send(['ok' => true]); + } + + /** Fotos eines Projekts für die Admin-Verwaltungsseite (per numerischer Projekt-ID). */ + public static function photosAdmin(array $params): void + { + $stmt = Database::connection()->prepare( + 'SELECT id, gallery_project_id, image_path, caption, sort_order FROM gallery_photos WHERE gallery_project_id = ? ORDER BY sort_order, id' + ); + $stmt->execute([(int) $params['id']]); + Http::send($stmt->fetchAll()); + } + + /** Öffentliche Ansicht: Projekt + Fotos per Marke-Slug + Projekt-Slug. */ + public static function photosForProject(array $params): void + { + $db = Database::connection(); + $projectStmt = $db->prepare( + 'SELECT p.id, p.name, p.slug, b.name AS brand_name, b.slug AS brand_slug + FROM gallery_projects p JOIN gallery_brands b ON b.id = p.gallery_brand_id + WHERE p.slug = ? AND b.slug = ?' + ); + $projectStmt->execute([$params['project_slug'], $params['brand_slug']]); + $project = $projectStmt->fetch(); + + if (!$project) { + Http::error('Projekt nicht gefunden', 404); + } + + $photoStmt = $db->prepare( + 'SELECT id, image_path, caption, sort_order FROM gallery_photos WHERE gallery_project_id = ? ORDER BY sort_order, id' + ); + $photoStmt->execute([$project['id']]); + + Http::send(['project' => $project, 'photos' => $photoStmt->fetchAll()]); + } +} diff --git a/hifi/api/src/Support/Permissions.php b/hifi/api/src/Support/Permissions.php index 60013e7..180c5a9 100644 --- a/hifi/api/src/Support/Permissions.php +++ b/hifi/api/src/Support/Permissions.php @@ -9,6 +9,7 @@ class Permissions 'models.manage' => 'Modelle verwalten', 'packages.manage' => 'Pakete, Produkte & Upgrades verwalten', 'services.manage' => 'Leistungen verwalten', + 'gallery.manage' => 'Bildergalerie verwalten', 'contact.manage' => 'Kontaktanfragen ansehen & bearbeiten', 'contact.delete' => 'Kontaktanfragen löschen', 'users.manage' => 'Benutzer verwalten', diff --git a/hifi/api/src/Support/Schema.php b/hifi/api/src/Support/Schema.php index 630703e..598fa40 100644 --- a/hifi/api/src/Support/Schema.php +++ b/hifi/api/src/Support/Schema.php @@ -102,6 +102,40 @@ class Schema CONSTRAINT fk_car_models_brand FOREIGN KEY (brand_id) REFERENCES brands(id) ON DELETE CASCADE ) ENGINE=InnoDB", + 'gallery_brands' => "CREATE TABLE gallery_brands ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(100) NOT NULL, + slug VARCHAR(120) NOT NULL UNIQUE, + cover_image_path VARCHAR(255) NULL, + sort_order INT NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + ) ENGINE=InnoDB", + + 'gallery_projects' => "CREATE TABLE gallery_projects ( + id INT AUTO_INCREMENT PRIMARY KEY, + gallery_brand_id INT NOT NULL, + name VARCHAR(100) NOT NULL, + slug VARCHAR(150) NOT NULL, + cover_image_path VARCHAR(255) NULL, + sort_order INT NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uniq_gallery_brand_slug (gallery_brand_id, slug), + CONSTRAINT fk_gallery_projects_brand FOREIGN KEY (gallery_brand_id) REFERENCES gallery_brands(id) ON DELETE CASCADE + ) ENGINE=InnoDB", + + 'gallery_photos' => "CREATE TABLE gallery_photos ( + id INT AUTO_INCREMENT PRIMARY KEY, + gallery_project_id INT NOT NULL, + image_path VARCHAR(255) NOT NULL, + caption VARCHAR(255) NULL, + sort_order INT NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_gallery_photos_project FOREIGN KEY (gallery_project_id) REFERENCES gallery_projects(id) ON DELETE CASCADE + ) ENGINE=InnoDB", + 'packages' => "CREATE TABLE packages ( id INT AUTO_INCREMENT PRIMARY KEY, car_model_id INT NOT NULL, @@ -246,6 +280,34 @@ class Schema 'created_at' => 'DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP', 'updated_at' => 'DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP', ], + 'gallery_brands' => [ + 'id' => 'INT AUTO_INCREMENT PRIMARY KEY', + 'name' => 'VARCHAR(100) NOT NULL', + 'slug' => 'VARCHAR(120) NOT NULL', + 'cover_image_path' => 'VARCHAR(255) NULL', + 'sort_order' => 'INT NOT NULL DEFAULT 0', + 'created_at' => 'DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP', + 'updated_at' => 'DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP', + ], + 'gallery_projects' => [ + 'id' => 'INT AUTO_INCREMENT PRIMARY KEY', + 'gallery_brand_id' => 'INT NOT NULL', + 'name' => 'VARCHAR(100) NOT NULL', + 'slug' => 'VARCHAR(150) NOT NULL', + 'cover_image_path' => 'VARCHAR(255) NULL', + 'sort_order' => 'INT NOT NULL DEFAULT 0', + 'created_at' => 'DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP', + 'updated_at' => 'DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP', + ], + 'gallery_photos' => [ + 'id' => 'INT AUTO_INCREMENT PRIMARY KEY', + 'gallery_project_id' => 'INT NOT NULL', + 'image_path' => 'VARCHAR(255) NOT NULL', + 'caption' => 'VARCHAR(255) NULL', + 'sort_order' => 'INT NOT NULL DEFAULT 0', + 'created_at' => 'DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP', + 'updated_at' => 'DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP', + ], 'packages' => [ 'id' => 'INT AUTO_INCREMENT PRIMARY KEY', 'car_model_id' => 'INT NOT NULL',