reworked the guest pwa, modernized start and gallery page. added share link functionality.
This commit is contained in:
@@ -6,6 +6,7 @@ use App\Models\Event;
|
|||||||
use App\Models\EventJoinToken;
|
use App\Models\EventJoinToken;
|
||||||
use App\Models\EventMediaAsset;
|
use App\Models\EventMediaAsset;
|
||||||
use App\Models\Photo;
|
use App\Models\Photo;
|
||||||
|
use App\Models\PhotoShareLink;
|
||||||
use App\Services\Analytics\JoinTokenAnalyticsRecorder;
|
use App\Services\Analytics\JoinTokenAnalyticsRecorder;
|
||||||
use App\Services\EventJoinTokenService;
|
use App\Services\EventJoinTokenService;
|
||||||
use App\Services\Packages\PackageLimitEvaluator;
|
use App\Services\Packages\PackageLimitEvaluator;
|
||||||
@@ -585,6 +586,18 @@ class EventPublicController extends BaseController
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function makeShareAssetUrl(PhotoShareLink $shareLink, string $variant): string
|
||||||
|
{
|
||||||
|
return URL::temporarySignedRoute(
|
||||||
|
'api.v1.photo-shares.asset',
|
||||||
|
now()->addSeconds(self::SIGNED_URL_TTL_SECONDS),
|
||||||
|
[
|
||||||
|
'slug' => $shareLink->slug,
|
||||||
|
'variant' => $variant,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public function gallery(Request $request, string $token)
|
public function gallery(Request $request, string $token)
|
||||||
{
|
{
|
||||||
$locale = $request->query('locale', app()->getLocale());
|
$locale = $request->query('locale', app()->getLocale());
|
||||||
@@ -677,6 +690,170 @@ class EventPublicController extends BaseController
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function createShareLink(Request $request, string $token, Photo $photo)
|
||||||
|
{
|
||||||
|
$resolved = $this->resolvePublishedEvent($request, $token, ['id']);
|
||||||
|
|
||||||
|
if ($resolved instanceof JsonResponse) {
|
||||||
|
return $resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var array{0: object{id:int}} $resolved */
|
||||||
|
[$eventRecord] = $resolved;
|
||||||
|
|
||||||
|
if ((int) $photo->event_id !== (int) $eventRecord->id) {
|
||||||
|
return ApiError::response(
|
||||||
|
'photo_not_shareable',
|
||||||
|
'Photo Not Shareable',
|
||||||
|
'The selected photo cannot be shared at this time.',
|
||||||
|
Response::HTTP_NOT_FOUND,
|
||||||
|
[
|
||||||
|
'photo_id' => $photo->id,
|
||||||
|
'event_id' => $eventRecord->id,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$deviceId = trim((string) ($request->header('X-Device-Id') ?? $request->input('device_id', '')));
|
||||||
|
$ttlHours = max(1, (int) config('share-links.ttl_hours', 48));
|
||||||
|
|
||||||
|
$existing = PhotoShareLink::query()
|
||||||
|
->where('photo_id', $photo->id)
|
||||||
|
->when($deviceId !== '', fn ($query) => $query->where('created_by_device_id', $deviceId))
|
||||||
|
->where('expires_at', '>', now())
|
||||||
|
->latest('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($existing && ! $existing->isExpired()) {
|
||||||
|
$shareLink = $existing;
|
||||||
|
} else {
|
||||||
|
$shareLink = PhotoShareLink::create([
|
||||||
|
'photo_id' => $photo->id,
|
||||||
|
'slug' => PhotoShareLink::generateSlug(),
|
||||||
|
'expires_at' => now()->addHours($ttlHours),
|
||||||
|
'created_by_device_id' => $deviceId ?: null,
|
||||||
|
'created_ip' => $request->ip(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'slug' => $shareLink->slug,
|
||||||
|
'expires_at' => $shareLink->expires_at?->toIso8601String(),
|
||||||
|
'url' => url("/share/{$shareLink->slug}"),
|
||||||
|
])->header('Cache-Control', 'no-store');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function shareLink(Request $request, string $slug)
|
||||||
|
{
|
||||||
|
$shareLink = PhotoShareLink::with(['photo.event', 'photo.emotion', 'photo.task'])
|
||||||
|
->where('slug', $slug)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $shareLink || $shareLink->isExpired()) {
|
||||||
|
return ApiError::response(
|
||||||
|
'share_link_expired',
|
||||||
|
'Link Expired',
|
||||||
|
'This shared photo link is no longer available.',
|
||||||
|
Response::HTTP_GONE,
|
||||||
|
['slug' => $slug]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$shareLink->forceFill(['last_accessed_at' => now()])->save();
|
||||||
|
|
||||||
|
$photo = $shareLink->photo;
|
||||||
|
$event = $photo->event;
|
||||||
|
|
||||||
|
if (! $event || $photo->status !== 'approved') {
|
||||||
|
return ApiError::response(
|
||||||
|
'photo_not_shareable',
|
||||||
|
'Photo Not Shareable',
|
||||||
|
'The shared photo is no longer available.',
|
||||||
|
Response::HTTP_NOT_FOUND,
|
||||||
|
['slug' => $slug]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$taskTitle = null;
|
||||||
|
|
||||||
|
if ($photo->task) {
|
||||||
|
$taskTitle = $this->translateLocalized($photo->task->title, app()->getLocale(), '');
|
||||||
|
if ($taskTitle === '') {
|
||||||
|
$taskTitle = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$photoResource = [
|
||||||
|
'id' => $photo->id,
|
||||||
|
'title' => $taskTitle,
|
||||||
|
'emotion' => $photo->emotion ? [
|
||||||
|
'name' => $photo->emotion->name,
|
||||||
|
'emoji' => $photo->emotion->emoji,
|
||||||
|
] : null,
|
||||||
|
'likes_count' => $photo->likes()->count(),
|
||||||
|
'image_urls' => [
|
||||||
|
'thumbnail' => $this->makeShareAssetUrl($shareLink, 'thumbnail'),
|
||||||
|
'full' => $this->makeShareAssetUrl($shareLink, 'full'),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'slug' => $shareLink->slug,
|
||||||
|
'expires_at' => $shareLink->expires_at?->toIso8601String(),
|
||||||
|
'photo' => $photoResource,
|
||||||
|
'event' => $event ? [
|
||||||
|
'id' => $event->id,
|
||||||
|
'name' => $event->name,
|
||||||
|
'city' => $event->city,
|
||||||
|
] : null,
|
||||||
|
])->header('Cache-Control', 'no-store');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function shareLinkAsset(Request $request, string $slug, string $variant)
|
||||||
|
{
|
||||||
|
if (! in_array($variant, ['thumbnail', 'full'], true)) {
|
||||||
|
return ApiError::response(
|
||||||
|
'invalid_variant',
|
||||||
|
'Invalid Variant',
|
||||||
|
'The requested asset variant is not supported.',
|
||||||
|
Response::HTTP_BAD_REQUEST
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$shareLink = PhotoShareLink::with(['photo.mediaAsset', 'photo.event.tenant'])
|
||||||
|
->where('slug', $slug)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $shareLink || $shareLink->isExpired()) {
|
||||||
|
return ApiError::response(
|
||||||
|
'share_link_expired',
|
||||||
|
'Link Expired',
|
||||||
|
'This shared photo link is no longer available.',
|
||||||
|
Response::HTTP_GONE,
|
||||||
|
['slug' => $slug]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$photo = $shareLink->photo;
|
||||||
|
$event = $photo->event;
|
||||||
|
|
||||||
|
if (! $event || $photo->status !== 'approved') {
|
||||||
|
return ApiError::response(
|
||||||
|
'photo_not_shareable',
|
||||||
|
'Photo Not Shareable',
|
||||||
|
'The shared photo is no longer available.',
|
||||||
|
Response::HTTP_NOT_FOUND,
|
||||||
|
['slug' => $slug]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$variantPreference = $variant === 'thumbnail'
|
||||||
|
? ['thumbnail', 'original']
|
||||||
|
: ['original'];
|
||||||
|
|
||||||
|
return $this->streamGalleryPhoto($event, $photo, $variantPreference, 'inline');
|
||||||
|
}
|
||||||
|
|
||||||
public function galleryPhotoAsset(Request $request, string $token, int $photo, string $variant)
|
public function galleryPhotoAsset(Request $request, string $token, int $photo, string $variant)
|
||||||
{
|
{
|
||||||
$resolved = $this->resolveGalleryEvent($request, $token);
|
$resolved = $this->resolveGalleryEvent($request, $token);
|
||||||
|
|||||||
@@ -76,6 +76,11 @@ class Photo extends Model
|
|||||||
return $this->hasMany(PhotoLike::class);
|
return $this->hasMany(PhotoLike::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function shareLinks(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(PhotoShareLink::class);
|
||||||
|
}
|
||||||
|
|
||||||
public static function supportsFilenameColumn(): bool
|
public static function supportsFilenameColumn(): bool
|
||||||
{
|
{
|
||||||
return static::hasColumn('filename');
|
return static::hasColumn('filename');
|
||||||
|
|||||||
51
app/Models/PhotoShareLink.php
Normal file
51
app/Models/PhotoShareLink.php
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class PhotoShareLink extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'photo_id',
|
||||||
|
'slug',
|
||||||
|
'expires_at',
|
||||||
|
'created_by_device_id',
|
||||||
|
'created_ip',
|
||||||
|
'last_accessed_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'expires_at' => 'datetime',
|
||||||
|
'last_accessed_at' => 'datetime',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected static function booted(): void
|
||||||
|
{
|
||||||
|
static::creating(function (PhotoShareLink $link) {
|
||||||
|
if (! $link->slug) {
|
||||||
|
$link->slug = static::generateSlug();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function generateSlug(): string
|
||||||
|
{
|
||||||
|
return Str::lower(Str::random(40));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function photo(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Photo::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isExpired(): bool
|
||||||
|
{
|
||||||
|
return $this->expires_at !== null && $this->expires_at->isPast();
|
||||||
|
}
|
||||||
|
}
|
||||||
5
config/share-links.php
Normal file
5
config/share-links.php
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'ttl_hours' => env('PHOTO_SHARE_LINK_TTL_HOURS', 48),
|
||||||
|
];
|
||||||
29
database/factories/PhotoShareLinkFactory.php
Normal file
29
database/factories/PhotoShareLinkFactory.php
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\Photo;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\PhotoShareLink>
|
||||||
|
*/
|
||||||
|
class PhotoShareLinkFactory extends Factory
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Define the model's default state.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'photo_id' => Photo::factory(),
|
||||||
|
'slug' => Str::lower(Str::random(32)),
|
||||||
|
'expires_at' => now()->addDay(),
|
||||||
|
'created_by_device_id' => $this->faker->unique()->uuid(),
|
||||||
|
'created_ip' => $this->faker->ipv4(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('photo_share_links', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('photo_id')->constrained()->cascadeOnDelete();
|
||||||
|
$table->string('slug', 64)->unique();
|
||||||
|
$table->string('created_by_device_id')->nullable();
|
||||||
|
$table->string('created_ip', 45)->nullable();
|
||||||
|
$table->timestamp('expires_at');
|
||||||
|
$table->timestamp('last_accessed_at')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('photo_share_links');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { NavLink, useParams, useLocation } from 'react-router-dom';
|
import { NavLink, useParams, useLocation, Link } from 'react-router-dom';
|
||||||
import { CheckSquare, GalleryHorizontal, Home, Trophy } from 'lucide-react';
|
import { CheckSquare, GalleryHorizontal, Home, Trophy, Camera } from 'lucide-react';
|
||||||
import { useEventData } from '../hooks/useEventData';
|
import { useEventData } from '../hooks/useEventData';
|
||||||
import { useTranslation } from '../i18n/useTranslation';
|
import { useTranslation } from '../i18n/useTranslation';
|
||||||
import { useEventBranding } from '../context/EventBrandingContext';
|
import { useEventBranding } from '../context/EventBrandingContext';
|
||||||
@@ -58,40 +58,61 @@ export default function BottomNav() {
|
|||||||
tasks: t('navigation.tasks'),
|
tasks: t('navigation.tasks'),
|
||||||
achievements: t('navigation.achievements'),
|
achievements: t('navigation.achievements'),
|
||||||
gallery: t('navigation.gallery'),
|
gallery: t('navigation.gallery'),
|
||||||
|
upload: t('home.actions.items.upload.label'),
|
||||||
};
|
};
|
||||||
|
|
||||||
const isHomeActive = currentPath === base || currentPath === `/${token}`;
|
const isHomeActive = currentPath === base || currentPath === `/${token}`;
|
||||||
const isTasksActive = currentPath.startsWith(`${base}/tasks`) || currentPath === `${base}/upload`;
|
const isTasksActive = currentPath.startsWith(`${base}/tasks`);
|
||||||
const isAchievementsActive = currentPath.startsWith(`${base}/achievements`);
|
const isAchievementsActive = currentPath.startsWith(`${base}/achievements`);
|
||||||
const isGalleryActive = currentPath.startsWith(`${base}/gallery`) || currentPath.startsWith(`${base}/photos`);
|
const isGalleryActive = currentPath.startsWith(`${base}/gallery`) || currentPath.startsWith(`${base}/photos`);
|
||||||
|
const isUploadActive = currentPath.startsWith(`${base}/upload`);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-x-0 bottom-0 z-30 border-t border-white/20 bg-gradient-to-t from-black/30 via-black/10 to-transparent px-3 py-2 shadow-xl backdrop-blur-xl dark:border-white/10 dark:from-gray-950/85 dark:via-gray-900/70 dark:to-gray-900/35 dark:backdrop-blur-2xl dark:shadow-[0_18px_60px_rgba(15,15,30,0.6)]">
|
<div className="fixed inset-x-0 bottom-0 z-30 border-t border-white/20 bg-gradient-to-t from-black/40 via-black/20 to-transparent px-4 pb-3 pt-2 shadow-xl backdrop-blur-2xl dark:border-white/10 dark:from-gray-950/90 dark:via-gray-900/70 dark:to-gray-900/35">
|
||||||
<div className="mx-auto flex max-w-sm items-center justify-around gap-2">
|
<div className="mx-auto flex max-w-lg items-center gap-3">
|
||||||
<TabLink to={`${base}`} isActive={isHomeActive} accentColor={branding.primaryColor}>
|
<div className="flex flex-1 justify-evenly gap-2">
|
||||||
<div className="flex flex-col items-center gap-1">
|
<TabLink to={`${base}`} isActive={isHomeActive} accentColor={branding.primaryColor}>
|
||||||
<Home className="h-5 w-5" aria-hidden />
|
<div className="flex flex-col items-center gap-1">
|
||||||
<span>{labels.home}</span>
|
<Home className="h-5 w-5" aria-hidden />
|
||||||
</div>
|
<span>{labels.home}</span>
|
||||||
</TabLink>
|
</div>
|
||||||
<TabLink to={`${base}/tasks`} isActive={isTasksActive} accentColor={branding.primaryColor}>
|
</TabLink>
|
||||||
<div className="flex flex-col items-center gap-1">
|
<TabLink to={`${base}/tasks`} isActive={isTasksActive} accentColor={branding.primaryColor}>
|
||||||
<CheckSquare className="h-5 w-5" aria-hidden />
|
<div className="flex flex-col items-center gap-1">
|
||||||
<span>{labels.tasks}</span>
|
<CheckSquare className="h-5 w-5" aria-hidden />
|
||||||
</div>
|
<span>{labels.tasks}</span>
|
||||||
</TabLink>
|
</div>
|
||||||
<TabLink to={`${base}/achievements`} isActive={isAchievementsActive} accentColor={branding.primaryColor}>
|
</TabLink>
|
||||||
<div className="flex flex-col items-center gap-1">
|
</div>
|
||||||
<Trophy className="h-5 w-5" aria-hidden />
|
|
||||||
<span>{labels.achievements}</span>
|
<Link
|
||||||
</div>
|
to={`${base}/upload`}
|
||||||
</TabLink>
|
aria-label={labels.upload}
|
||||||
<TabLink to={`${base}/gallery`} isActive={isGalleryActive} accentColor={branding.primaryColor}>
|
className={`relative flex h-16 w-16 items-center justify-center rounded-full text-white shadow-2xl transition ${
|
||||||
<div className="flex flex-col items-center gap-1">
|
isUploadActive ? 'scale-105' : 'hover:scale-105'
|
||||||
<GalleryHorizontal className="h-5 w-5" aria-hidden />
|
}`}
|
||||||
<span>{labels.gallery}</span>
|
style={{
|
||||||
</div>
|
background: `radial-gradient(circle at 20% 20%, ${branding.secondaryColor}, ${branding.primaryColor})`,
|
||||||
</TabLink>
|
boxShadow: `0 20px 35px ${branding.primaryColor}44`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Camera className="h-6 w-6" aria-hidden />
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="flex flex-1 justify-evenly gap-2">
|
||||||
|
<TabLink to={`${base}/achievements`} isActive={isAchievementsActive} accentColor={branding.primaryColor}>
|
||||||
|
<div className="flex flex-col items-center gap-1">
|
||||||
|
<Trophy className="h-5 w-5" aria-hidden />
|
||||||
|
<span>{labels.achievements}</span>
|
||||||
|
</div>
|
||||||
|
</TabLink>
|
||||||
|
<TabLink to={`${base}/gallery`} isActive={isGalleryActive} accentColor={branding.primaryColor}>
|
||||||
|
<div className="flex flex-col items-center gap-1">
|
||||||
|
<GalleryHorizontal className="h-5 w-5" aria-hidden />
|
||||||
|
<span>{labels.gallery}</span>
|
||||||
|
</div>
|
||||||
|
</TabLink>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -73,30 +73,17 @@ export default function EmotionPicker({ onSelect }: EmotionPickerProps) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
const content = (
|
||||||
return (
|
<div className="space-y-4">
|
||||||
<div className="mb-6 p-4 text-center">
|
<div className="flex items-center justify-between">
|
||||||
<div className="text-sm text-muted-foreground">Lade Emotionen...</div>
|
<h3 className="text-base font-semibold">
|
||||||
|
Wie fühlst du dich?
|
||||||
|
<span className="ml-2 text-xs text-muted-foreground">(optional)</span>
|
||||||
|
</h3>
|
||||||
|
{loading && <span className="text-xs text-muted-foreground">Lade Emotionen…</span>}
|
||||||
</div>
|
</div>
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
<div className="flex gap-3 overflow-x-auto pb-2 [-ms-overflow-style:none] [scrollbar-width:none]" aria-label="Emotions">
|
||||||
return (
|
|
||||||
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg">
|
|
||||||
<div className="text-sm text-red-700">{error}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mb-6">
|
|
||||||
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
|
||||||
Wie fühlst du dich?
|
|
||||||
<span className="text-xs text-muted-foreground">(optional)</span>
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
|
||||||
{emotions.map((emotion) => {
|
{emotions.map((emotion) => {
|
||||||
// Localize name and description if they are JSON
|
// Localize name and description if they are JSON
|
||||||
const localize = (value: string | object, defaultValue: string = ''): string => {
|
const localize = (value: string | object, defaultValue: string = ''): string => {
|
||||||
@@ -113,25 +100,26 @@ export default function EmotionPicker({ onSelect }: EmotionPickerProps) {
|
|||||||
|
|
||||||
const localizedName = localize(emotion.name, emotion.name);
|
const localizedName = localize(emotion.name, emotion.name);
|
||||||
const localizedDescription = localize(emotion.description || '', '');
|
const localizedDescription = localize(emotion.description || '', '');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<button
|
||||||
key={emotion.id}
|
key={emotion.id}
|
||||||
variant="outline"
|
type="button"
|
||||||
className="w-full justify-start h-16 p-3 bg-pink-50 dark:bg-gray-800/50 hover:bg-pink-100 dark:hover:bg-gray-700/50 border-pink-200 dark:border-gray-600 rounded-xl text-left shadow-sm dark:text-white"
|
|
||||||
onClick={() => handleEmotionSelect(emotion)}
|
onClick={() => handleEmotionSelect(emotion)}
|
||||||
|
className="group flex min-w-[180px] flex-col gap-2 rounded-2xl border border-white/40 bg-white/80 px-4 py-3 text-left shadow-sm transition hover:-translate-y-0.5 hover:border-pink-200 hover:shadow-md dark:border-gray-700/60 dark:bg-gray-900/70"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="text-2xl">{emotion.emoji}</span>
|
<span className="text-2xl" aria-hidden>
|
||||||
|
{emotion.emoji}
|
||||||
|
</span>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="font-medium text-sm truncate">{localizedName}</div>
|
<div className="font-medium text-sm text-foreground line-clamp-1">{localizedName}</div>
|
||||||
{localizedDescription && (
|
{localizedDescription && (
|
||||||
<div className="text-xs text-muted-foreground truncate">{localizedDescription}</div>
|
<div className="text-xs text-muted-foreground line-clamp-1">{localizedDescription}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<ChevronRight className="h-4 w-4 text-muted-foreground ml-auto" />
|
<ChevronRight className="h-4 w-4 text-muted-foreground opacity-0 transition group-hover:opacity-100" />
|
||||||
</div>
|
</div>
|
||||||
</Button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
@@ -151,4 +139,18 @@ export default function EmotionPicker({ onSelect }: EmotionPickerProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-3xl border border-red-200 bg-red-50 p-4 text-sm text-red-700">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-3xl border border-muted/40 bg-gradient-to-br from-white to-white/70 p-4 shadow-sm backdrop-blur">
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,40 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Sparkles, Flame, UserRound, Camera } from 'lucide-react';
|
||||||
|
|
||||||
export type GalleryFilter = 'latest' | 'popular' | 'mine' | 'photobooth';
|
export type GalleryFilter = 'latest' | 'popular' | 'mine' | 'photobooth';
|
||||||
|
|
||||||
export default function FiltersBar({ value, onChange }: { value: GalleryFilter; onChange: (v: GalleryFilter) => void }) {
|
const filterConfig: Array<{ value: GalleryFilter; label: string; icon: React.ReactNode }> = [
|
||||||
|
{ value: 'latest', label: 'Neueste', icon: <Sparkles className="h-4 w-4" aria-hidden /> },
|
||||||
|
{ value: 'popular', label: 'Beliebt', icon: <Flame className="h-4 w-4" aria-hidden /> },
|
||||||
|
{ value: 'mine', label: 'Meine', icon: <UserRound className="h-4 w-4" aria-hidden /> },
|
||||||
|
{ value: 'photobooth', label: 'Fotobox', icon: <Camera className="h-4 w-4" aria-hidden /> },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function FiltersBar({ value, onChange, className }: { value: GalleryFilter; onChange: (v: GalleryFilter) => void; className?: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="mb-3 flex items-center justify-between">
|
<div
|
||||||
<ToggleGroup type="single" value={value} onValueChange={(v) => v && onChange(v as GalleryFilter)}>
|
className={cn(
|
||||||
<ToggleGroupItem value="latest">Neueste</ToggleGroupItem>
|
'flex gap-2 overflow-x-auto px-4 pb-2 text-sm font-medium [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden',
|
||||||
<ToggleGroupItem value="popular">Beliebt</ToggleGroupItem>
|
className,
|
||||||
<ToggleGroupItem value="mine">Meine</ToggleGroupItem>
|
)}
|
||||||
<ToggleGroupItem value="photobooth">Fotobox</ToggleGroupItem>
|
>
|
||||||
</ToggleGroup>
|
{filterConfig.map((filter) => (
|
||||||
|
<button
|
||||||
|
key={filter.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange(filter.value)}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-2 rounded-full border px-4 py-2 transition',
|
||||||
|
value === filter.value
|
||||||
|
? 'border-pink-500 bg-gradient-to-r from-pink-500 to-pink-600 text-white shadow'
|
||||||
|
: 'border-transparent bg-white/70 text-muted-foreground hover:border-pink-200',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{filter.icon}
|
||||||
|
{filter.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Link } from 'react-router-dom';
|
|||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { getDeviceId } from '../lib/device';
|
import { getDeviceId } from '../lib/device';
|
||||||
import { usePollGalleryDelta } from '../polling/usePollGalleryDelta';
|
import { usePollGalleryDelta } from '../polling/usePollGalleryDelta';
|
||||||
|
import { Heart } from 'lucide-react';
|
||||||
|
|
||||||
type Props = { token: string };
|
type Props = { token: string };
|
||||||
|
|
||||||
@@ -50,56 +51,45 @@ export default function GalleryPreview({ token }: Props) {
|
|||||||
return 'Abendstimmung';
|
return 'Abendstimmung';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const filters: { value: PreviewFilter; label: string }[] = [
|
||||||
|
{ value: 'latest', label: 'Newest' },
|
||||||
|
{ value: 'popular', label: 'Popular' },
|
||||||
|
{ value: 'mine', label: 'My Photos' },
|
||||||
|
{ value: 'photobooth', label: 'Fotobox' },
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<section className="space-y-4">
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="inline-flex rounded-full bg-white/80 backdrop-blur-sm border border-pink-200 p-1 shadow-sm">
|
<div>
|
||||||
<button
|
<p className="text-xs uppercase tracking-wide text-muted-foreground">Live-Galerie</p>
|
||||||
onClick={() => setMode('latest')}
|
<h3 className="text-lg font-semibold text-foreground">Alle Uploads auf einen Blick</h3>
|
||||||
className={`px-4 py-2 text-sm font-medium rounded-full transition-all duration-200 ${
|
|
||||||
mode === 'latest'
|
|
||||||
? 'bg-gradient-to-r from-pink-500 to-pink-600 text-white shadow-lg scale-105'
|
|
||||||
: 'text-gray-600 hover:bg-pink-50 hover:text-pink-700'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Newest
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setMode('popular')}
|
|
||||||
className={`px-4 py-2 text-sm font-medium rounded-full transition-all duration-200 ${
|
|
||||||
mode === 'popular'
|
|
||||||
? 'bg-gradient-to-r from-pink-500 to-pink-600 text-white shadow-lg scale-105'
|
|
||||||
: 'text-gray-600 hover:bg-pink-50 hover:text-pink-700'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Popular
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setMode('mine')}
|
|
||||||
className={`px-4 py-2 text-sm font-medium rounded-full transition-all duration-200 ${
|
|
||||||
mode === 'mine'
|
|
||||||
? 'bg-gradient-to-r from-pink-500 to-pink-600 text-white shadow-lg scale-105'
|
|
||||||
: 'text-gray-600 hover:bg-pink-50 hover:text-pink-700'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
My Photos
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setMode('photobooth')}
|
|
||||||
className={`px-4 py-2 text-sm font-medium rounded-full transition-all duration-200 ${
|
|
||||||
mode === 'photobooth'
|
|
||||||
? 'bg-gradient-to-r from-pink-500 to-pink-600 text-white shadow-lg scale-105'
|
|
||||||
: 'text-gray-600 hover:bg-pink-50 hover:text-pink-700'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Fotobox
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<Link to={`/e/${encodeURIComponent(token)}/gallery?mode=${mode}`} className="text-sm text-pink-600 hover:text-pink-700 font-medium">
|
<Link
|
||||||
|
to={`/e/${encodeURIComponent(token)}/gallery?mode=${mode}`}
|
||||||
|
className="text-sm font-semibold text-pink-600 hover:text-pink-700"
|
||||||
|
>
|
||||||
Alle ansehen →
|
Alle ansehen →
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 overflow-x-auto pb-1 text-sm font-medium [-ms-overflow-style:none] [scrollbar-width:none]">
|
||||||
|
{filters.map((filter) => (
|
||||||
|
<button
|
||||||
|
key={filter.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMode(filter.value)}
|
||||||
|
className={`rounded-full border px-4 py-1 transition ${
|
||||||
|
mode === filter.value
|
||||||
|
? 'border-pink-500 bg-pink-500 text-white shadow'
|
||||||
|
: 'border-transparent bg-white/70 text-muted-foreground hover:border-pink-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{filter.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
{loading && <p className="text-sm text-muted-foreground">Lädt…</p>}
|
{loading && <p className="text-sm text-muted-foreground">Lädt…</p>}
|
||||||
{!loading && items.length === 0 && (
|
{!loading && items.length === 0 && (
|
||||||
<Card>
|
<Card>
|
||||||
@@ -108,31 +98,31 @@ export default function GalleryPreview({ token }: Props) {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
{items.map((p: any) => (
|
{items.map((p: any) => (
|
||||||
<Link key={p.id} to={`/e/${encodeURIComponent(token)}/gallery?photoId=${p.id}`} className="block">
|
<Link
|
||||||
<div className="relative">
|
key={p.id}
|
||||||
<img
|
to={`/e/${encodeURIComponent(token)}/gallery?photoId=${p.id}`}
|
||||||
src={p.thumbnail_path || p.file_path}
|
className="group relative block overflow-hidden rounded-3xl border border-white/30 bg-gray-900 text-white shadow-lg"
|
||||||
alt={p.title || 'Foto'}
|
>
|
||||||
className="aspect-square w-full rounded-xl object-cover shadow-lg hover:shadow-xl transition-shadow duration-200"
|
<img
|
||||||
loading="lazy"
|
src={p.thumbnail_path || p.file_path}
|
||||||
/>
|
alt={p.title || 'Foto'}
|
||||||
{/* Photo Title */}
|
className="h-48 w-full object-cover transition duration-300 group-hover:scale-105"
|
||||||
<div className="mt-2">
|
loading="lazy"
|
||||||
<div className="text-xs font-medium text-gray-900 line-clamp-2 bg-white/80 px-2 py-1 rounded-md">
|
/>
|
||||||
{p.title || getPhotoTitle(p)}
|
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/10 to-transparent" aria-hidden />
|
||||||
</div>
|
<div className="absolute bottom-0 left-0 right-0 p-4">
|
||||||
{p.likes_count > 0 && (
|
<p className="text-sm font-semibold leading-tight line-clamp-2">{p.title || getPhotoTitle(p)}</p>
|
||||||
<div className="mt-1 flex items-center gap-1 text-xs text-pink-600">
|
<div className="mt-2 flex items-center gap-1 text-xs text-white/80">
|
||||||
❤️ {p.likes_count}
|
<Heart className="h-4 w-4 fill-current" aria-hidden />
|
||||||
</div>
|
{p.likes_count ?? 0}
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -209,6 +209,21 @@ export const messages: Record<LocaleCode, NestedMessages> = {
|
|||||||
emptyDescription: 'Sobald Fotos freigegeben sind, erscheinen sie hier.',
|
emptyDescription: 'Sobald Fotos freigegeben sind, erscheinen sie hier.',
|
||||||
lightboxGuestFallback: 'Gast',
|
lightboxGuestFallback: 'Gast',
|
||||||
},
|
},
|
||||||
|
share: {
|
||||||
|
title: 'Geteiltes Foto',
|
||||||
|
defaultEvent: 'Ein besonderer Moment',
|
||||||
|
button: 'Teilen',
|
||||||
|
copyLink: 'Link kopieren',
|
||||||
|
copySuccess: 'Link kopiert!',
|
||||||
|
copyError: 'Link konnte nicht kopiert werden.',
|
||||||
|
manualPrompt: 'Link kopieren',
|
||||||
|
openEvent: 'Event öffnen',
|
||||||
|
loading: 'Moment wird geladen...',
|
||||||
|
expiredTitle: 'Link abgelaufen',
|
||||||
|
expiredDescription: 'Dieser Link ist nicht mehr verfügbar.',
|
||||||
|
shareText: 'Schau dir diesen Moment bei Fotospiel an.',
|
||||||
|
error: 'Teilen fehlgeschlagen',
|
||||||
|
},
|
||||||
uploadQueue: {
|
uploadQueue: {
|
||||||
title: 'Uploads',
|
title: 'Uploads',
|
||||||
description: 'Warteschlange mit Fortschritt und erneuten Versuchen; Hintergrund-Sync umschalten.',
|
description: 'Warteschlange mit Fortschritt und erneuten Versuchen; Hintergrund-Sync umschalten.',
|
||||||
@@ -662,6 +677,21 @@ export const messages: Record<LocaleCode, NestedMessages> = {
|
|||||||
emptyDescription: 'Once photos are approved they will appear here.',
|
emptyDescription: 'Once photos are approved they will appear here.',
|
||||||
lightboxGuestFallback: 'Guest',
|
lightboxGuestFallback: 'Guest',
|
||||||
},
|
},
|
||||||
|
share: {
|
||||||
|
title: 'Shared photo',
|
||||||
|
defaultEvent: 'A special moment',
|
||||||
|
button: 'Share',
|
||||||
|
copyLink: 'Copy link',
|
||||||
|
copySuccess: 'Link copied!',
|
||||||
|
copyError: 'Link could not be copied.',
|
||||||
|
manualPrompt: 'Copy link',
|
||||||
|
openEvent: 'Open event',
|
||||||
|
loading: 'Loading moment...',
|
||||||
|
expiredTitle: 'Link expired',
|
||||||
|
expiredDescription: 'This link is no longer available.',
|
||||||
|
shareText: 'Check out this moment on Fotospiel.',
|
||||||
|
error: 'Share failed',
|
||||||
|
},
|
||||||
uploadQueue: {
|
uploadQueue: {
|
||||||
title: 'Uploads',
|
title: 'Uploads',
|
||||||
description: 'Queue with progress/retry and background sync toggle.',
|
description: 'Queue with progress/retry and background sync toggle.',
|
||||||
|
|||||||
59
resources/js/guest/lib/sharePhoto.ts
Normal file
59
resources/js/guest/lib/sharePhoto.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { createPhotoShareLink } from '../services/photosApi';
|
||||||
|
|
||||||
|
type ShareOptions = {
|
||||||
|
token: string;
|
||||||
|
photoId: number;
|
||||||
|
title?: string;
|
||||||
|
text?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function copyToClipboard(text: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore and fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.value = text;
|
||||||
|
document.body.appendChild(input);
|
||||||
|
input.select();
|
||||||
|
document.execCommand('copy');
|
||||||
|
document.body.removeChild(input);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sharePhotoLink(options: ShareOptions): Promise<{ url: string; method: 'native' | 'clipboard' | 'manual' }>
|
||||||
|
{
|
||||||
|
const payload = await createPhotoShareLink(options.token, options.photoId);
|
||||||
|
const shareData: ShareData = {
|
||||||
|
title: options.title ?? 'Fotospiel Moment',
|
||||||
|
text: options.text ?? '',
|
||||||
|
url: payload.url,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (navigator.share && (!navigator.canShare || navigator.canShare(shareData))) {
|
||||||
|
try {
|
||||||
|
await navigator.share(shareData);
|
||||||
|
return { url: payload.url, method: 'native' };
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error?.name === 'AbortError') {
|
||||||
|
return { url: payload.url, method: 'native' };
|
||||||
|
}
|
||||||
|
// fall through to clipboard
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await copyToClipboard(payload.url)) {
|
||||||
|
return { url: payload.url, method: 'clipboard' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { url: payload.url, method: 'manual' };
|
||||||
|
}
|
||||||
@@ -43,10 +43,6 @@ function formatRelativeTime(input: string): string {
|
|||||||
return `vor ${days} Tagen`;
|
return `vor ${days} Tagen`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function badgeVariant(earned: boolean): string {
|
|
||||||
return earned ? 'bg-emerald-500/10 text-emerald-500 border border-emerald-500/30' : 'bg-muted text-muted-foreground';
|
|
||||||
}
|
|
||||||
|
|
||||||
function Leaderboard({ title, icon: Icon, entries, emptyCopy }: { title: string; icon: React.ElementType; entries: LeaderboardEntry[]; emptyCopy: string }) {
|
function Leaderboard({ title, icon: Icon, entries, emptyCopy }: { title: string; icon: React.ElementType; entries: LeaderboardEntry[]; emptyCopy: string }) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
@@ -83,6 +79,17 @@ function Leaderboard({ title, icon: Icon, entries, emptyCopy }: { title: string;
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function progressMeta(badge: AchievementBadge) {
|
||||||
|
const target = badge.target ?? 0;
|
||||||
|
const progress = badge.progress ?? 0;
|
||||||
|
const ratio = target > 0 ? Math.min(1, progress / target) : 0;
|
||||||
|
return {
|
||||||
|
progress,
|
||||||
|
target,
|
||||||
|
ratio: badge.earned ? 1 : ratio,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function BadgesGrid({ badges }: { badges: AchievementBadge[] }) {
|
function BadgesGrid({ badges }: { badges: AchievementBadge[] }) {
|
||||||
if (badges.length === 0) {
|
if (badges.length === 0) {
|
||||||
return (
|
return (
|
||||||
@@ -104,21 +111,43 @@ function BadgesGrid({ badges }: { badges: AchievementBadge[] }) {
|
|||||||
<CardTitle>Badges</CardTitle>
|
<CardTitle>Badges</CardTitle>
|
||||||
<CardDescription>Dein Fortschritt bei den verfügbaren Erfolgen.</CardDescription>
|
<CardDescription>Dein Fortschritt bei den verfügbaren Erfolgen.</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
<CardContent className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||||
{badges.map((badge) => (
|
{badges.map((badge) => {
|
||||||
<div key={badge.id} className={cn('rounded-xl border px-4 py-3', badgeVariant(badge.earned))}>
|
const { ratio } = progressMeta(badge);
|
||||||
<div className="flex items-center justify-between gap-2">
|
const percentage = Math.round(ratio * 100);
|
||||||
<div>
|
return (
|
||||||
<p className="text-sm font-semibold">{badge.title}</p>
|
<div
|
||||||
<p className="text-xs text-muted-foreground">{badge.description}</p>
|
key={badge.id}
|
||||||
|
className={cn(
|
||||||
|
'relative overflow-hidden rounded-2xl border px-4 py-3 shadow-sm transition',
|
||||||
|
badge.earned
|
||||||
|
? 'border-emerald-400/40 bg-gradient-to-br from-emerald-500/20 via-emerald-500/5 to-white text-emerald-900'
|
||||||
|
: 'border-border/60 bg-white/80',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-foreground">{badge.title}</p>
|
||||||
|
<p className="text-xs text-muted-foreground line-clamp-2">{badge.description}</p>
|
||||||
|
</div>
|
||||||
|
<span className="rounded-full bg-white/30 p-2 text-pink-500">
|
||||||
|
<Award className="h-4 w-4" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3">
|
||||||
|
<div className="h-2 w-full overflow-hidden rounded-full bg-muted/40">
|
||||||
|
<div
|
||||||
|
className={cn('h-2 rounded-full transition-all', badge.earned ? 'bg-emerald-500' : 'bg-pink-500')}
|
||||||
|
style={{ width: `${Math.max(8, percentage)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
{badge.earned ? 'Freigeschaltet 🎉' : `Fortschritt: ${badge.progress}/${badge.target}`}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Award className="h-5 w-5" />
|
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 text-xs text-muted-foreground">
|
);
|
||||||
{badge.earned ? 'Abgeschlossen' : `Fortschritt: ${badge.progress}/${badge.target}`}
|
})}
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,22 +2,38 @@ import React, { useEffect, useState } from 'react';
|
|||||||
import { Page } from './_util';
|
import { Page } from './_util';
|
||||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||||
import { usePollGalleryDelta } from '../polling/usePollGalleryDelta';
|
import { usePollGalleryDelta } from '../polling/usePollGalleryDelta';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
||||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import FiltersBar, { type GalleryFilter } from '../components/FiltersBar';
|
import FiltersBar, { type GalleryFilter } from '../components/FiltersBar';
|
||||||
import { Heart, Users, Image as ImageIcon, Camera, Package as PackageIcon, AlertTriangle } from 'lucide-react';
|
import { Heart, Image as ImageIcon, Share2 } from 'lucide-react';
|
||||||
import { likePhoto } from '../services/photosApi';
|
import { likePhoto } from '../services/photosApi';
|
||||||
import PhotoLightbox from './PhotoLightbox';
|
import PhotoLightbox from './PhotoLightbox';
|
||||||
import { fetchEvent, getEventPackage, fetchStats, type EventData, type EventPackage, type EventStats } from '../services/eventApi';
|
import { fetchEvent, fetchStats, type EventData, type EventStats } from '../services/eventApi';
|
||||||
import { useTranslation } from '../i18n/useTranslation';
|
import { useTranslation } from '../i18n/useTranslation';
|
||||||
|
import { sharePhotoLink } from '../lib/sharePhoto';
|
||||||
|
import { useToast } from '../components/ToastHost';
|
||||||
|
|
||||||
const allowedGalleryFilters: GalleryFilter[] = ['latest', 'popular', 'mine', 'photobooth'];
|
const allowedGalleryFilters: GalleryFilter[] = ['latest', 'popular', 'mine', 'photobooth'];
|
||||||
|
|
||||||
const parseGalleryFilter = (value: string | null): GalleryFilter =>
|
const parseGalleryFilter = (value: string | null): GalleryFilter =>
|
||||||
allowedGalleryFilters.includes(value as GalleryFilter) ? (value as GalleryFilter) : 'latest';
|
allowedGalleryFilters.includes(value as GalleryFilter) ? (value as GalleryFilter) : 'latest';
|
||||||
|
|
||||||
|
const normalizeImageUrl = (src?: string | null) => {
|
||||||
|
if (!src) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^https?:/i.test(src)) {
|
||||||
|
return src;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cleanPath = src.replace(/^\/+/g, '').replace(/\/+/g, '/');
|
||||||
|
if (!cleanPath.startsWith('storage/')) {
|
||||||
|
cleanPath = `storage/${cleanPath}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `/${cleanPath}`.replace(/\/+/g, '/');
|
||||||
|
};
|
||||||
|
|
||||||
export default function GalleryPage() {
|
export default function GalleryPage() {
|
||||||
const { token } = useParams<{ token?: string }>();
|
const { token } = useParams<{ token?: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -30,11 +46,11 @@ export default function GalleryPage() {
|
|||||||
const [hasOpenedPhoto, setHasOpenedPhoto] = useState(false);
|
const [hasOpenedPhoto, setHasOpenedPhoto] = useState(false);
|
||||||
|
|
||||||
const [event, setEvent] = useState<EventData | null>(null);
|
const [event, setEvent] = useState<EventData | null>(null);
|
||||||
const [eventPackage, setEventPackage] = useState<EventPackage | null>(null);
|
|
||||||
const [stats, setStats] = useState<EventStats | null>(null);
|
const [stats, setStats] = useState<EventStats | null>(null);
|
||||||
const [eventLoading, setEventLoading] = useState(true);
|
const [eventLoading, setEventLoading] = useState(true);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const locale = typeof window !== 'undefined' ? window.navigator.language : 'de-DE';
|
const toast = useToast();
|
||||||
|
const [shareTargetId, setShareTargetId] = React.useState<number | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setFilterState(parseGalleryFilter(modeParam));
|
setFilterState(parseGalleryFilter(modeParam));
|
||||||
@@ -64,13 +80,11 @@ export default function GalleryPage() {
|
|||||||
const loadEventData = async () => {
|
const loadEventData = async () => {
|
||||||
try {
|
try {
|
||||||
setEventLoading(true);
|
setEventLoading(true);
|
||||||
const [eventData, packageData, statsData] = await Promise.all([
|
const [eventData, statsData] = await Promise.all([
|
||||||
fetchEvent(token),
|
fetchEvent(token),
|
||||||
getEventPackage(token),
|
|
||||||
fetchStats(token),
|
fetchStats(token),
|
||||||
]);
|
]);
|
||||||
setEvent(eventData);
|
setEvent(eventData);
|
||||||
setEventPackage(packageData);
|
|
||||||
setStats(statsData);
|
setStats(statsData);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load event data', err);
|
console.error('Failed to load event data', err);
|
||||||
@@ -106,67 +120,10 @@ export default function GalleryPage() {
|
|||||||
const [liked, setLiked] = React.useState<Set<number>>(new Set());
|
const [liked, setLiked] = React.useState<Set<number>>(new Set());
|
||||||
const [counts, setCounts] = React.useState<Record<number, number>>({});
|
const [counts, setCounts] = React.useState<Record<number, number>>({});
|
||||||
|
|
||||||
const photoLimits = eventPackage?.limits?.photos ?? null;
|
const totalLikes = React.useMemo(
|
||||||
const guestLimits = eventPackage?.limits?.guests ?? null;
|
() => photos.reduce((sum, photo: any) => sum + (photo.likes_count ?? 0), 0),
|
||||||
const galleryLimits = eventPackage?.limits?.gallery ?? null;
|
[photos],
|
||||||
|
);
|
||||||
const galleryCountdown = React.useMemo(() => {
|
|
||||||
if (!galleryLimits || galleryLimits.state !== 'expired') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
tone: 'danger' as const,
|
|
||||||
label: t('galleryCountdown.expired'),
|
|
||||||
description: t('galleryCountdown.expiredDescription'),
|
|
||||||
cta: null,
|
|
||||||
};
|
|
||||||
}, [galleryLimits, t]);
|
|
||||||
|
|
||||||
const handleCountdownCta = React.useCallback(() => {
|
|
||||||
if (!galleryCountdown?.cta || !token) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (galleryCountdown.cta.type === 'upload') {
|
|
||||||
navigate(`/e/${encodeURIComponent(token)}/upload`);
|
|
||||||
}
|
|
||||||
}, [galleryCountdown?.cta, navigate, token]);
|
|
||||||
|
|
||||||
const packageWarnings = React.useMemo(() => {
|
|
||||||
const warnings: { id: string; tone: 'warning' | 'danger'; message: string }[] = [];
|
|
||||||
|
|
||||||
if (photoLimits?.state === 'limit_reached' && typeof photoLimits.limit === 'number') {
|
|
||||||
warnings.push({
|
|
||||||
id: 'photos-blocked',
|
|
||||||
tone: 'danger',
|
|
||||||
message: t('upload.limitReached')
|
|
||||||
.replace('{used}', `${photoLimits.used}`)
|
|
||||||
.replace('{max}', `${photoLimits.limit}`),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (galleryLimits?.state === 'expired') {
|
|
||||||
warnings.push({
|
|
||||||
id: 'gallery-expired',
|
|
||||||
tone: 'danger',
|
|
||||||
message: t('upload.errors.galleryExpired'),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return warnings;
|
|
||||||
}, [photoLimits, galleryLimits, t]);
|
|
||||||
|
|
||||||
const formatDate = React.useCallback((value: string | null) => {
|
|
||||||
if (!value) return null;
|
|
||||||
const date = new Date(value);
|
|
||||||
if (Number.isNaN(date.getTime())) return null;
|
|
||||||
try {
|
|
||||||
return new Intl.DateTimeFormat(locale, { day: '2-digit', month: 'short', year: 'numeric' }).format(date);
|
|
||||||
} catch {
|
|
||||||
return date.toISOString().slice(0, 10);
|
|
||||||
}
|
|
||||||
}, [locale]);
|
|
||||||
|
|
||||||
async function onLike(id: number) {
|
async function onLike(id: number) {
|
||||||
if (liked.has(id)) return;
|
if (liked.has(id)) return;
|
||||||
@@ -185,6 +142,30 @@ export default function GalleryPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function onShare(photo: any) {
|
||||||
|
if (!token) return;
|
||||||
|
setShareTargetId(photo.id);
|
||||||
|
try {
|
||||||
|
const result = await sharePhotoLink({
|
||||||
|
token,
|
||||||
|
photoId: photo.id,
|
||||||
|
title: photo.task_title ?? event?.name ?? t('share.title', 'Geteiltes Foto'),
|
||||||
|
text: t('share.shareText', { event: event?.name ?? 'Fotospiel' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.method === 'clipboard') {
|
||||||
|
toast.push({ text: t('share.copySuccess', 'Link kopiert!') });
|
||||||
|
} else if (result.method === 'manual') {
|
||||||
|
window.prompt(t('share.manualPrompt', 'Link kopieren'), result.url);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('share failed', error);
|
||||||
|
toast.push({ text: t('share.error', 'Teilen fehlgeschlagen'), type: 'error' });
|
||||||
|
} finally {
|
||||||
|
setShareTargetId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return <Page title="Galerie"><p>Event nicht gefunden.</p></Page>;
|
return <Page title="Galerie"><p>Event nicht gefunden.</p></Page>;
|
||||||
}
|
}
|
||||||
@@ -195,183 +176,109 @@ export default function GalleryPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Page title="Galerie">
|
<Page title="Galerie">
|
||||||
<Card className="mx-4 mb-4">
|
<section className="space-y-4 px-4">
|
||||||
<CardHeader>
|
<div className="rounded-[32px] border border-white/40 bg-gradient-to-br from-pink-50 via-white to-white p-6 shadow-sm">
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||||
<CardTitle className="flex flex-wrap items-center gap-2">
|
<div>
|
||||||
<ImageIcon className="h-6 w-6" />
|
<p className="text-xs uppercase tracking-wide text-muted-foreground">Live-Galerie</p>
|
||||||
<span>Galerie: {event?.name || 'Event'}</span>
|
<div className="mt-1 flex items-center gap-2 text-2xl font-semibold text-foreground">
|
||||||
{galleryCountdown && (
|
<ImageIcon className="h-6 w-6 text-pink-500" aria-hidden />
|
||||||
<Badge
|
<span>{event?.name ?? 'Event'}</span>
|
||||||
variant="secondary"
|
|
||||||
className={galleryCountdown.tone === 'danger'
|
|
||||||
? 'border-rose-200 bg-rose-100 text-rose-700'
|
|
||||||
: 'border-amber-200 bg-amber-100 text-amber-700'}
|
|
||||||
>
|
|
||||||
{galleryCountdown.label}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</CardTitle>
|
|
||||||
{galleryCountdown?.cta && (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant={galleryCountdown.tone === 'danger' ? 'destructive' : 'outline'}
|
|
||||||
onClick={handleCountdownCta}
|
|
||||||
disabled={!token}
|
|
||||||
>
|
|
||||||
{galleryCountdown.cta.label}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{galleryCountdown && (
|
|
||||||
<CardDescription className={galleryCountdown.tone === 'danger' ? 'text-rose-600' : 'text-amber-600'}>
|
|
||||||
{galleryCountdown.description}
|
|
||||||
</CardDescription>
|
|
||||||
)}
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
{packageWarnings.length > 0 && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{packageWarnings.map((warning) => (
|
|
||||||
<Alert
|
|
||||||
key={warning.id}
|
|
||||||
variant={warning.tone === 'danger' ? 'destructive' : 'default'}
|
|
||||||
className={warning.tone === 'warning' ? 'border-amber-400/40 bg-amber-50 text-amber-900' : undefined}
|
|
||||||
>
|
|
||||||
<AlertDescription className="flex items-center gap-2 text-sm">
|
|
||||||
<AlertTriangle className="h-4 w-4" />
|
|
||||||
{warning.message}
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
|
||||||
<div className="text-center">
|
|
||||||
<Users className="h-8 w-8 mx-auto mb-2 text-blue-500" />
|
|
||||||
<p className="font-semibold">Online Gäste</p>
|
|
||||||
<p className="text-2xl">{stats?.onlineGuests || 0}</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-center">
|
|
||||||
<Heart className="h-8 w-8 mx-auto mb-2 text-red-500" />
|
|
||||||
<p className="font-semibold">Gesamt Likes</p>
|
|
||||||
<p className="text-2xl">{photos.reduce((sum, p) => sum + ((p as any).likes_count || 0), 0)}</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-center">
|
|
||||||
<Camera className="h-8 w-8 mx-auto mb-2 text-green-500" />
|
|
||||||
<p className="font-semibold">Gesamt Fotos</p>
|
|
||||||
<p className="text-2xl">{photos.length}</p>
|
|
||||||
</div>
|
|
||||||
{eventPackage && (
|
|
||||||
<div className="rounded-2xl border border-gray-200 bg-white/70 p-4 text-center">
|
|
||||||
<PackageIcon className="mx-auto mb-2 h-8 w-8 text-purple-500" />
|
|
||||||
<p className="font-semibold">Package</p>
|
|
||||||
<p className="text-sm text-gray-600">{eventPackage.package?.name ?? '—'}</p>
|
|
||||||
{photoLimits?.limit ? (
|
|
||||||
<>
|
|
||||||
<div className="mt-3 h-2 w-full overflow-hidden rounded-full bg-gray-200">
|
|
||||||
<div
|
|
||||||
className={`h-2 rounded-full ${photoLimits.state === 'limit_reached' ? 'bg-red-500' : photoLimits.state === 'warning' ? 'bg-amber-500' : 'bg-blue-600'}`}
|
|
||||||
style={{ width: `${Math.min(100, Math.max(6, Math.round((photoLimits.used / photoLimits.limit) * 100))) }%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p className="mt-2 text-xs text-gray-600">
|
|
||||||
{photoLimits.used} / {photoLimits.limit} Fotos
|
|
||||||
</p>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<p className="mt-2 text-xs text-gray-600">{t('upload.limitUnlimited')}</p>
|
|
||||||
)}
|
|
||||||
{guestLimits?.limit ? (
|
|
||||||
<p className="mt-2 text-xs text-gray-500">
|
|
||||||
Gäste: {guestLimits.used} / {guestLimits.limit}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
{galleryLimits?.expires_at ? (
|
|
||||||
<p className="mt-2 text-xs text-gray-500">
|
|
||||||
Galerie bis {formatDate(galleryLimits.expires_at)}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<FiltersBar value={filter} onChange={setFilter} />
|
|
||||||
{newCount > 0 && (
|
|
||||||
<Alert className="mb-3 mx-4">
|
|
||||||
<AlertDescription>
|
|
||||||
{newCount} neue Fotos verfügbar.{' '}
|
|
||||||
<Button variant="link" className="px-1" onClick={acknowledgeNew}>Aktualisieren</Button>
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
{loading && <p className="mx-4">Lade…</p>}
|
|
||||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 px-4">
|
|
||||||
{list.map((p: any) => {
|
|
||||||
// Debug: Log image URLs
|
|
||||||
const imgSrc = p.thumbnail_path || p.file_path;
|
|
||||||
|
|
||||||
// Normalize image URL
|
|
||||||
let imageUrl = imgSrc;
|
|
||||||
let cleanPath = '';
|
|
||||||
|
|
||||||
if (imageUrl) {
|
|
||||||
// Remove leading/trailing slashes for processing
|
|
||||||
cleanPath = imageUrl.replace(/^\/+|\/+$/g, '');
|
|
||||||
|
|
||||||
// Check if path already contains storage prefix
|
|
||||||
if (cleanPath.startsWith('storage/')) {
|
|
||||||
// Already has storage prefix, just ensure it starts with /
|
|
||||||
imageUrl = `/${cleanPath}`;
|
|
||||||
} else {
|
|
||||||
// Add storage prefix
|
|
||||||
imageUrl = `/storage/${cleanPath}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove double slashes
|
|
||||||
imageUrl = imageUrl.replace(/\/+/g, '/');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Production: avoid heavy console logging for each image
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card key={p.id} className="relative overflow-hidden">
|
|
||||||
<CardContent className="p-0">
|
|
||||||
<div
|
|
||||||
onClick={() => {
|
|
||||||
const index = list.findIndex(photo => photo.id === p.id);
|
|
||||||
setCurrentPhotoIndex(index >= 0 ? index : null);
|
|
||||||
}}
|
|
||||||
className="cursor-pointer"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src={imageUrl}
|
|
||||||
alt={`Foto ${p.id}${p.task_title ? ` - ${p.task_title}` : ''}`}
|
|
||||||
className="aspect-square w-full object-cover bg-gray-200"
|
|
||||||
onError={(e) => {
|
|
||||||
(e.target as HTMLImageElement).src = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTAwIiBoZWlnaHQ9IjEwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiBmaWxsPSIjRjNGNEY2Ii8+PHRleHQgeD0iNTAlIiB5PSI1MCUiIGZvbnQtZmFtaWx5PSJBcmlhbCIgZm9udC1zaXplPSIxNCIgZmlsbD0iIzk5OSIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZHk9Ii4zZW0iPk5vIEltYWdlPC90ZXh0Pjwvc3ZnPg==';
|
|
||||||
}}
|
|
||||||
loading="lazy"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
|
|
||||||
{p.task_title && (
|
|
||||||
<div className="px-2 pb-2 text-center">
|
|
||||||
<p className="text-xs text-gray-700 truncate bg-white/80 py-1 rounded-sm">{p.task_title}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="absolute bottom-1 right-1 flex items-center gap-1 rounded-full bg-black/50 px-2 py-1 text-white">
|
|
||||||
<button onClick={(e) => { e.preventDefault(); e.stopPropagation(); onLike(p.id); }} className={`inline-flex items-center ${liked.has(p.id) ? 'text-red-400' : ''}`} aria-label="Like">
|
|
||||||
<Heart className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
<span className="text-xs">{counts[p.id] ?? (p.likes_count || 0)}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
{photos.length} Fotos · {totalLikes} ❤️ · {stats?.onlineGuests ?? 0} Gäste online
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-start gap-2 sm:items-end">
|
||||||
|
{newCount > 0 && (
|
||||||
|
<Button size="sm" className="rounded-full" onClick={acknowledgeNew}>
|
||||||
|
{newCount} neue Fotos ansehen
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => navigate(`/e/${encodeURIComponent(token)}/upload`)}
|
||||||
|
>
|
||||||
|
Neues Foto hochladen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<FiltersBar value={filter} onChange={setFilter} className="mt-2" />
|
||||||
|
{loading && <p className="px-4">Lade…</p>}
|
||||||
|
<div className="grid gap-3 px-4 pb-16 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{list.map((p: any) => {
|
||||||
|
const imageUrl = normalizeImageUrl(p.thumbnail_path || p.file_path);
|
||||||
|
const createdLabel = p.created_at
|
||||||
|
? new Date(p.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||||
|
: t('gallery.justNow', 'Gerade eben');
|
||||||
|
const likeCount = counts[p.id] ?? (p.likes_count || 0);
|
||||||
|
|
||||||
|
const openPhoto = () => {
|
||||||
|
const index = list.findIndex((photo: any) => photo.id === p.id);
|
||||||
|
setCurrentPhotoIndex(index >= 0 ? index : null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={p.id}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={openPhoto}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
openPhoto();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="group relative overflow-hidden rounded-[28px] border border-white/20 bg-gray-950 text-white shadow-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-pink-400"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={imageUrl}
|
||||||
|
alt={`Foto ${p.id}${p.task_title ? ` - ${p.task_title}` : ''}`}
|
||||||
|
className="h-64 w-full object-cover transition duration-500 group-hover:scale-105"
|
||||||
|
onError={(e) => {
|
||||||
|
(e.target as HTMLImageElement).src = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTAwIiBoZWlnaHQ9IjEwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiBmaWxsPSIjRjNGNEY2Ii8+PHRleHQgeD0iNTAlIiB5PSI1MCUiIGZvbnQtZmFtaWx5PSJBcmlhbCIgZm9udC1zaXplPSIxNCIgZmlsbD0iIzk5OSIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZHk9Ii4zZW0iPk5vIEltYWdlPC90ZXh0Pjwvc3ZnPg==';
|
||||||
|
}}
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/80 via-black/10 to-transparent" aria-hidden />
|
||||||
|
<div className="pointer-events-none absolute inset-x-0 bottom-0 space-y-2 px-4 pb-4">
|
||||||
|
{p.task_title && <p className="text-sm font-medium leading-tight line-clamp-2">{p.task_title}</p>}
|
||||||
|
<div className="flex items-center justify-between text-xs text-white/80">
|
||||||
|
<span>{createdLabel}</span>
|
||||||
|
<span>{p.uploader_name || 'Gast'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="absolute bottom-3 right-3 flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onShare(p);
|
||||||
|
}}
|
||||||
|
className={`flex h-9 w-9 items-center justify-center rounded-full border border-white/30 bg-white/10 transition ${shareTargetId === p.id ? 'opacity-60' : 'hover:bg-white/20'}`}
|
||||||
|
aria-label={t('share.button', 'Teilen')}
|
||||||
|
disabled={shareTargetId === p.id}
|
||||||
|
>
|
||||||
|
<Share2 className="h-4 w-4" aria-hidden />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onLike(p.id);
|
||||||
|
}}
|
||||||
|
className={`flex items-center gap-1 rounded-full border border-white/30 bg-white/10 px-3 py-1 text-sm font-medium transition ${liked.has(p.id) ? 'text-pink-300' : 'text-white'}`}
|
||||||
|
aria-label="Foto liken"
|
||||||
|
>
|
||||||
|
<Heart className={`h-4 w-4 ${liked.has(p.id) ? 'fill-current' : ''}`} aria-hidden />
|
||||||
|
{likeCount}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { useGuestIdentity } from '../context/GuestIdentityContext';
|
|||||||
import { useEventStats } from '../context/EventStatsContext';
|
import { useEventStats } from '../context/EventStatsContext';
|
||||||
import { useEventData } from '../hooks/useEventData';
|
import { useEventData } from '../hooks/useEventData';
|
||||||
import { useGuestTaskProgress } from '../hooks/useGuestTaskProgress';
|
import { useGuestTaskProgress } from '../hooks/useGuestTaskProgress';
|
||||||
import { Sparkles, UploadCloud, Images, CheckCircle2, Users, TimerReset, X } from 'lucide-react';
|
import { Sparkles, UploadCloud, Images, CheckCircle2, Users, TimerReset, X, Camera, ArrowUpRight } from 'lucide-react';
|
||||||
import { useTranslation, type TranslateFn } from '../i18n/useTranslation';
|
import { useTranslation, type TranslateFn } from '../i18n/useTranslation';
|
||||||
import { useEventBranding } from '../context/EventBrandingContext';
|
import { useEventBranding } from '../context/EventBrandingContext';
|
||||||
import type { EventBranding } from '../types/event-branding';
|
import type { EventBranding } from '../types/event-branding';
|
||||||
@@ -67,25 +67,52 @@ export default function HomePage() {
|
|||||||
const accentColor = branding.primaryColor;
|
const accentColor = branding.primaryColor;
|
||||||
const secondaryAccent = branding.secondaryColor;
|
const secondaryAccent = branding.secondaryColor;
|
||||||
|
|
||||||
const primaryActions = React.useMemo(
|
const statItems = React.useMemo(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
to: 'tasks',
|
icon: <Users className="h-4 w-4" aria-hidden />,
|
||||||
label: t('home.actions.items.tasks.label'),
|
label: t('home.stats.online'),
|
||||||
description: t('home.actions.items.tasks.description'),
|
value: `${stats.onlineGuests}`,
|
||||||
icon: <Sparkles className="h-5 w-5" aria-hidden />,
|
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
icon: <Sparkles className="h-4 w-4" aria-hidden />,
|
||||||
|
label: t('home.stats.tasksSolved'),
|
||||||
|
value: `${stats.tasksSolved}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <TimerReset className="h-4 w-4" aria-hidden />,
|
||||||
|
label: t('home.stats.lastUpload'),
|
||||||
|
value: latestUploadText,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <CheckCircle2 className="h-4 w-4" aria-hidden />,
|
||||||
|
label: t('home.stats.completedTasks'),
|
||||||
|
value: `${completedCount}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[completedCount, latestUploadText, stats.onlineGuests, stats.tasksSolved, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
const quickActions = React.useMemo(
|
||||||
|
() => [
|
||||||
{
|
{
|
||||||
to: 'upload',
|
to: 'upload',
|
||||||
label: t('home.actions.items.upload.label'),
|
label: t('home.actions.items.upload.label'),
|
||||||
description: t('home.actions.items.upload.description'),
|
description: t('home.actions.items.upload.description'),
|
||||||
icon: <UploadCloud className="h-5 w-5" aria-hidden />,
|
icon: <Camera className="h-5 w-5" aria-hidden />,
|
||||||
|
highlight: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: 'tasks',
|
||||||
|
label: t('home.actions.items.tasks.label'),
|
||||||
|
description: t('home.actions.items.tasks.description'),
|
||||||
|
icon: <Sparkles className="h-5 w-5" aria-hidden />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
to: 'gallery',
|
to: 'gallery',
|
||||||
label: t('home.actions.items.gallery.label'),
|
label: t('home.actions.items.gallery.label'),
|
||||||
description: t('home.actions.items.gallery.description'),
|
description: t('home.actions.items.gallery.description'),
|
||||||
icon: <Images className="h-5 w-5" aria-hidden />,
|
icon: <Images className="h-5 w-5" aria-hidden />,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[t],
|
[t],
|
||||||
@@ -105,7 +132,7 @@ export default function HomePage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 pb-24">
|
<div className="space-y-6 pb-32">
|
||||||
{heroVisible && (
|
{heroVisible && (
|
||||||
<HeroCard
|
<HeroCard
|
||||||
name={displayName}
|
name={displayName}
|
||||||
@@ -114,73 +141,36 @@ export default function HomePage() {
|
|||||||
t={t}
|
t={t}
|
||||||
branding={branding}
|
branding={branding}
|
||||||
onDismiss={dismissHero}
|
onDismiss={dismissHero}
|
||||||
|
ctaLabel={t('home.actions.items.tasks.label')}
|
||||||
|
ctaHref={`/e/${encodeURIComponent(token)}/tasks`}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Card style={branding.fontFamily ? { fontFamily: branding.fontFamily } : undefined}>
|
<StatsRibbon items={statItems} accentColor={accentColor} fontFamily={branding.fontFamily} />
|
||||||
<CardContent className="grid grid-cols-1 gap-4 py-4 sm:grid-cols-4">
|
|
||||||
<StatTile
|
|
||||||
icon={<Users className="h-4 w-4" aria-hidden />}
|
|
||||||
label={t('home.stats.online')}
|
|
||||||
value={`${stats.onlineGuests}`}
|
|
||||||
accentColor={accentColor}
|
|
||||||
/>
|
|
||||||
<StatTile
|
|
||||||
icon={<Sparkles className="h-4 w-4" aria-hidden />}
|
|
||||||
label={t('home.stats.tasksSolved')}
|
|
||||||
value={`${stats.tasksSolved}`}
|
|
||||||
accentColor={accentColor}
|
|
||||||
/>
|
|
||||||
<StatTile
|
|
||||||
icon={<TimerReset className="h-4 w-4" aria-hidden />}
|
|
||||||
label={t('home.stats.lastUpload')}
|
|
||||||
value={latestUploadText}
|
|
||||||
accentColor={accentColor}
|
|
||||||
/>
|
|
||||||
<StatTile
|
|
||||||
icon={<CheckCircle2 className="h-4 w-4" aria-hidden />}
|
|
||||||
label={t('home.stats.completedTasks')}
|
|
||||||
value={`${completedCount}`}
|
|
||||||
accentColor={accentColor}
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<section className="space-y-3">
|
<section className="space-y-4" style={branding.fontFamily ? { fontFamily: branding.fontFamily } : undefined}>
|
||||||
<div className="flex items-center justify-between" style={branding.fontFamily ? { fontFamily: branding.fontFamily } : undefined}>
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
<div>
|
||||||
{t('home.actions.title')}
|
<h2 className="text-base font-semibold text-foreground">{t('home.actions.title')}</h2>
|
||||||
</h2>
|
<p className="text-xs text-muted-foreground">{t('home.actions.subtitle')}</p>
|
||||||
<span className="text-xs text-muted-foreground">{t('home.actions.subtitle')}</span>
|
</div>
|
||||||
|
<ArrowUpRight className="h-5 w-5 text-muted-foreground" aria-hidden />
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
{primaryActions.map((action) => (
|
{quickActions.map((action) => (
|
||||||
<Link to={action.to} key={action.to} className="block touch-manipulation">
|
<QuickActionCard
|
||||||
<Card className="transition-all hover:shadow-lg">
|
key={action.to}
|
||||||
<CardContent className="flex items-center gap-3 py-4">
|
action={action}
|
||||||
<div
|
accentColor={accentColor}
|
||||||
className="flex h-10 w-10 items-center justify-center rounded-lg shadow-sm"
|
secondaryAccent={secondaryAccent}
|
||||||
style={{
|
/>
|
||||||
backgroundColor: `${secondaryAccent}1a`,
|
|
||||||
color: secondaryAccent,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{action.icon}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="text-base font-semibold" style={branding.fontFamily ? { fontFamily: branding.fontFamily } : undefined}>{action.label}</span>
|
|
||||||
<span className="text-sm text-muted-foreground">{action.description}</span>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</Link>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
asChild
|
asChild
|
||||||
className="w-full touch-manipulation"
|
className="w-full touch-manipulation border-dashed"
|
||||||
style={{ borderColor: `${accentColor}33` }}
|
style={{ borderColor: `${accentColor}44` }}
|
||||||
>
|
>
|
||||||
<Link to="queue">{t('home.actions.queueButton')}</Link>
|
<Link to="queue">{t('home.actions.queueButton')}</Link>
|
||||||
</Button>
|
</Button>
|
||||||
@@ -217,6 +207,8 @@ function HeroCard({
|
|||||||
t,
|
t,
|
||||||
branding,
|
branding,
|
||||||
onDismiss,
|
onDismiss,
|
||||||
|
ctaLabel,
|
||||||
|
ctaHref,
|
||||||
}: {
|
}: {
|
||||||
name: string;
|
name: string;
|
||||||
eventName: string;
|
eventName: string;
|
||||||
@@ -224,6 +216,8 @@ function HeroCard({
|
|||||||
t: TranslateFn;
|
t: TranslateFn;
|
||||||
branding: EventBranding;
|
branding: EventBranding;
|
||||||
onDismiss: () => void;
|
onDismiss: () => void;
|
||||||
|
ctaLabel?: string;
|
||||||
|
ctaHref?: string;
|
||||||
}) {
|
}) {
|
||||||
const heroTitle = t('home.hero.title').replace('{name}', name);
|
const heroTitle = t('home.hero.title').replace('{name}', name);
|
||||||
const heroDescription = t('home.hero.description').replace('{eventName}', eventName);
|
const heroDescription = t('home.hero.description').replace('{eventName}', eventName);
|
||||||
@@ -249,33 +243,114 @@ function HeroCard({
|
|||||||
<X className="h-4 w-4" aria-hidden />
|
<X className="h-4 w-4" aria-hidden />
|
||||||
<span className="sr-only">{t('common.actions.close')}</span>
|
<span className="sr-only">{t('common.actions.close')}</span>
|
||||||
</Button>
|
</Button>
|
||||||
<CardHeader className="space-y-2 pr-10">
|
<CardHeader className="space-y-4 pr-10">
|
||||||
<CardDescription className="text-sm text-white/80">{t('home.hero.subtitle')}</CardDescription>
|
<CardDescription className="text-sm text-white/80">{t('home.hero.subtitle')}</CardDescription>
|
||||||
<CardTitle className="text-2xl font-bold leading-snug">{heroTitle}</CardTitle>
|
<CardTitle className="text-2xl font-bold leading-snug">{heroTitle}</CardTitle>
|
||||||
<p className="text-sm text-white/85">{heroDescription}</p>
|
<p className="text-sm text-white/85">{heroDescription}</p>
|
||||||
<p className="text-sm font-medium text-white/90">{progressMessage}</p>
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<p className="text-sm font-medium text-white/90">{progressMessage}</p>
|
||||||
|
{ctaHref && ctaLabel && (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
asChild
|
||||||
|
className="rounded-full bg-white/15 px-4 py-1 text-xs font-semibold uppercase tracking-wide text-white hover:bg-white/25"
|
||||||
|
>
|
||||||
|
<Link to={ctaHref}>{ctaLabel}</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatTile({ icon, label, value, accentColor }: { icon: React.ReactNode; label: string; value: string; accentColor: string }) {
|
function StatsRibbon({
|
||||||
|
items,
|
||||||
|
accentColor,
|
||||||
|
fontFamily,
|
||||||
|
}: {
|
||||||
|
items: { icon: React.ReactNode; label: string; value: string }[];
|
||||||
|
accentColor: string;
|
||||||
|
fontFamily?: string | null;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-3 rounded-lg border bg-muted/30 px-3 py-2">
|
<div className="overflow-hidden rounded-3xl border border-muted/40 bg-white/70 shadow-sm backdrop-blur">
|
||||||
<div
|
<div
|
||||||
className="flex h-10 w-10 items-center justify-center rounded-full bg-white shadow-sm"
|
className="flex gap-3 overflow-x-auto px-4 py-3 [scrollbar-width:none] sm:grid sm:grid-cols-4 sm:overflow-visible"
|
||||||
style={{ color: accentColor }}
|
style={fontFamily ? { fontFamily } : undefined}
|
||||||
>
|
>
|
||||||
{icon}
|
{items.map((item) => (
|
||||||
</div>
|
<div
|
||||||
<div className="flex flex-col">
|
key={item.label}
|
||||||
<span className="text-xs uppercase tracking-wide text-muted-foreground">{label}</span>
|
className="flex min-w-[150px] flex-1 items-center gap-3 rounded-2xl border border-transparent bg-white/60 px-3 py-2 shadow-sm transition hover:-translate-y-0.5 hover:border-white"
|
||||||
<span className="text-lg font-semibold text-foreground">{value}</span>
|
>
|
||||||
|
<div
|
||||||
|
className="flex h-10 w-10 items-center justify-center rounded-full bg-white shadow"
|
||||||
|
style={{ color: accentColor }}
|
||||||
|
>
|
||||||
|
{item.icon}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-[11px] uppercase tracking-wide text-muted-foreground">{item.label}</span>
|
||||||
|
<span className="text-lg font-semibold text-foreground">{item.value}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function QuickActionCard({
|
||||||
|
action,
|
||||||
|
accentColor,
|
||||||
|
secondaryAccent,
|
||||||
|
}: {
|
||||||
|
action: { to: string; label: string; description: string; icon: React.ReactNode; highlight?: boolean };
|
||||||
|
accentColor: string;
|
||||||
|
secondaryAccent: string;
|
||||||
|
}) {
|
||||||
|
const highlightStyle = action.highlight
|
||||||
|
? {
|
||||||
|
background: `linear-gradient(120deg, ${accentColor}, ${secondaryAccent})`,
|
||||||
|
color: '#fff',
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link to={action.to} className="group block">
|
||||||
|
<Card
|
||||||
|
className="relative overflow-hidden border-0 shadow-sm transition-all group-hover:shadow-lg"
|
||||||
|
style={highlightStyle}
|
||||||
|
>
|
||||||
|
<CardContent className="flex items-start gap-4 py-4">
|
||||||
|
<div
|
||||||
|
className={`flex h-11 w-11 items-center justify-center rounded-2xl shadow-sm ${
|
||||||
|
action.highlight ? 'bg-white/15 text-white' : 'bg-pink-50'
|
||||||
|
}`}
|
||||||
|
style={!action.highlight ? { color: secondaryAccent } : undefined}
|
||||||
|
>
|
||||||
|
{action.icon}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-1 flex-col">
|
||||||
|
<span className={`text-base font-semibold ${action.highlight ? 'text-white' : 'text-foreground'}`}>
|
||||||
|
{action.label}
|
||||||
|
</span>
|
||||||
|
<span className={`text-sm ${action.highlight ? 'text-white/80' : 'text-muted-foreground'}`}>
|
||||||
|
{action.description}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ArrowUpRight
|
||||||
|
className={`h-4 w-4 transition ${action.highlight ? 'text-white/70 group-hover:translate-x-0.5 group-hover:-translate-y-0.5' : 'text-muted-foreground group-hover:text-foreground'}`}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function formatLatestUpload(isoDate: string | null, t: TranslateFn) {
|
function formatLatestUpload(isoDate: string | null, t: TranslateFn) {
|
||||||
if (!isoDate) {
|
if (!isoDate) {
|
||||||
return t('home.latestUpload.none');
|
return t('home.latestUpload.none');
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ import React, { useState, useEffect } from 'react';
|
|||||||
import { useParams, useLocation, useNavigate } from 'react-router-dom';
|
import { useParams, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { Dialog, DialogContent } from '@/components/ui/dialog';
|
import { Dialog, DialogContent } from '@/components/ui/dialog';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Heart, ChevronLeft, ChevronRight, X } from 'lucide-react';
|
import { Heart, ChevronLeft, ChevronRight, X, Share2 } from 'lucide-react';
|
||||||
import { likePhoto } from '../services/photosApi';
|
import { likePhoto } from '../services/photosApi';
|
||||||
import { useTranslation } from '../i18n/useTranslation';
|
import { useTranslation } from '../i18n/useTranslation';
|
||||||
|
import { sharePhotoLink } from '../lib/sharePhoto';
|
||||||
|
import { useToast } from '../components/ToastHost';
|
||||||
|
|
||||||
type Photo = {
|
type Photo = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -33,6 +35,7 @@ export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexCh
|
|||||||
const photoId = params.photoId;
|
const photoId = params.photoId;
|
||||||
const eventToken = params.token || token;
|
const eventToken = params.token || token;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
const [standalonePhoto, setStandalonePhoto] = useState<Photo | null>(null);
|
const [standalonePhoto, setStandalonePhoto] = useState<Photo | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -41,6 +44,7 @@ export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexCh
|
|||||||
const [taskLoading, setTaskLoading] = useState(false);
|
const [taskLoading, setTaskLoading] = useState(false);
|
||||||
const [likes, setLikes] = useState<number>(0);
|
const [likes, setLikes] = useState<number>(0);
|
||||||
const [liked, setLiked] = useState(false);
|
const [liked, setLiked] = useState(false);
|
||||||
|
const [shareLoading, setShareLoading] = useState(false);
|
||||||
|
|
||||||
// Determine mode and photo
|
// Determine mode and photo
|
||||||
const isStandalone = !photos || photos.length === 0;
|
const isStandalone = !photos || photos.length === 0;
|
||||||
@@ -197,6 +201,30 @@ export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexCh
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function onShare() {
|
||||||
|
if (!photo || !eventToken) return;
|
||||||
|
setShareLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await sharePhotoLink({
|
||||||
|
token: eventToken,
|
||||||
|
photoId: photo.id,
|
||||||
|
title: photo.task_title ?? task?.title ?? t('share.title', 'Geteiltes Foto'),
|
||||||
|
text: t('share.shareText', { event: '' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.method === 'clipboard') {
|
||||||
|
toast.push({ text: t('share.copySuccess', 'Link kopiert!') });
|
||||||
|
} else if (result.method === 'manual') {
|
||||||
|
window.prompt(t('share.manualPrompt', 'Link kopieren'), result.url);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('share failed', error);
|
||||||
|
toast.push({ text: t('share.error', 'Teilen fehlgeschlagen'), type: 'error' });
|
||||||
|
} finally {
|
||||||
|
setShareLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function onOpenChange(open: boolean) {
|
function onOpenChange(open: boolean) {
|
||||||
if (!open) handleClose();
|
if (!open) handleClose();
|
||||||
}
|
}
|
||||||
@@ -216,6 +244,15 @@ export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexCh
|
|||||||
<Heart className={`mr-1 h-4 w-4 ${liked ? 'fill-red-400 text-red-400' : ''}`} />
|
<Heart className={`mr-1 h-4 w-4 ${liked ? 'fill-red-400 text-red-400' : ''}`} />
|
||||||
{likes}
|
{likes}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={onShare}
|
||||||
|
disabled={shareLoading || !eventToken || !photo}
|
||||||
|
>
|
||||||
|
<Share2 className="mr-1 h-4 w-4" />
|
||||||
|
{t('share.button', 'Teilen')}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{currentIndexVal > 0 && (
|
{currentIndexVal > 0 && (
|
||||||
|
|||||||
121
resources/js/guest/pages/SharedPhotoPage.tsx
Normal file
121
resources/js/guest/pages/SharedPhotoPage.tsx
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useParams, Link } from 'react-router-dom';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { fetchPhotoShare } from '../services/photosApi';
|
||||||
|
import { useTranslation } from '../i18n/useTranslation';
|
||||||
|
import { useToast } from '../components/ToastHost';
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
|
interface ShareResponse {
|
||||||
|
slug: string;
|
||||||
|
expires_at?: string;
|
||||||
|
photo: {
|
||||||
|
id: number;
|
||||||
|
title?: string;
|
||||||
|
likes_count?: number;
|
||||||
|
emotion?: { name?: string; emoji?: string } | null;
|
||||||
|
image_urls: { full: string; thumbnail: string };
|
||||||
|
};
|
||||||
|
event?: { id: number; name?: string | null } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SharedPhotoPage() {
|
||||||
|
const { slug } = useParams<{ slug: string }>();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const toast = useToast();
|
||||||
|
const [state, setState] = React.useState<{
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
data: ShareResponse | null;
|
||||||
|
}>({ loading: true, error: null, data: null });
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
if (!slug) return;
|
||||||
|
|
||||||
|
setState({ loading: true, error: null, data: null });
|
||||||
|
fetchPhotoShare(slug)
|
||||||
|
.then((data) => {
|
||||||
|
if (!active) return;
|
||||||
|
setState({ loading: false, error: null, data });
|
||||||
|
})
|
||||||
|
.catch((error: any) => {
|
||||||
|
if (!active) return;
|
||||||
|
setState({ loading: false, error: error?.message ?? t('share.error', 'Moment konnte nicht geladen werden.'), data: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}, [slug, t]);
|
||||||
|
|
||||||
|
const handleCopy = React.useCallback(async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(window.location.href);
|
||||||
|
toast.push({ text: t('share.copySuccess', 'Link kopiert!') });
|
||||||
|
} catch {
|
||||||
|
toast.push({ text: t('share.copyError', 'Link konnte nicht kopiert werden.'), type: 'error' });
|
||||||
|
}
|
||||||
|
}, [toast, t]);
|
||||||
|
|
||||||
|
if (state.loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen flex-col items-center justify-center gap-3 bg-gradient-to-br from-pink-50 to-white px-4 text-center">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-pink-500" aria-hidden />
|
||||||
|
<p className="text-sm text-muted-foreground">{t('share.loading', 'Moment wird geladen...')}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.error || !state.data) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen flex-col items-center justify-center gap-4 bg-gradient-to-br from-pink-50 to-white px-6 text-center">
|
||||||
|
<p className="text-lg font-semibold text-foreground">{t('share.expiredTitle', 'Link abgelaufen')}</p>
|
||||||
|
<p className="text-sm text-muted-foreground max-w-md">{state.error ?? t('share.expiredDescription', 'Dieses Foto ist nicht mehr verfügbar.')}</p>
|
||||||
|
<Button asChild>
|
||||||
|
<Link to="/event">{t('share.openEvent', 'Event öffnen')}</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data } = state;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-b from-white via-pink-50 to-white px-4 py-8">
|
||||||
|
<div className="mx-auto flex max-w-2xl flex-col gap-5">
|
||||||
|
<div className="rounded-3xl border border-white/60 bg-white/80 p-6 text-center shadow">
|
||||||
|
<p className="text-xs uppercase tracking-wide text-muted-foreground">{t('share.title', 'Geteiltes Foto')}</p>
|
||||||
|
<h1 className="mt-2 text-2xl font-semibold text-foreground">{data.event?.name ?? t('share.defaultEvent', 'Ein besonderer Moment')}</h1>
|
||||||
|
{data.photo.title && (
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">{data.photo.title}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-hidden rounded-[32px] border border-white/60 bg-black">
|
||||||
|
<img
|
||||||
|
src={data.photo.image_urls.full}
|
||||||
|
alt={data.photo.title ?? 'Foto'}
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{data.photo.emotion && (
|
||||||
|
<p className="text-center text-sm text-muted-foreground">
|
||||||
|
{data.photo.emotion.emoji} {data.photo.emotion.name}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:justify-center">
|
||||||
|
<Button variant="secondary" onClick={handleCopy}>
|
||||||
|
{t('share.copyLink', 'Link kopieren')}
|
||||||
|
</Button>
|
||||||
|
<Button asChild>
|
||||||
|
<Link to="/event">{t('share.openEvent', 'Event öffnen')}</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -30,6 +30,7 @@ const LegalPage = React.lazy(() => import('./pages/LegalPage'));
|
|||||||
const HelpCenterPage = React.lazy(() => import('./pages/HelpCenterPage'));
|
const HelpCenterPage = React.lazy(() => import('./pages/HelpCenterPage'));
|
||||||
const HelpArticlePage = React.lazy(() => import('./pages/HelpArticlePage'));
|
const HelpArticlePage = React.lazy(() => import('./pages/HelpArticlePage'));
|
||||||
const PublicGalleryPage = React.lazy(() => import('./pages/PublicGalleryPage'));
|
const PublicGalleryPage = React.lazy(() => import('./pages/PublicGalleryPage'));
|
||||||
|
const SharedPhotoPage = React.lazy(() => import('./pages/SharedPhotoPage'));
|
||||||
const NotFoundPage = React.lazy(() => import('./pages/NotFoundPage'));
|
const NotFoundPage = React.lazy(() => import('./pages/NotFoundPage'));
|
||||||
|
|
||||||
function HomeLayout() {
|
function HomeLayout() {
|
||||||
@@ -56,6 +57,7 @@ function HomeLayout() {
|
|||||||
|
|
||||||
export const router = createBrowserRouter([
|
export const router = createBrowserRouter([
|
||||||
{ path: '/event', element: <SimpleLayout title="Event"><LandingPage /></SimpleLayout> },
|
{ path: '/event', element: <SimpleLayout title="Event"><LandingPage /></SimpleLayout> },
|
||||||
|
{ path: '/share/:slug', element: <SharedPhotoPage /> },
|
||||||
{
|
{
|
||||||
path: '/setup/:token',
|
path: '/setup/:token',
|
||||||
element: <SetupLayout />,
|
element: <SetupLayout />,
|
||||||
|
|||||||
@@ -134,3 +134,43 @@ export async function uploadPhoto(eventToken: string, file: File, taskId?: numbe
|
|||||||
const json = await res.json();
|
const json = await res.json();
|
||||||
return json.photo_id ?? json.id ?? json.data?.id ?? 0;
|
return json.photo_id ?? json.id ?? json.data?.id ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function createPhotoShareLink(eventToken: string, photoId: number): Promise<{ slug: string; url: string; expires_at?: string }> {
|
||||||
|
const headers = getCsrfHeaders();
|
||||||
|
|
||||||
|
const res = await fetch(`/api/v1/events/${encodeURIComponent(eventToken)}/photos/${photoId}/share`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
let payload: any = null;
|
||||||
|
try {
|
||||||
|
payload = await res.clone().json();
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
const error: UploadError = new Error(payload?.error?.message ?? 'Share link creation failed');
|
||||||
|
error.code = payload?.error?.code ?? 'share_failed';
|
||||||
|
error.status = res.status;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchPhotoShare(slug: string) {
|
||||||
|
const res = await fetch(`/api/v1/photo-shares/${encodeURIComponent(slug)}`, {
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const payload = await res.json().catch(() => null);
|
||||||
|
const error: UploadError = new Error(payload?.error?.message ?? 'Share link unavailable');
|
||||||
|
error.code = payload?.error?.code ?? 'share_unavailable';
|
||||||
|
error.status = res.status;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Http\Controllers\Api\EventPublicController;
|
use App\Http\Controllers\Api\EventPublicController;
|
||||||
use App\Http\Controllers\Api\LegalController;
|
|
||||||
use App\Http\Controllers\Api\HelpController;
|
use App\Http\Controllers\Api\HelpController;
|
||||||
|
use App\Http\Controllers\Api\LegalController;
|
||||||
use App\Http\Controllers\Api\Marketing\CouponPreviewController;
|
use App\Http\Controllers\Api\Marketing\CouponPreviewController;
|
||||||
use App\Http\Controllers\Api\PackageController;
|
use App\Http\Controllers\Api\PackageController;
|
||||||
use App\Http\Controllers\Api\Tenant\DashboardController;
|
use App\Http\Controllers\Api\Tenant\DashboardController;
|
||||||
@@ -73,6 +73,13 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
|
|||||||
Route::get('/events/{token}/photos', [EventPublicController::class, 'photos'])->name('events.photos');
|
Route::get('/events/{token}/photos', [EventPublicController::class, 'photos'])->name('events.photos');
|
||||||
Route::get('/photos/{id}', [EventPublicController::class, 'photo'])->name('photos.show');
|
Route::get('/photos/{id}', [EventPublicController::class, 'photo'])->name('photos.show');
|
||||||
Route::post('/photos/{id}/like', [EventPublicController::class, 'like'])->name('photos.like');
|
Route::post('/photos/{id}/like', [EventPublicController::class, 'like'])->name('photos.like');
|
||||||
|
Route::post('/events/{token}/photos/{photo}/share', [EventPublicController::class, 'createShareLink'])
|
||||||
|
->whereNumber('photo')
|
||||||
|
->name('photos.share');
|
||||||
|
Route::get('/photo-shares/{slug}', [EventPublicController::class, 'shareLink'])->name('photo-shares.show');
|
||||||
|
Route::get('/photo-shares/{slug}/asset/{variant}', [EventPublicController::class, 'shareLinkAsset'])
|
||||||
|
->middleware('signed')
|
||||||
|
->name('photo-shares.asset');
|
||||||
Route::post('/events/{token}/upload', [EventPublicController::class, 'upload'])->name('events.upload');
|
Route::post('/events/{token}/upload', [EventPublicController::class, 'upload'])->name('events.upload');
|
||||||
|
|
||||||
Route::get('/gallery/{token}', [EventPublicController::class, 'gallery'])->name('gallery.show');
|
Route::get('/gallery/{token}', [EventPublicController::class, 'gallery'])->name('gallery.show');
|
||||||
|
|||||||
@@ -276,6 +276,9 @@ Route::view('/e/{token}/{path?}', 'guest')
|
|||||||
->where('token', '.*')
|
->where('token', '.*')
|
||||||
->where('path', '.*')
|
->where('path', '.*')
|
||||||
->name('guest.event');
|
->name('guest.event');
|
||||||
|
Route::view('/share/{slug}', 'guest')
|
||||||
|
->where('slug', '[A-Za-z0-9]+')
|
||||||
|
->name('guest.share');
|
||||||
Route::middleware('auth')->group(function () {
|
Route::middleware('auth')->group(function () {
|
||||||
Route::get('/tenant/events/{event}/photos/archive', EventPhotoArchiveController::class)
|
Route::get('/tenant/events/{event}/photos/archive', EventPhotoArchiveController::class)
|
||||||
->name('tenant.events.photos.archive');
|
->name('tenant.events.photos.archive');
|
||||||
|
|||||||
86
tests/Feature/Api/PhotoShareLinkTest.php
Normal file
86
tests/Feature/Api/PhotoShareLinkTest.php
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Api;
|
||||||
|
|
||||||
|
use App\Models\Event;
|
||||||
|
use App\Models\Photo;
|
||||||
|
use App\Models\PhotoShareLink;
|
||||||
|
use App\Models\Task;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Services\EventJoinTokenService;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Config;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class PhotoShareLinkTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
Config::set('share-links.ttl_hours', 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_guest_can_create_share_link(): void
|
||||||
|
{
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
$event = Event::factory()->for($tenant)->create(['status' => 'published']);
|
||||||
|
$photo = Photo::factory()->for($event)->create([
|
||||||
|
'status' => 'approved',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$token = app(EventJoinTokenService::class)->createToken($event)->plain_token;
|
||||||
|
|
||||||
|
$response = $this->withHeaders([
|
||||||
|
'X-Device-Id' => 'device-share',
|
||||||
|
'Accept' => 'application/json',
|
||||||
|
])->postJson("/api/v1/events/{$token}/photos/{$photo->id}/share");
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
$response->assertJsonStructure(['slug', 'url', 'expires_at']);
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('photo_share_links', [
|
||||||
|
'photo_id' => $photo->id,
|
||||||
|
'slug' => $response->json('slug'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_share_link_cannot_be_created_for_unrelated_photo(): void
|
||||||
|
{
|
||||||
|
$event = Event::factory()->create(['status' => 'published']);
|
||||||
|
$otherEvent = Event::factory()->create(['status' => 'published']);
|
||||||
|
$photo = Photo::factory()->for($otherEvent)->create(['status' => 'approved']);
|
||||||
|
|
||||||
|
$token = app(EventJoinTokenService::class)->createToken($event)->plain_token;
|
||||||
|
|
||||||
|
$response = $this->withHeaders([
|
||||||
|
'X-Device-Id' => 'device-share',
|
||||||
|
'Accept' => 'application/json',
|
||||||
|
])->postJson("/api/v1/events/{$token}/photos/{$photo->id}/share");
|
||||||
|
|
||||||
|
$response->assertNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_share_payload_exposes_public_photo_data(): void
|
||||||
|
{
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
$event = Event::factory()->for($tenant)->create(['status' => 'published']);
|
||||||
|
$task = Task::factory()->for($tenant)->create();
|
||||||
|
$photo = Photo::factory()->for($event)->create([
|
||||||
|
'status' => 'approved',
|
||||||
|
'task_id' => $task->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$share = PhotoShareLink::factory()->for($photo)->create([
|
||||||
|
'expires_at' => now()->addDay(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->getJson("/api/v1/photo-shares/{$share->slug}");
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
$response->assertJsonPath('photo.id', $photo->id);
|
||||||
|
$response->assertJsonPath('photo.image_urls.full', fn ($value) => str_contains($value, '/photo-shares/'));
|
||||||
|
$response->assertJsonPath('event.id', $event->id);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user