Add photo gallery feature (brand -> project -> photos drill-down)

Mirrors the old site's Bildergalerie structure: a brand tile grid at
/galerie, project tiles within a brand, and a photo grid with a
lightbox for each project. Backed by new gallery_brands/gallery_projects/
gallery_photos tables and admin CRUD pages, gated by a new gallery.manage
permission.

Also fixes a real bug found while testing photo uploads: Apache's
mod_dir was redirecting POST /api/uploads to /api/uploads/ (a trailing
slash) because api/uploads/ exists as a real directory, silently
dropping the multipart body on every image upload across the whole
app. Fixed via DirectorySlash Off in .htaccess.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Maaxxs 2026-07-06 17:04:46 +02:00
parent b6909e756c
commit e234a2435d
18 changed files with 1113 additions and 6 deletions

View file

@ -10,6 +10,9 @@ import Home from './pages/public/Home.jsx';
import VehicleSelect from './pages/public/VehicleSelect.jsx'; import VehicleSelect from './pages/public/VehicleSelect.jsx';
import BrandPage from './pages/public/BrandPage.jsx'; import BrandPage from './pages/public/BrandPage.jsx';
import ModelPage from './pages/public/ModelPage.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 Leistungen from './pages/public/Leistungen.jsx';
import ContactPage from './pages/public/ContactPage.jsx'; import ContactPage from './pages/public/ContactPage.jsx';
import Impressum from './pages/public/Impressum.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 Dashboard from './pages/admin/Dashboard.jsx';
import Brands from './pages/admin/Brands.jsx'; import Brands from './pages/admin/Brands.jsx';
import Models from './pages/admin/Models.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 Packages from './pages/admin/Packages.jsx';
import Services from './pages/admin/Services.jsx'; import Services from './pages/admin/Services.jsx';
import PackageProducts from './pages/admin/PackageProducts.jsx'; import PackageProducts from './pages/admin/PackageProducts.jsx';
@ -49,6 +55,9 @@ export default function App() {
<Route path="/fahrzeuge" element={<VehicleSelect />} /> <Route path="/fahrzeuge" element={<VehicleSelect />} />
<Route path="/fahrzeuge/:brandSlug" element={<BrandPage />} /> <Route path="/fahrzeuge/:brandSlug" element={<BrandPage />} />
<Route path="/fahrzeuge/:brandSlug/:modelSlug" element={<ModelPage />} /> <Route path="/fahrzeuge/:brandSlug/:modelSlug" element={<ModelPage />} />
<Route path="/galerie" element={<GalleryOverview />} />
<Route path="/galerie/:brandSlug" element={<GalleryBrandPage />} />
<Route path="/galerie/:brandSlug/:projectSlug" element={<GalleryProjectPage />} />
<Route path="/leistungen" element={<Leistungen />} /> <Route path="/leistungen" element={<Leistungen />} />
<Route path="/kontakt" element={<ContactPage />} /> <Route path="/kontakt" element={<ContactPage />} />
<Route path="/impressum" element={<Impressum />} /> <Route path="/impressum" element={<Impressum />} />
@ -64,6 +73,9 @@ export default function App() {
<Route path="account" element={<AccountSettings />} /> <Route path="account" element={<AccountSettings />} />
<Route path="brands" element={<RequirePermission permission="brands.manage"><Brands /></RequirePermission>} /> <Route path="brands" element={<RequirePermission permission="brands.manage"><Brands /></RequirePermission>} />
<Route path="models" element={<RequirePermission permission="models.manage"><Models /></RequirePermission>} /> <Route path="models" element={<RequirePermission permission="models.manage"><Models /></RequirePermission>} />
<Route path="gallery-brands" element={<RequirePermission permission="gallery.manage"><GalleryBrands /></RequirePermission>} />
<Route path="gallery-projects" element={<RequirePermission permission="gallery.manage"><GalleryProjects /></RequirePermission>} />
<Route path="gallery-projects/:projectId/photos" element={<RequirePermission permission="gallery.manage"><GalleryPhotos /></RequirePermission>} />
<Route path="packages" element={<RequirePermission permission="packages.manage"><Packages /></RequirePermission>} /> <Route path="packages" element={<RequirePermission permission="packages.manage"><Packages /></RequirePermission>} />
<Route path="packages/:packageId/products" element={<RequirePermission permission="packages.manage"><PackageProducts /></RequirePermission>} /> <Route path="packages/:packageId/products" element={<RequirePermission permission="packages.manage"><PackageProducts /></RequirePermission>} />
<Route path="packages/:packageId/upgrades" element={<RequirePermission permission="packages.manage"><PackageUpgrades /></RequirePermission>} /> <Route path="packages/:packageId/upgrades" element={<RequirePermission permission="packages.manage"><PackageUpgrades /></RequirePermission>} />

View file

@ -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', 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', 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', 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 }) => ( const Icon = ({ path }) => (
@ -57,6 +58,13 @@ export default function AdminLayout() {
{ to: '/admin/services', label: 'Leistungen', icon: 'briefcase', permission: 'services.manage' }, { 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', title: 'Kunden',
links: [ links: [

View file

@ -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(
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-black/90 p-4" onClick={onClose}>
<button
onClick={(e) => { stop(e); onClose(); }}
aria-label="Schließen"
className="absolute right-4 top-4 rounded-full p-2 text-white/80 hover:bg-white/10 hover:text-white"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="h-7 w-7">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 6l12 12M18 6 6 18" />
</svg>
</button>
{photos.length > 1 && (
<button
onClick={(e) => { stop(e); onNavigate(-1); }}
aria-label="Vorheriges Bild"
className="absolute left-2 top-1/2 -translate-y-1/2 rounded-full p-2 text-white/80 hover:bg-white/10 hover:text-white sm:left-4"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="h-8 w-8">
<path strokeLinecap="round" strokeLinejoin="round" d="m15 18-6-6 6-6" />
</svg>
</button>
)}
<img
src={photo.image_path}
alt={photo.caption || ''}
onClick={stop}
className="max-h-[85vh] max-w-full rounded-lg object-contain"
/>
{photos.length > 1 && (
<button
onClick={(e) => { stop(e); onNavigate(1); }}
aria-label="Nächstes Bild"
className="absolute right-2 top-1/2 -translate-y-1/2 rounded-full p-2 text-white/80 hover:bg-white/10 hover:text-white sm:right-4"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="h-8 w-8">
<path strokeLinecap="round" strokeLinejoin="round" d="m9 18 6-6-6-6" />
</svg>
</button>
)}
{photo.caption && (
<p onClick={stop} className="absolute bottom-4 left-1/2 -translate-x-1/2 rounded-full bg-black/60 px-4 py-1.5 text-sm text-white">
{photo.caption}
</p>
)}
</div>,
document.body
);
}

View file

@ -87,6 +87,7 @@ export default function Navbar() {
<nav className="hidden items-center gap-6 text-sm font-medium text-neutral-600 dark:text-neutral-300 md:flex"> <nav className="hidden items-center gap-6 text-sm font-medium text-neutral-600 dark:text-neutral-300 md:flex">
<Link to="/fahrzeuge" className="hover:text-brand-500">Fahrzeuge</Link> <Link to="/fahrzeuge" className="hover:text-brand-500">Fahrzeuge</Link>
<Link to="/leistungen" className="hover:text-brand-500">Leistungen</Link> <Link to="/leistungen" className="hover:text-brand-500">Leistungen</Link>
<Link to="/galerie" className="hover:text-brand-500">Galerie</Link>
<Link to="/kontakt" className="hover:text-brand-500">Kontakt</Link> <Link to="/kontakt" className="hover:text-brand-500">Kontakt</Link>
</nav> </nav>
@ -125,14 +126,17 @@ export default function Navbar() {
<Link to="/leistungen" onClick={() => setOpen(false)} className={`text-3xl font-extrabold tracking-tight text-neutral-900 hover:text-brand-600 ${flyIn()}`} style={flyInStyle(1)}> <Link to="/leistungen" onClick={() => setOpen(false)} className={`text-3xl font-extrabold tracking-tight text-neutral-900 hover:text-brand-600 ${flyIn()}`} style={flyInStyle(1)}>
Leistungen Leistungen
</Link> </Link>
<Link to="/kontakt" onClick={() => setOpen(false)} className={`text-3xl font-extrabold tracking-tight text-neutral-900 hover:text-brand-600 ${flyIn()}`} style={flyInStyle(2)}> <Link to="/galerie" onClick={() => setOpen(false)} className={`text-3xl font-extrabold tracking-tight text-neutral-900 hover:text-brand-600 ${flyIn()}`} style={flyInStyle(2)}>
Galerie
</Link>
<Link to="/kontakt" onClick={() => setOpen(false)} className={`text-3xl font-extrabold tracking-tight text-neutral-900 hover:text-brand-600 ${flyIn()}`} style={flyInStyle(3)}>
Kontakt Kontakt
</Link> </Link>
<div className={`h-px w-16 bg-brand-500/60 ${flyIn()}`} style={flyInStyle(3)} /> <div className={`h-px w-16 bg-brand-500/60 ${flyIn()}`} style={flyInStyle(4)} />
{phone && ( {phone && (
<a href={`tel:${digitsOnly(phone)}`} className={`flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-neutral-600 hover:text-brand-600 ${flyIn()}`} style={flyInStyle(4)}> <a href={`tel:${digitsOnly(phone)}`} className={`flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-neutral-600 hover:text-brand-600 ${flyIn()}`} style={flyInStyle(5)}>
{phone} {phone}
<DynamicIcon name="phone" className="h-4 w-4" /> <DynamicIcon name="phone" className="h-4 w-4" />
</a> </a>
@ -143,7 +147,7 @@ export default function Navbar() {
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className={`flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-neutral-600 hover:text-brand-600 ${flyIn()}`} 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 WhatsApp
<DynamicIcon name="message-circle" className="h-4 w-4" /> <DynamicIcon name="message-circle" className="h-4 w-4" />
@ -154,7 +158,7 @@ export default function Navbar() {
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className={`flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-neutral-600 hover:text-brand-600 ${flyIn()}`} 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 Zum Shop
<DynamicIcon name="shopping-bag" className="h-4 w-4" /> <DynamicIcon name="shopping-bag" className="h-4 w-4" />

View file

@ -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 (
<div className="grid gap-6 lg:grid-cols-3">
<div className="lg:col-span-2">
<h1 className="mb-4 text-xl font-bold text-neutral-900 dark:text-white">Galerie-Marken</h1>
<div className="overflow-hidden rounded-xl border border-neutral-200 dark:border-neutral-800">
<table className="w-full text-left text-sm">
<thead className="bg-neutral-50 text-neutral-500 dark:bg-neutral-900 dark:text-neutral-400">
<tr>
<th className="px-4 py-2">Bild</th>
<th className="px-4 py-2">Name</th>
<th className="px-4 py-2">Sortierung</th>
<th className="px-4 py-2 text-right">Aktionen</th>
</tr>
</thead>
<tbody className="divide-y divide-neutral-100 dark:divide-neutral-800">
{brands.map((brand) => (
<tr key={brand.id} className="bg-white dark:bg-neutral-950">
<td className="px-4 py-2">
{brand.cover_image_path ? (
<img src={brand.cover_image_path} alt="" className="h-10 w-10 rounded-md object-cover" />
) : (
<span className="flex h-10 w-10 items-center justify-center rounded-md bg-neutral-100 text-xs text-neutral-400 dark:bg-neutral-800"></span>
)}
</td>
<td className="px-4 py-2 font-medium text-neutral-800 dark:text-neutral-100">
<Link to={`/galerie/${brand.slug}`} target="_blank" className="hover:text-brand-500">
{brand.name}
</Link>
</td>
<td className="px-4 py-2">{brand.sort_order}</td>
<td className="px-4 py-2 text-right">
<button onClick={() => startEdit(brand)} className="mr-3 text-brand-600 hover:underline">Bearbeiten</button>
<button onClick={() => handleDelete(brand.id)} className="text-red-600 hover:underline">Löschen</button>
</td>
</tr>
))}
{brands.length === 0 && (
<tr><td colSpan={4} className="px-4 py-6 text-center text-neutral-400">Noch keine Galerie-Marken angelegt.</td></tr>
)}
</tbody>
</table>
</div>
</div>
<form onSubmit={handleSubmit} className="h-fit space-y-4 rounded-xl border border-neutral-200 bg-white p-5 dark:border-neutral-800 dark:bg-neutral-900">
<h2 className="font-bold text-neutral-900 dark:text-white">{editingId ? 'Marke bearbeiten' : 'Neue Marke'}</h2>
<div>
<label className="mb-1 block text-sm font-medium text-neutral-700 dark:text-neutral-300">Name *</label>
<input
required
value={form.name}
onChange={(e) => 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"
/>
</div>
<ImageUploadField
value={form.cover_image_path}
onChange={(path) => setForm({ ...form, cover_image_path: path })}
label="Titelbild (optional)"
/>
<div>
<label className="mb-1 block text-sm font-medium text-neutral-700 dark:text-neutral-300">Sortierung</label>
<input
type="number"
value={form.sort_order}
onChange={(e) => 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"
/>
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
<div className="flex gap-2">
<button type="submit" className="rounded-md bg-brand-500 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-600">
{editingId ? 'Speichern' : 'Anlegen'}
</button>
{editingId && (
<button type="button" onClick={resetForm} className="rounded-md border border-neutral-300 px-4 py-2 text-sm dark:border-neutral-700">
Abbrechen
</button>
)}
</div>
</form>
</div>
);
}

View file

@ -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 (
<div>
<p className="mb-2 text-sm">
<Link to="/admin/gallery-projects" className="text-brand-600 hover:underline"> Zurück zu den Projekten</Link>
</p>
<h1 className="mb-4 text-xl font-bold text-neutral-900 dark:text-white">Fotos im Projekt</h1>
<div className="mb-6 rounded-xl border border-neutral-200 bg-white p-4 dark:border-neutral-800 dark:bg-neutral-900">
<label className="mb-1 block text-sm font-medium text-neutral-700 dark:text-neutral-300">
Fotos hinzufügen (Mehrfachauswahl möglich)
</label>
<input
ref={fileInputRef}
type="file"
multiple
accept="image/png,image/jpeg,image/webp"
onChange={handleFiles}
disabled={uploading}
className="text-sm"
/>
{uploading && <p className="mt-2 text-sm text-neutral-500">{uploadProgress}</p>}
</div>
{error && <p className="mb-4 text-sm text-red-600">{error}</p>}
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4">
{photos.map((photo) => (
<div key={photo.id} className="overflow-hidden rounded-xl border border-neutral-200 bg-white dark:border-neutral-800 dark:bg-neutral-900">
<img src={photo.image_path} alt={photo.caption || ''} className="aspect-square w-full object-cover" />
<div className="space-y-2 p-3">
<input
value={photo.caption || ''}
onChange={(e) => 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"
/>
<div className="flex items-center gap-2">
<input
type="number"
value={photo.sort_order}
onChange={(e) => 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"
/>
<button onClick={() => handleDelete(photo.id)} className="text-xs text-red-600 hover:underline">Entfernen</button>
</div>
</div>
</div>
))}
{photos.length === 0 && (
<p className="col-span-full py-6 text-center text-neutral-400">Noch keine Fotos in diesem Projekt.</p>
)}
</div>
</div>
);
}

View file

@ -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 (
<div className="grid gap-6 lg:grid-cols-3">
<div className="lg:col-span-2">
<h1 className="mb-4 text-xl font-bold text-neutral-900 dark:text-white">Galerie-Projekte</h1>
<div className="mb-3 flex flex-wrap items-center gap-2">
<label className="text-sm font-medium text-neutral-600 dark:text-neutral-300">Nach Marke filtern:</label>
<select
value={brandFilter}
onChange={(e) => setBrandFilter(e.target.value)}
className="rounded-md border border-neutral-300 bg-white px-3 py-1.5 text-sm dark:border-neutral-700 dark:bg-neutral-900"
>
<option value="all">Alle Marken</option>
{brands.map((b) => <option key={b.id} value={String(b.id)}>{b.name}</option>)}
</select>
{brandFilter !== 'all' && (
<button onClick={() => setBrandFilter('all')} className="text-sm text-brand-600 hover:underline">
Filter zurücksetzen
</button>
)}
<span className="text-sm text-neutral-400">{filteredProjects.length} von {projects.length}</span>
</div>
<div className="overflow-hidden rounded-xl border border-neutral-200 dark:border-neutral-800">
<table className="w-full text-left text-sm">
<thead className="bg-neutral-50 text-neutral-500 dark:bg-neutral-900 dark:text-neutral-400">
<tr>
<th className="px-4 py-2">Marke</th>
<th className="px-4 py-2">Projekt</th>
<th className="px-4 py-2 text-right">Aktionen</th>
</tr>
</thead>
<tbody className="divide-y divide-neutral-100 dark:divide-neutral-800">
{filteredProjects.map((project) => (
<tr key={project.id} className="bg-white dark:bg-neutral-950">
<td className="px-4 py-2">{project.brand_name}</td>
<td className="px-4 py-2 font-medium text-neutral-800 dark:text-neutral-100">{project.name}</td>
<td className="px-4 py-2 text-right">
<Link to={`/admin/gallery-projects/${project.id}/photos`} className="mr-3 text-brand-600 hover:underline">Fotos</Link>
<button onClick={() => startEdit(project)} className="mr-3 text-brand-600 hover:underline">Bearbeiten</button>
<button onClick={() => handleDelete(project.id)} className="text-red-600 hover:underline">Löschen</button>
</td>
</tr>
))}
{filteredProjects.length === 0 && (
<tr><td colSpan={3} className="px-4 py-6 text-center text-neutral-400">
{projects.length === 0 ? 'Noch keine Projekte angelegt.' : 'Keine Projekte für diese Marke.'}
</td></tr>
)}
</tbody>
</table>
</div>
</div>
<form onSubmit={handleSubmit} className="h-fit space-y-4 rounded-xl border border-neutral-200 bg-white p-5 dark:border-neutral-800 dark:bg-neutral-900">
<h2 className="font-bold text-neutral-900 dark:text-white">{editingId ? 'Projekt bearbeiten' : 'Neues Projekt'}</h2>
<div>
<label className="mb-1 block text-sm font-medium text-neutral-700 dark:text-neutral-300">Marke *</label>
<select
required
value={form.gallery_brand_id}
onChange={(e) => setForm({ ...form, gallery_brand_id: 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"
>
<option value="">Bitte wählen</option>
{brands.map((b) => <option key={b.id} value={b.id}>{b.name}</option>)}
</select>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-neutral-700 dark:text-neutral-300">Projektname *</label>
<input
required
placeholder="z. B. Audi TT"
value={form.name}
onChange={(e) => 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"
/>
</div>
<ImageUploadField
value={form.cover_image_path}
onChange={(path) => setForm({ ...form, cover_image_path: path })}
label="Titelbild (optional)"
/>
<div>
<label className="mb-1 block text-sm font-medium text-neutral-700 dark:text-neutral-300">Sortierung</label>
<input
type="number"
value={form.sort_order}
onChange={(e) => 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"
/>
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
<div className="flex gap-2">
<button type="submit" className="rounded-md bg-brand-500 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-600">
{editingId ? 'Speichern' : 'Anlegen'}
</button>
{editingId && (
<button type="button" onClick={resetForm} className="rounded-md border border-neutral-300 px-4 py-2 text-sm dark:border-neutral-700">
Abbrechen
</button>
)}
</div>
</form>
</div>
);
}

View file

@ -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 <p className="mx-auto max-w-6xl px-4 py-12 text-red-600 sm:px-6">{error}</p>;
}
if (!data) {
return <p className="mx-auto max-w-6xl px-4 py-12 text-neutral-500 sm:px-6">Lädt</p>;
}
return (
<div className="mx-auto max-w-6xl px-4 py-12 sm:px-6">
<p className="mb-1 text-sm text-neutral-500 dark:text-neutral-400">
<Link to="/galerie" className="hover:text-brand-500">Galerie</Link> / {data.brand.name}
</p>
<h1 className="mb-8 text-2xl font-bold text-neutral-900 dark:text-white sm:text-3xl">
{data.brand.name} Projekt wählen
</h1>
{data.projects.length === 0 && (
<p className="text-neutral-500 dark:text-neutral-400">Für diese Marke sind noch keine Projekte hinterlegt.</p>
)}
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4">
{data.projects.map((project) => (
<Link
key={project.id}
to={`/galerie/${brandSlug}/${project.slug}`}
className="group relative block aspect-[4/3] overflow-hidden rounded-xl bg-neutral-900 shadow-sm transition hover:shadow-lg"
>
{project.cover_image_path ? (
<img
src={project.cover_image_path}
alt={project.name}
className="h-full w-full object-cover transition duration-300 group-hover:scale-105"
/>
) : (
<div className="flex h-full w-full items-center justify-center bg-neutral-200 dark:bg-neutral-800" />
)}
<div className="absolute inset-0 bg-gradient-to-t from-black/85 via-black/10 to-transparent transition group-hover:from-black/95" />
<span className="absolute inset-x-0 bottom-0 p-4 text-lg font-extrabold text-white">{project.name}</span>
</Link>
))}
</div>
</div>
);
}

View file

@ -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 (
<div className="mx-auto max-w-6xl px-4 py-12 sm:px-6">
<h1 className="mb-2 text-2xl font-bold text-neutral-900 dark:text-white sm:text-3xl">Bildergalerie</h1>
<p className="mb-8 text-neutral-600 dark:text-neutral-300">
Wähle eine Marke und entdecke unsere Umbauten im Detail.
</p>
{error && <p className="text-red-600">{error}</p>}
{!brands && !error && <p className="text-neutral-500 dark:text-neutral-400">Lädt</p>}
{brands && brands.length === 0 && (
<p className="text-neutral-500 dark:text-neutral-400">Es sind noch keine Galerie-Marken hinterlegt.</p>
)}
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4">
{brands?.map((brand) => (
<Link
key={brand.id}
to={`/galerie/${brand.slug}`}
className="group relative block aspect-[4/3] overflow-hidden rounded-xl bg-neutral-900 shadow-sm transition hover:shadow-lg"
>
{brand.cover_image_path ? (
<img
src={brand.cover_image_path}
alt={brand.name}
className="h-full w-full object-cover transition duration-300 group-hover:scale-105"
/>
) : (
<div className="flex h-full w-full items-center justify-center bg-neutral-200 dark:bg-neutral-800" />
)}
<div className="absolute inset-0 bg-gradient-to-t from-black/85 via-black/10 to-transparent transition group-hover:from-black/95" />
<span className="absolute inset-x-0 bottom-0 p-4 text-lg font-extrabold text-white">{brand.name}</span>
</Link>
))}
</div>
</div>
);
}

View file

@ -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 <p className="mx-auto max-w-6xl px-4 py-12 text-red-600 sm:px-6">{error}</p>;
}
if (!data) {
return <p className="mx-auto max-w-6xl px-4 py-12 text-neutral-500 sm:px-6">Lädt</p>;
}
return (
<div className="mx-auto max-w-6xl px-4 py-12 sm:px-6">
<p className="mb-1 text-sm text-neutral-500 dark:text-neutral-400">
<Link to="/galerie" className="hover:text-brand-500">Galerie</Link> /{' '}
<Link to={`/galerie/${brandSlug}`} className="hover:text-brand-500">{data.project.brand_name}</Link> / {data.project.name}
</p>
<h1 className="mb-8 text-2xl font-bold text-neutral-900 dark:text-white sm:text-3xl">{data.project.name}</h1>
{data.photos.length === 0 && (
<p className="text-neutral-500 dark:text-neutral-400">Für dieses Projekt sind noch keine Fotos hinterlegt.</p>
)}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
{data.photos.map((photo, i) => (
<Reveal key={photo.id} index={i % 6} shine className="aspect-square overflow-hidden rounded-xl">
<button
onClick={() => setOpenIndex(i)}
className="block h-full w-full"
aria-label={photo.caption || 'Bild vergrößern'}
>
<img
src={photo.image_path}
alt={photo.caption || ''}
className="h-full w-full object-cover transition duration-300 hover:scale-105"
/>
</button>
</Reveal>
))}
</div>
<Lightbox photos={data.photos} index={openIndex} onClose={() => setOpenIndex(null)} onNavigate={navigate} />
</div>
);
}

View file

@ -1,6 +1,13 @@
RewriteEngine On RewriteEngine On
RewriteBase / 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 # API-Requests an den PHP-Front-Controller weiterleiten
RewriteCond %{REQUEST_URI} ^/api/ RewriteCond %{REQUEST_URI} ^/api/
RewriteCond %{REQUEST_FILENAME} !-f RewriteCond %{REQUEST_FILENAME} !-f

View file

@ -91,6 +91,40 @@ CREATE TABLE car_models (
CONSTRAINT fk_car_models_brand FOREIGN KEY (brand_id) REFERENCES brands(id) ON DELETE CASCADE CONSTRAINT fk_car_models_brand FOREIGN KEY (brand_id) REFERENCES brands(id) ON DELETE CASCADE
) ENGINE=InnoDB; ) 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 ( CREATE TABLE packages (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
car_model_id INT NOT NULL, car_model_id INT NOT NULL,

View file

@ -13,6 +13,9 @@ use App\Controllers\AuthController;
use App\Controllers\BrandController; use App\Controllers\BrandController;
use App\Controllers\ContactController; use App\Controllers\ContactController;
use App\Controllers\DatabaseConfigController; use App\Controllers\DatabaseConfigController;
use App\Controllers\GalleryBrandController;
use App\Controllers\GalleryPhotoController;
use App\Controllers\GalleryProjectController;
use App\Controllers\MaintenanceController; use App\Controllers\MaintenanceController;
use App\Controllers\ModelController; use App\Controllers\ModelController;
use App\Controllers\PackageController; 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->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->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->post('/contact', $maint(null, fn($p) => ContactController::store()));
$router->get('/contact', $perm('contact.manage', fn($p) => ContactController::index())); $router->get('/contact', $perm('contact.manage', fn($p) => ContactController::index()));
$router->patch('/contact/{id}', $perm('contact.manage', fn($p) => ContactController::updateStatus($p))); $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->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->get('/services', $maint('services', fn($p) => ServiceController::index()));
$router->post('/services', $perm('services.manage', fn($p) => ServiceController::store())); $router->post('/services', $perm('services.manage', fn($p) => ServiceController::store()));

View file

@ -0,0 +1,90 @@
<?php
namespace App\Controllers;
use App\Config\Database;
use App\Support\Http;
use App\Support\Slug;
class GalleryBrandController
{
public static function index(): void
{
$stmt = Database::connection()->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()]);
}
}

View file

@ -0,0 +1,57 @@
<?php
namespace App\Controllers;
use App\Config\Database;
use App\Support\Http;
class GalleryPhotoController
{
public static function store(): void
{
$body = Http::jsonBody();
$projectId = (int) ($body['gallery_project_id'] ?? 0);
$imagePath = trim($body['image_path'] ?? '');
if ($projectId <= 0 || $imagePath === '') {
Http::error('Projekt und Bild erforderlich', 422);
}
$db = Database::connection();
$stmt = $db->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]);
}
}

View file

@ -0,0 +1,113 @@
<?php
namespace App\Controllers;
use App\Config\Database;
use App\Support\Http;
use App\Support\Slug;
class GalleryProjectController
{
public static function index(): void
{
$stmt = Database::connection()->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()]);
}
}

View file

@ -9,6 +9,7 @@ class Permissions
'models.manage' => 'Modelle verwalten', 'models.manage' => 'Modelle verwalten',
'packages.manage' => 'Pakete, Produkte & Upgrades verwalten', 'packages.manage' => 'Pakete, Produkte & Upgrades verwalten',
'services.manage' => 'Leistungen verwalten', 'services.manage' => 'Leistungen verwalten',
'gallery.manage' => 'Bildergalerie verwalten',
'contact.manage' => 'Kontaktanfragen ansehen & bearbeiten', 'contact.manage' => 'Kontaktanfragen ansehen & bearbeiten',
'contact.delete' => 'Kontaktanfragen löschen', 'contact.delete' => 'Kontaktanfragen löschen',
'users.manage' => 'Benutzer verwalten', 'users.manage' => 'Benutzer verwalten',

View file

@ -102,6 +102,40 @@ class Schema
CONSTRAINT fk_car_models_brand FOREIGN KEY (brand_id) REFERENCES brands(id) ON DELETE CASCADE CONSTRAINT fk_car_models_brand FOREIGN KEY (brand_id) REFERENCES brands(id) ON DELETE CASCADE
) ENGINE=InnoDB", ) 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 ( 'packages' => "CREATE TABLE packages (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
car_model_id INT NOT NULL, car_model_id INT NOT NULL,
@ -246,6 +280,34 @@ class Schema
'created_at' => 'DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP', 'created_at' => 'DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP',
'updated_at' => 'DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE 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' => [ 'packages' => [
'id' => 'INT AUTO_INCREMENT PRIMARY KEY', 'id' => 'INT AUTO_INCREMENT PRIMARY KEY',
'car_model_id' => 'INT NOT NULL', 'car_model_id' => 'INT NOT NULL',