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:
parent
b6909e756c
commit
e234a2435d
18 changed files with 1113 additions and 6 deletions
|
|
@ -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() {
|
|||
<Route path="/fahrzeuge" element={<VehicleSelect />} />
|
||||
<Route path="/fahrzeuge/:brandSlug" element={<BrandPage />} />
|
||||
<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="/kontakt" element={<ContactPage />} />
|
||||
<Route path="/impressum" element={<Impressum />} />
|
||||
|
|
@ -64,6 +73,9 @@ export default function App() {
|
|||
<Route path="account" element={<AccountSettings />} />
|
||||
<Route path="brands" element={<RequirePermission permission="brands.manage"><Brands /></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/:packageId/products" element={<RequirePermission permission="packages.manage"><PackageProducts /></RequirePermission>} />
|
||||
<Route path="packages/:packageId/upgrades" element={<RequirePermission permission="packages.manage"><PackageUpgrades /></RequirePermission>} />
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
71
hifi-src/src/components/Lightbox.jsx
Normal file
71
hifi-src/src/components/Lightbox.jsx
Normal 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
|
||||
);
|
||||
}
|
||||
|
|
@ -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">
|
||||
<Link to="/fahrzeuge" className="hover:text-brand-500">Fahrzeuge</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>
|
||||
</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)}>
|
||||
Leistungen
|
||||
</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
|
||||
</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 && (
|
||||
<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}
|
||||
<DynamicIcon name="phone" className="h-4 w-4" />
|
||||
</a>
|
||||
|
|
@ -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
|
||||
<DynamicIcon name="message-circle" className="h-4 w-4" />
|
||||
|
|
@ -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
|
||||
<DynamicIcon name="shopping-bag" className="h-4 w-4" />
|
||||
|
|
|
|||
137
hifi-src/src/pages/admin/GalleryBrands.jsx
Normal file
137
hifi-src/src/pages/admin/GalleryBrands.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
121
hifi-src/src/pages/admin/GalleryPhotos.jsx
Normal file
121
hifi-src/src/pages/admin/GalleryPhotos.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
174
hifi-src/src/pages/admin/GalleryProjects.jsx
Normal file
174
hifi-src/src/pages/admin/GalleryProjects.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
65
hifi-src/src/pages/public/GalleryBrandPage.jsx
Normal file
65
hifi-src/src/pages/public/GalleryBrandPage.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
56
hifi-src/src/pages/public/GalleryOverview.jsx
Normal file
56
hifi-src/src/pages/public/GalleryOverview.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
75
hifi-src/src/pages/public/GalleryProjectPage.jsx
Normal file
75
hifi-src/src/pages/public/GalleryProjectPage.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()));
|
||||
|
|
|
|||
90
hifi/api/src/Controllers/GalleryBrandController.php
Normal file
90
hifi/api/src/Controllers/GalleryBrandController.php
Normal 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()]);
|
||||
}
|
||||
}
|
||||
57
hifi/api/src/Controllers/GalleryPhotoController.php
Normal file
57
hifi/api/src/Controllers/GalleryPhotoController.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
113
hifi/api/src/Controllers/GalleryProjectController.php
Normal file
113
hifi/api/src/Controllers/GalleryProjectController.php
Normal 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()]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Reference in a new issue