reworked the guest pwa, modernized start and gallery page. added share link functionality.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { NavLink, useParams, useLocation } from 'react-router-dom';
|
||||
import { CheckSquare, GalleryHorizontal, Home, Trophy } from 'lucide-react';
|
||||
import { NavLink, useParams, useLocation, Link } from 'react-router-dom';
|
||||
import { CheckSquare, GalleryHorizontal, Home, Trophy, Camera } from 'lucide-react';
|
||||
import { useEventData } from '../hooks/useEventData';
|
||||
import { useTranslation } from '../i18n/useTranslation';
|
||||
import { useEventBranding } from '../context/EventBrandingContext';
|
||||
@@ -58,40 +58,61 @@ export default function BottomNav() {
|
||||
tasks: t('navigation.tasks'),
|
||||
achievements: t('navigation.achievements'),
|
||||
gallery: t('navigation.gallery'),
|
||||
upload: t('home.actions.items.upload.label'),
|
||||
};
|
||||
|
||||
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 isGalleryActive = currentPath.startsWith(`${base}/gallery`) || currentPath.startsWith(`${base}/photos`);
|
||||
const isUploadActive = currentPath.startsWith(`${base}/upload`);
|
||||
|
||||
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="mx-auto flex max-w-sm items-center justify-around gap-2">
|
||||
<TabLink to={`${base}`} isActive={isHomeActive} accentColor={branding.primaryColor}>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<Home className="h-5 w-5" aria-hidden />
|
||||
<span>{labels.home}</span>
|
||||
</div>
|
||||
</TabLink>
|
||||
<TabLink to={`${base}/tasks`} isActive={isTasksActive} accentColor={branding.primaryColor}>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<CheckSquare className="h-5 w-5" aria-hidden />
|
||||
<span>{labels.tasks}</span>
|
||||
</div>
|
||||
</TabLink>
|
||||
<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 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-lg items-center gap-3">
|
||||
<div className="flex flex-1 justify-evenly gap-2">
|
||||
<TabLink to={`${base}`} isActive={isHomeActive} accentColor={branding.primaryColor}>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<Home className="h-5 w-5" aria-hidden />
|
||||
<span>{labels.home}</span>
|
||||
</div>
|
||||
</TabLink>
|
||||
<TabLink to={`${base}/tasks`} isActive={isTasksActive} accentColor={branding.primaryColor}>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<CheckSquare className="h-5 w-5" aria-hidden />
|
||||
<span>{labels.tasks}</span>
|
||||
</div>
|
||||
</TabLink>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
to={`${base}/upload`}
|
||||
aria-label={labels.upload}
|
||||
className={`relative flex h-16 w-16 items-center justify-center rounded-full text-white shadow-2xl transition ${
|
||||
isUploadActive ? 'scale-105' : 'hover:scale-105'
|
||||
}`}
|
||||
style={{
|
||||
background: `radial-gradient(circle at 20% 20%, ${branding.secondaryColor}, ${branding.primaryColor})`,
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -73,30 +73,17 @@ export default function EmotionPicker({ onSelect }: EmotionPickerProps) {
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="mb-6 p-4 text-center">
|
||||
<div className="text-sm text-muted-foreground">Lade Emotionen...</div>
|
||||
const content = (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
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">
|
||||
<div className="flex gap-3 overflow-x-auto pb-2 [-ms-overflow-style:none] [scrollbar-width:none]" aria-label="Emotions">
|
||||
{emotions.map((emotion) => {
|
||||
// Localize name and description if they are JSON
|
||||
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 localizedDescription = localize(emotion.description || '', '');
|
||||
|
||||
return (
|
||||
<Button
|
||||
<button
|
||||
key={emotion.id}
|
||||
variant="outline"
|
||||
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"
|
||||
type="button"
|
||||
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">
|
||||
<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="font-medium text-sm truncate">{localizedName}</div>
|
||||
<div className="font-medium text-sm text-foreground line-clamp-1">{localizedName}</div>
|
||||
{localizedDescription && (
|
||||
<div className="text-xs text-muted-foreground truncate">{localizedDescription}</div>
|
||||
<div className="text-xs text-muted-foreground line-clamp-1">{localizedDescription}</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>
|
||||
</Button>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@@ -151,4 +139,18 @@ export default function EmotionPicker({ onSelect }: EmotionPickerProps) {
|
||||
</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 { 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 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 (
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<ToggleGroup type="single" value={value} onValueChange={(v) => v && onChange(v as GalleryFilter)}>
|
||||
<ToggleGroupItem value="latest">Neueste</ToggleGroupItem>
|
||||
<ToggleGroupItem value="popular">Beliebt</ToggleGroupItem>
|
||||
<ToggleGroupItem value="mine">Meine</ToggleGroupItem>
|
||||
<ToggleGroupItem value="photobooth">Fotobox</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
<div
|
||||
className={cn(
|
||||
'flex gap-2 overflow-x-auto px-4 pb-2 text-sm font-medium [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Link } from 'react-router-dom';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { getDeviceId } from '../lib/device';
|
||||
import { usePollGalleryDelta } from '../polling/usePollGalleryDelta';
|
||||
import { Heart } from 'lucide-react';
|
||||
|
||||
type Props = { token: string };
|
||||
|
||||
@@ -50,56 +51,45 @@ export default function GalleryPreview({ token }: Props) {
|
||||
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 (
|
||||
<div>
|
||||
<div className="mb-4 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">
|
||||
<button
|
||||
onClick={() => setMode('latest')}
|
||||
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>
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">Live-Galerie</p>
|
||||
<h3 className="text-lg font-semibold text-foreground">Alle Uploads auf einen Blick</h3>
|
||||
</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 →
|
||||
</Link>
|
||||
</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 && items.length === 0 && (
|
||||
<Card>
|
||||
@@ -108,31 +98,31 @@ export default function GalleryPreview({ token }: Props) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{items.map((p: any) => (
|
||||
<Link key={p.id} to={`/e/${encodeURIComponent(token)}/gallery?photoId=${p.id}`} className="block">
|
||||
<div className="relative">
|
||||
<img
|
||||
src={p.thumbnail_path || p.file_path}
|
||||
alt={p.title || 'Foto'}
|
||||
className="aspect-square w-full rounded-xl object-cover shadow-lg hover:shadow-xl transition-shadow duration-200"
|
||||
loading="lazy"
|
||||
/>
|
||||
{/* Photo Title */}
|
||||
<div className="mt-2">
|
||||
<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>
|
||||
{p.likes_count > 0 && (
|
||||
<div className="mt-1 flex items-center gap-1 text-xs text-pink-600">
|
||||
❤️ {p.likes_count}
|
||||
</div>
|
||||
)}
|
||||
<Link
|
||||
key={p.id}
|
||||
to={`/e/${encodeURIComponent(token)}/gallery?photoId=${p.id}`}
|
||||
className="group relative block overflow-hidden rounded-3xl border border-white/30 bg-gray-900 text-white shadow-lg"
|
||||
>
|
||||
<img
|
||||
src={p.thumbnail_path || p.file_path}
|
||||
alt={p.title || 'Foto'}
|
||||
className="h-48 w-full object-cover transition duration-300 group-hover:scale-105"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/10 to-transparent" aria-hidden />
|
||||
<div className="absolute bottom-0 left-0 right-0 p-4">
|
||||
<p className="text-sm font-semibold leading-tight line-clamp-2">{p.title || getPhotoTitle(p)}</p>
|
||||
<div className="mt-2 flex items-center gap-1 text-xs text-white/80">
|
||||
<Heart className="h-4 w-4 fill-current" aria-hidden />
|
||||
{p.likes_count ?? 0}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user