From ef6203c60321d1e703930073e3c806d5507b6442 Mon Sep 17 00:00:00 2001 From: SEB Fotografie - soeren Date: Thu, 18 Sep 2025 15:27:33 +0200 Subject: [PATCH] fixed tenants and eventpurchaseresource, changed lightbox in gallery --- .gitignore | 3 + .../Resources/EventPurchaseResource.php | 14 +- app/Filament/Resources/TenantResource.php | 8 +- .../PurchasesRelationManager.php | 6 +- app/Models/Tenant.php | 1 - ...9_18_150000_flatten_tenant_name_column.php | 70 +++++ database/seeders/TasksSeeder.php | 3 +- package-lock.json | 47 ++- package.json | 1 + resources/js/components/ui/dialog.tsx | 13 +- .../js/guest/components/GalleryPreview.tsx | 2 +- resources/js/guest/pages/GalleryPage.tsx | 40 ++- resources/js/guest/pages/PhotoLightbox.tsx | 284 ++++++++++++------ .../src/images/wedding-lights-background.svg | 44 +++ vite.config.ts | 22 ++ 15 files changed, 440 insertions(+), 118 deletions(-) create mode 100644 database/migrations/2025_09_18_150000_flatten_tenant_name_column.php create mode 100644 resources/js/src/images/wedding-lights-background.svg diff --git a/.gitignore b/.gitignore index 3911ddb..d8e47a8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +fotospiel-tenant-app /.phpunit.cache /bootstrap/ssr /node_modules @@ -26,3 +27,5 @@ yarn-error.log /.vscode /.zed tools/git-askpass.ps1 +docker +podman-compose.dev.yml diff --git a/app/Filament/Resources/EventPurchaseResource.php b/app/Filament/Resources/EventPurchaseResource.php index 0591bd2..2841c2a 100644 --- a/app/Filament/Resources/EventPurchaseResource.php +++ b/app/Filament/Resources/EventPurchaseResource.php @@ -13,10 +13,10 @@ use Filament\Schemas\Schema; use Filament\Resources\Resource; use Filament\Tables; use Filament\Actions\Action; -use Filament\Tables\Actions\BulkActionGroup; -use Filament\Tables\Actions\DeleteBulkAction; -use Filament\Tables\Actions\ExportBulkAction; -use Filament\Tables\Actions\ViewAction; +use Filament\Actions\BulkActionGroup; +use Filament\Actions\DeleteBulkAction; +use Filament\Actions\ExportBulkAction; +use Filament\Actions\ViewAction; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Filters\Filter; use Filament\Tables\Filters\SelectFilter; @@ -71,7 +71,9 @@ class EventPurchaseResource extends Resource ->minValue(0), TextInput::make('price') ->label('Preis') - ->money('EUR') + ->numeric() + ->step(0.01) + ->prefix('€') ->required(), Select::make('platform') ->label('Plattform') @@ -208,4 +210,4 @@ class EventPurchaseResource extends Resource 'edit' => Pages\EditEventPurchase::route('/{record}/edit'), ]; } -} \ No newline at end of file +} diff --git a/app/Filament/Resources/TenantResource.php b/app/Filament/Resources/TenantResource.php index 23320af..ba3cb34 100644 --- a/app/Filament/Resources/TenantResource.php +++ b/app/Filament/Resources/TenantResource.php @@ -21,6 +21,7 @@ use App\Filament\Resources\TenantResource\RelationManagers\PurchasesRelationMana use Filament\Resources\RelationManagers\RelationGroup; use UnitEnum; use BackedEnum; +use Illuminate\Support\Facades\Route; class TenantResource extends Resource { @@ -69,7 +70,9 @@ class TenantResource extends Resource ->label(__('admin.tenants.fields.subscription_expires_at')), TextInput::make('total_revenue') ->label(__('admin.tenants.fields.total_revenue')) - ->money('EUR') + ->prefix('€') + ->numeric() + ->step(0.01) ->readOnly(), Toggle::make('is_active') ->label(__('admin.tenants.fields.is_active')) @@ -144,7 +147,8 @@ class TenantResource extends Resource Actions\Action::make('export') ->label('Daten exportieren') ->icon('heroicon-o-arrow-down-tray') - ->url(fn (Tenant $record) => route('admin.tenants.export', $record)) + ->url(fn (Tenant $record) => Route::has('admin.tenants.export') ? route('admin.tenants.export', $record) : null) + ->visible(fn () => Route::has('admin.tenants.export')) ->openUrlInNewTab(), ]) ->bulkActions([ diff --git a/app/Filament/Resources/TenantResource/RelationManagers/PurchasesRelationManager.php b/app/Filament/Resources/TenantResource/RelationManagers/PurchasesRelationManager.php index 8f56350..8613725 100644 --- a/app/Filament/Resources/TenantResource/RelationManagers/PurchasesRelationManager.php +++ b/app/Filament/Resources/TenantResource/RelationManagers/PurchasesRelationManager.php @@ -46,7 +46,9 @@ class PurchasesRelationManager extends RelationManager ->minValue(0), TextInput::make('price') ->label('Preis') - ->money('EUR') + ->numeric() + ->step(0.01) + ->prefix('€') ->required(), Select::make('platform') ->label('Plattform') @@ -125,4 +127,4 @@ class PurchasesRelationManager extends RelationManager ]), ]); } -} \ No newline at end of file +} diff --git a/app/Models/Tenant.php b/app/Models/Tenant.php index 208580e..a088f57 100644 --- a/app/Models/Tenant.php +++ b/app/Models/Tenant.php @@ -18,7 +18,6 @@ class Tenant extends Model protected $table = 'tenants'; protected $guarded = []; protected $casts = [ - 'name' => 'array', 'settings' => 'array', 'features' => 'array', 'last_activity_at' => 'datetime', diff --git a/database/migrations/2025_09_18_150000_flatten_tenant_name_column.php b/database/migrations/2025_09_18_150000_flatten_tenant_name_column.php new file mode 100644 index 0000000..2cbc274 --- /dev/null +++ b/database/migrations/2025_09_18_150000_flatten_tenant_name_column.php @@ -0,0 +1,70 @@ +select('id', 'name') + ->orderBy('id') + ->chunkById(100, function ($tenants): void { + foreach ($tenants as $tenant) { + $raw = $tenant->name; + + if ($raw === null || $raw === '') { + continue; + } + + $decoded = json_decode($raw, true); + $value = $raw; + + if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) { + $preferred = $decoded['de'] ?? $decoded['en'] ?? null; + + if ($preferred === null) { + foreach ($decoded as $entry) { + if (is_string($entry) && $entry !== '') { + $preferred = $entry; + break; + } + } + } + + $value = $preferred ?? (string) $raw; + } + + DB::table('tenants')->where('id', $tenant->id)->update([ + 'name' => (string) $value, + ]); + } + }); + } + + public function down(): void + { + DB::table('tenants') + ->select('id', 'name') + ->orderBy('id') + ->chunkById(100, function ($tenants): void { + foreach ($tenants as $tenant) { + $raw = $tenant->name; + + if ($raw === null || $raw === '') { + continue; + } + + $localized = json_encode([ + 'de' => $raw, + 'en' => $raw, + ], JSON_UNESCAPED_UNICODE); + + DB::table('tenants')->where('id', $tenant->id)->update([ + 'name' => $localized, + ]); + } + }); + } +}; diff --git a/database/seeders/TasksSeeder.php b/database/seeders/TasksSeeder.php index 49eb1a1..70546ed 100644 --- a/database/seeders/TasksSeeder.php +++ b/database/seeders/TasksSeeder.php @@ -13,7 +13,7 @@ class TasksSeeder extends Seeder $demoTenant = \App\Models\Tenant::updateOrCreate( ['slug' => 'demo'], [ - 'name' => ['de' => 'Demo Tenant', 'en' => 'Demo Tenant'], + 'name' => 'Demo Tenant', 'domain' => null, 'is_active' => true, 'settings' => [], @@ -58,4 +58,3 @@ class TasksSeeder extends Seeder } } } - diff --git a/package-lock.json b/package-lock.json index ab9feb8..3f70703 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "fotospiel-app", + "name": "html", "lockfileVersion": 3, "requires": true, "packages": { @@ -30,6 +30,7 @@ "globals": "^15.14.0", "laravel-vite-plugin": "^2.0", "lucide-react": "^0.475.0", + "playwright": "^1.55.0", "react": "^19.0.0", "react-dom": "^19.0.0", "react-router-dom": "^7.8.2", @@ -7863,6 +7864,50 @@ "node": ">=16.20.0" } }, + "node_modules/playwright": { + "version": "1.55.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.55.0.tgz", + "integrity": "sha512-sdCWStblvV1YU909Xqx0DhOjPZE4/5lJsIS84IfN9dAZfcl/CIZ5O8l3o0j7hPMjDvqoTF8ZUcc+i/GL5erstA==", + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.55.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.55.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.55.0.tgz", + "integrity": "sha512-GvZs4vU3U5ro2nZpeiwyb0zuFaqb9sUiAJuyrWpcGouD8y9/HLgGbNRjIph7zU9D3hnPaisMl9zG9CgFi/biIg==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", diff --git a/package.json b/package.json index 6031113..69da078 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "globals": "^15.14.0", "laravel-vite-plugin": "^2.0", "lucide-react": "^0.475.0", + "playwright": "^1.55.0", "react": "^19.0.0", "react-dom": "^19.0.0", "react-router-dom": "^7.8.2", diff --git a/resources/js/components/ui/dialog.tsx b/resources/js/components/ui/dialog.tsx index 1b608b2..a2aecbb 100644 --- a/resources/js/components/ui/dialog.tsx +++ b/resources/js/components/ui/dialog.tsx @@ -47,8 +47,9 @@ function DialogOverlay({ function DialogContent({ className, children, + hideClose = false, ...props -}: React.ComponentProps) { +}: React.ComponentProps & { hideClose?: boolean }) { return ( @@ -61,10 +62,12 @@ function DialogContent({ {...props} > {children} - - - Close - + {!hideClose && ( + + + Close + + )} ) diff --git a/resources/js/guest/components/GalleryPreview.tsx b/resources/js/guest/components/GalleryPreview.tsx index 937ed25..85c4344 100644 --- a/resources/js/guest/components/GalleryPreview.tsx +++ b/resources/js/guest/components/GalleryPreview.tsx @@ -97,7 +97,7 @@ export default function GalleryPreview({ slug }: Props) { )}
{items.map((p: any) => ( - +
('latest'); + const [currentPhotoIndex, setCurrentPhotoIndex] = React.useState(null); + const [hasOpenedPhoto, setHasOpenedPhoto] = useState(false); + + const [searchParams] = useSearchParams(); + const photoIdParam = searchParams.get('photoId'); + // Auto-open lightbox if photoId in query params + useEffect(() => { + if (photoIdParam && photos.length > 0 && currentPhotoIndex === null && !hasOpenedPhoto) { + const index = photos.findIndex((photo: any) => photo.id === parseInt(photoIdParam, 10)); + if (index !== -1) { + setCurrentPhotoIndex(index); + setHasOpenedPhoto(true); + } + } + }, [photos, photoIdParam, currentPhotoIndex, hasOpenedPhoto]); const myPhotoIds = React.useMemo(() => { try { @@ -106,7 +121,13 @@ export default function GalleryPage() { return ( - +
{ + const index = list.findIndex(photo => photo.id === p.id); + setCurrentPhotoIndex(index >= 0 ? index : null); + }} + className="cursor-pointer" + > {`Foto console.log(`✅ Successfully loaded image ${p.id}:`, imageUrl)} loading="lazy" /> - +
{p.task_title && ( @@ -138,6 +159,15 @@ export default function GalleryPage() { ); })}
+ {currentPhotoIndex !== null && list.length > 0 && ( + setCurrentPhotoIndex(null)} + onIndexChange={(index: number) => setCurrentPhotoIndex(index)} + slug={slug} + /> + )} ); } diff --git a/resources/js/guest/pages/PhotoLightbox.tsx b/resources/js/guest/pages/PhotoLightbox.tsx index f3489eb..08eddaa 100644 --- a/resources/js/guest/pages/PhotoLightbox.tsx +++ b/resources/js/guest/pages/PhotoLightbox.tsx @@ -1,65 +1,133 @@ -import React from 'react'; -import { useLocation, useNavigate, useParams } from 'react-router-dom'; +import React, { useState, useEffect } from 'react'; +import { useParams, useLocation, useNavigate } from 'react-router-dom'; import { Dialog, DialogContent } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; -import { Heart } from 'lucide-react'; +import { Heart, ChevronLeft, ChevronRight, X } from 'lucide-react'; import { likePhoto } from '../services/photosApi'; -type Photo = { - id: number; - file_path?: string; - thumbnail_path?: string; - likes_count?: number; - created_at?: string; - task_id?: number +type Photo = { + id: number; + file_path?: string; + thumbnail_path?: string; + likes_count?: number; + created_at?: string; + task_id?: number; + task_title?: string; }; type Task = { id: number; title: string }; -export default function PhotoLightbox() { - const navigate = useNavigate(); - const location = useLocation(); - const { photoId } = useParams(); - - const [photo, setPhoto] = React.useState(null); - const [task, setTask] = React.useState(null); - const [taskLoading, setTaskLoading] = React.useState(false); - const [likes, setLikes] = React.useState(null); - const [liked, setLiked] = React.useState(false); +interface Props { + photos?: Photo[]; + currentIndex?: number; + onClose?: () => void; + onIndexChange?: (index: number) => void; + slug?: string; +} - // Extract event slug from URL path - const getEventSlug = () => { - const path = window.location.pathname; - const match = path.match(/^\/e\/([^\/]+)\/photo\/[^\/]+$/); - return match ? match[1] : null; +export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexChange, slug }: Props) { + const params = useParams(); + const location = useLocation(); + const navigate = useNavigate(); + const photoId = params.photoId; + const eventSlug = params.slug || slug; + + const [standalonePhoto, setStandalonePhoto] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [task, setTask] = useState(null); + const [taskLoading, setTaskLoading] = useState(false); + const [likes, setLikes] = useState(0); + const [liked, setLiked] = useState(false); + + // Determine mode and photo + const isStandalone = !photos || photos.length === 0; + const currentPhotos = isStandalone ? (standalonePhoto ? [standalonePhoto] : []) : photos || []; + const currentIndexVal = isStandalone ? 0 : (currentIndex || 0); + const photo = currentPhotos[currentIndexVal]; + + // Fallback onClose for standalone + const handleClose = onClose || (() => navigate(-1)); + + // Fetch single photo for standalone mode + useEffect(() => { + if (isStandalone && photoId && !standalonePhoto && eventSlug) { + const fetchPhoto = async () => { + setLoading(true); + setError(null); + try { + const res = await fetch(`/api/v1/photos/${photoId}`); + if (res.ok) { + const fetchedPhoto: Photo = await res.json(); + setStandalonePhoto(fetchedPhoto); + // Check state for initial photo + if (location.state?.photo) { + setStandalonePhoto(location.state.photo); + } + } else { + setError('Foto nicht gefunden'); + } + } catch (err) { + setError('Fehler beim Laden des Fotos'); + } finally { + setLoading(false); + } + }; + + fetchPhoto(); + } else if (!isStandalone) { + setLoading(false); + } + }, [isStandalone, photoId, eventSlug, standalonePhoto, location.state]); + + // Update likes when photo changes + React.useEffect(() => { + if (photo) { + setLikes(photo.likes_count ?? 0); + // Check if liked from localStorage + try { + const raw = localStorage.getItem('liked-photo-ids'); + const likedIds = raw ? JSON.parse(raw) : []; + setLiked(likedIds.includes(photo.id)); + } catch { + setLiked(false); + } + } + }, [photo]); + + const touchRef = React.useRef(null); + const startX = React.useRef(0); + const currentX = React.useRef(0); + + const handleTouchStart = (e: React.TouchEvent) => { + startX.current = e.touches[0].clientX; }; - const slug = getEventSlug(); + const handleTouchMove = (e: React.TouchEvent) => { + if (!touchRef.current) return; + currentX.current = e.touches[0].clientX; + const deltaX = currentX.current - startX.current; + touchRef.current.style.transform = `translateX(${deltaX}px)`; + }; - // Load photo if not passed via state - React.useEffect(() => { - const statePhoto = (location.state as any)?.photo; - if (statePhoto) { - setPhoto(statePhoto); - setLikes(statePhoto.likes_count ?? 0); - return; - } + const handleTouchEnd = () => { + if (!touchRef.current) return; + const deltaX = currentX.current - startX.current; + const threshold = 50; // pixels - if (!photoId) return; + touchRef.current.style.transform = 'translateX(0)'; - (async () => { - try { - const res = await fetch(`/api/v1/photos/${photoId}`); - if (res.ok) { - const photoData = await res.json(); - setPhoto(photoData); - setLikes(photoData.likes_count ?? 0); - } - } catch (error) { - console.error('Failed to load photo:', error); + if (Math.abs(deltaX) > threshold) { + if (deltaX > 0 && currentIndexVal > 0) { + // Swipe right - previous + onIndexChange?.(currentIndexVal - 1); + } else if (deltaX < 0 && currentIndexVal < currentPhotos.length - 1) { + // Swipe left - next + onIndexChange?.(currentIndexVal + 1); } - })(); - }, [photoId, location.state]); + } + }; + // Load task info if photo has task_id and slug is available React.useEffect(() => { @@ -79,27 +147,27 @@ export default function PhotoLightbox() { const tasks = await res.json(); const foundTask = tasks.find((t: any) => t.id === taskId); if (foundTask) { - setTask({ - id: foundTask.id, - title: foundTask.title || `Aufgabe ${taskId}` + setTask({ + id: foundTask.id, + title: foundTask.title || `Aufgabe ${taskId}` }); } else { - setTask({ - id: taskId, - title: `Unbekannte Aufgabe ${taskId}` + setTask({ + id: taskId, + title: `Unbekannte Aufgabe ${taskId}` }); } } else { - setTask({ - id: taskId, - title: `Unbekannte Aufgabe ${taskId}` + setTask({ + id: taskId, + title: `Unbekannte Aufgabe ${taskId}` }); } } catch (error) { console.error('Failed to load task:', error); - setTask({ - id: taskId, - title: `Unbekannte Aufgabe ${taskId}` + setTask({ + id: taskId, + title: `Unbekannte Aufgabe ${taskId}` }); } finally { setTaskLoading(false); @@ -113,6 +181,14 @@ export default function PhotoLightbox() { try { const count = await likePhoto(photo.id); setLikes(count); + // Update localStorage + try { + const raw = localStorage.getItem('liked-photo-ids'); + const arr: number[] = raw ? JSON.parse(raw) : []; + if (!arr.includes(photo.id)) { + localStorage.setItem('liked-photo-ids', JSON.stringify([...arr, photo.id])); + } + } catch {} } catch (error) { console.error('Like failed:', error); setLiked(false); @@ -120,37 +196,60 @@ export default function PhotoLightbox() { } function onOpenChange(open: boolean) { - if (!open) navigate(-1); - } - - if (!photo && !photoId) { - return null; + if (!open) handleClose(); } return ( - + {/* Header with controls */}
-
- +
+ {currentIndexVal > 0 && ( + + )} + + {currentIndexVal < currentPhotos.length - 1 && ( + + )} +
{/* Task Info Overlay */} {task && ( -
+
Task: {task.title}
{taskLoading && ( @@ -161,23 +260,22 @@ export default function PhotoLightbox() { )} {/* Photo Display */} -
- {photo ? ( - Foto { - console.error('Image load error:', e); - (e.target as HTMLImageElement).style.display = 'none'; - }} - /> - ) : ( -
-
-
Lade Foto...
-
- )} +
+ {`Foto { + console.error('Image load error:', e); + (e.target as HTMLImageElement).style.display = 'none'; + }} + />
{/* Loading state for task */} diff --git a/resources/js/src/images/wedding-lights-background.svg b/resources/js/src/images/wedding-lights-background.svg new file mode 100644 index 0000000..6f0991c --- /dev/null +++ b/resources/js/src/images/wedding-lights-background.svg @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/vite.config.ts b/vite.config.ts index 555ed53..56153f6 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -4,7 +4,29 @@ import react from '@vitejs/plugin-react'; import laravel from 'laravel-vite-plugin'; import { defineConfig } from 'vite'; +const devServerHost = process.env.VITE_DEV_SERVER_HOST ?? '0.0.0.0'; +const devServerPort = Number.parseInt(process.env.VITE_DEV_SERVER_PORT ?? '5173', 10); +const devServerOrigin = process.env.VITE_DEV_SERVER_URL ?? `http://localhost:${devServerPort}`; +const parsedOrigin = new URL(devServerOrigin); +const hmrPort = parsedOrigin.port === '' ? devServerPort : Number.parseInt(parsedOrigin.port, 10); +const appUrl = process.env.APP_URL ?? 'http://localhost:8000'; + export default defineConfig({ + server: { + host: devServerHost, + port: devServerPort, + strictPort: true, + origin: devServerOrigin, + hmr: { + host: parsedOrigin.hostname, + protocol: parsedOrigin.protocol.replace(':', ''), + port: hmrPort, + }, + cors: { + origin: appUrl, + credentials: true, + }, + }, plugins: [ laravel({ input: ['resources/css/app.css', 'resources/js/app.tsx', 'resources/js/guest/main.tsx', 'resources/js/admin/main.tsx'],