fixed like action, better dark mode, bottom navigation working, added taskcollection
This commit is contained in:
@@ -1,11 +1,28 @@
|
||||
import React from 'react';
|
||||
import { NavLink, useParams } from 'react-router-dom';
|
||||
import { NavLink, useParams, useLocation } from 'react-router-dom';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { GalleryHorizontal, Home, Trophy } from 'lucide-react';
|
||||
import { CheckSquare, GalleryHorizontal, Home, Trophy } from 'lucide-react';
|
||||
import { useEventData } from '../hooks/useEventData';
|
||||
|
||||
function TabLink({ to, children }: { to: string; children: React.ReactNode }) {
|
||||
function TabLink({
|
||||
to,
|
||||
children,
|
||||
isActive
|
||||
}: {
|
||||
to: string;
|
||||
children: React.ReactNode;
|
||||
isActive: boolean;
|
||||
}) {
|
||||
return (
|
||||
<NavLink to={to} className={({ isActive }) => (isActive ? 'text-foreground' : 'text-muted-foreground')}>
|
||||
<NavLink
|
||||
to={to}
|
||||
className={`
|
||||
flex flex-col items-center gap-1 h-14 p-2 transition-all duration-200 rounded-lg backdrop-blur-md
|
||||
${isActive
|
||||
? 'bg-gradient-to-t from-pink-500/90 to-pink-400/90 text-white shadow-lg scale-105 border border-white/30'
|
||||
: 'text-gray-300 hover:bg-white/10 hover:text-pink-300 hover:scale-105 hover:border-white/20 border border-transparent'
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</NavLink>
|
||||
);
|
||||
@@ -13,25 +30,60 @@ function TabLink({ to, children }: { to: string; children: React.ReactNode }) {
|
||||
|
||||
export default function BottomNav() {
|
||||
const { slug } = useParams();
|
||||
const location = useLocation();
|
||||
const { event } = useEventData();
|
||||
|
||||
if (!slug) return null; // Only show bottom nav within event context
|
||||
const base = `/e/${encodeURIComponent(slug)}`;
|
||||
const currentPath = location.pathname;
|
||||
const locale = event?.default_locale || 'de';
|
||||
|
||||
// Translations
|
||||
const translations = {
|
||||
de: {
|
||||
home: 'Start',
|
||||
tasks: 'Aufgaben',
|
||||
achievements: 'Erfolge',
|
||||
gallery: 'Galerie'
|
||||
},
|
||||
en: {
|
||||
home: 'Home',
|
||||
tasks: 'Tasks',
|
||||
achievements: 'Achievements',
|
||||
gallery: 'Gallery'
|
||||
}
|
||||
};
|
||||
|
||||
const t = translations[locale as keyof typeof translations] || translations.de;
|
||||
|
||||
// Improved active state logic
|
||||
const isHomeActive = currentPath === base || currentPath === `/${slug}`;
|
||||
const isTasksActive = currentPath.startsWith(`${base}/tasks`) || currentPath === `${base}/upload`;
|
||||
const isAchievementsActive = currentPath.startsWith(`${base}/achievements`);
|
||||
const isGalleryActive = currentPath.startsWith(`${base}/gallery`) || currentPath.startsWith(`${base}/photos`);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-x-0 bottom-0 z-20 border-t bg-white/90 px-3 py-2 backdrop-blur dark:bg-black/40">
|
||||
<div className="mx-auto flex max-w-md items-center justify-between">
|
||||
<TabLink to={`${base}`}>
|
||||
<Button variant="ghost" size="sm" className="flex flex-col gap-1">
|
||||
<Home className="h-5 w-5" /> <span className="text-xs">Start</span>
|
||||
</Button>
|
||||
<div className="fixed inset-x-0 bottom-0 z-30 border-t border-white/20 bg-black/30 px-2 py-2 backdrop-blur-xl shadow-xl dark:bg-black/40 dark:border-gray-800/50">
|
||||
<div className="mx-auto flex max-w-sm items-center justify-around">
|
||||
<TabLink to={`${base}`} isActive={isHomeActive}>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<Home className="h-5 w-5" /> <span className="text-xs">{t.home}</span>
|
||||
</div>
|
||||
</TabLink>
|
||||
<TabLink to={`${base}/gallery`}>
|
||||
<Button variant="ghost" size="sm" className="flex flex-col gap-1">
|
||||
<GalleryHorizontal className="h-5 w-5" /> <span className="text-xs">Galerie</span>
|
||||
</Button>
|
||||
<TabLink to={`${base}/tasks`} isActive={isTasksActive}>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<CheckSquare className="h-5 w-5" /> <span className="text-xs">{t.tasks}</span>
|
||||
</div>
|
||||
</TabLink>
|
||||
<TabLink to={`${base}/achievements`}>
|
||||
<Button variant="ghost" size="sm" className="flex flex-col gap-1">
|
||||
<Trophy className="h-5 w-5" /> <span className="text-xs">Erfolge</span>
|
||||
</Button>
|
||||
<TabLink to={`${base}/achievements`} isActive={isAchievementsActive}>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<Trophy className="h-5 w-5" /> <span className="text-xs">{t.achievements}</span>
|
||||
</div>
|
||||
</TabLink>
|
||||
<TabLink to={`${base}/gallery`} isActive={isGalleryActive}>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<GalleryHorizontal className="h-5 w-5" /> <span className="text-xs">{t.gallery}</span>
|
||||
</div>
|
||||
</TabLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
149
resources/js/guest/components/EmotionPicker.tsx
Normal file
149
resources/js/guest/components/EmotionPicker.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
|
||||
interface Emotion {
|
||||
id: number;
|
||||
slug: string;
|
||||
name: string;
|
||||
emoji: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface EmotionPickerProps {
|
||||
onSelect?: (emotion: Emotion) => void;
|
||||
}
|
||||
|
||||
export default function EmotionPicker({ onSelect }: EmotionPickerProps) {
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [emotions, setEmotions] = useState<Emotion[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Fallback emotions (when API not available yet)
|
||||
const fallbackEmotions: Emotion[] = [
|
||||
{ id: 1, slug: 'happy', name: 'Glücklich', emoji: '😊' },
|
||||
{ id: 2, slug: 'love', name: 'Verliebt', emoji: '❤️' },
|
||||
{ id: 3, slug: 'excited', name: 'Aufgeregt', emoji: '🎉' },
|
||||
{ id: 4, slug: 'relaxed', name: 'Entspannt', emoji: '😌' },
|
||||
{ id: 5, slug: 'sad', name: 'Traurig', emoji: '😢' },
|
||||
{ id: 6, slug: 'surprised', name: 'Überrascht', emoji: '😲' },
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
if (!slug) return;
|
||||
|
||||
async function fetchEmotions() {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Try API first
|
||||
const response = await fetch(`/api/v1/events/${slug}/emotions`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setEmotions(Array.isArray(data) ? data : fallbackEmotions);
|
||||
} else {
|
||||
// Fallback to predefined emotions
|
||||
console.warn('Emotions API not available, using fallback');
|
||||
setEmotions(fallbackEmotions);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch emotions:', err);
|
||||
setError('Emotions konnten nicht geladen werden');
|
||||
setEmotions(fallbackEmotions);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchEmotions();
|
||||
}, [slug]);
|
||||
|
||||
const handleEmotionSelect = (emotion: Emotion) => {
|
||||
if (onSelect) {
|
||||
onSelect(emotion);
|
||||
} else {
|
||||
// Default: Navigate to tasks with emotion filter
|
||||
navigate(`/e/${slug}/tasks?emotion=${emotion.slug}`);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="mb-6 p-4 text-center">
|
||||
<div className="text-sm text-muted-foreground">Lade Emotionen...</div>
|
||||
</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">
|
||||
{emotions.map((emotion) => {
|
||||
// Localize name and description if they are JSON
|
||||
const localize = (value: string | object, defaultValue: string = ''): string => {
|
||||
if (typeof value === 'string' && value.startsWith('{')) {
|
||||
try {
|
||||
const data = JSON.parse(value as string);
|
||||
return data.de || data.en || defaultValue || '';
|
||||
} catch {
|
||||
return value as string;
|
||||
}
|
||||
}
|
||||
return value as string;
|
||||
};
|
||||
|
||||
const localizedName = localize(emotion.name, emotion.name);
|
||||
const localizedDescription = localize(emotion.description || '', '');
|
||||
|
||||
return (
|
||||
<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"
|
||||
onClick={() => handleEmotionSelect(emotion)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xl">{emotion.emoji}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-sm truncate">{localizedName}</div>
|
||||
{localizedDescription && (
|
||||
<div className="text-xs text-muted-foreground truncate">{localizedDescription}</div>
|
||||
)}
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground ml-auto" />
|
||||
</div>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Skip option */}
|
||||
<div className="mt-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full text-sm text-gray-600 dark:text-gray-300 hover:text-pink-600 hover:bg-pink-50 dark:hover:bg-gray-800 border-t border-gray-200 dark:border-gray-700 pt-3 mt-3"
|
||||
onClick={() => navigate(`/e/${slug}/tasks`)}
|
||||
>
|
||||
Überspringen und Aufgabe wählen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,39 +2,89 @@ import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { getDeviceId } from '../lib/device';
|
||||
import { usePollGalleryDelta } from '../polling/usePollGalleryDelta';
|
||||
|
||||
type Props = { slug: string };
|
||||
|
||||
export default function GalleryPreview({ slug }: Props) {
|
||||
const { photos, loading } = usePollGalleryDelta(slug);
|
||||
const [mode, setMode] = React.useState<'latest' | 'popular'>('latest');
|
||||
const [mode, setMode] = React.useState<'latest' | 'popular' | 'myphotos'>('latest');
|
||||
|
||||
const items = React.useMemo(() => {
|
||||
const arr = photos.slice();
|
||||
let arr = photos.slice();
|
||||
|
||||
// MyPhotos filter (requires session_id matching)
|
||||
if (mode === 'myphotos') {
|
||||
const deviceId = getDeviceId();
|
||||
arr = arr.filter((photo: any) => photo.session_id === deviceId);
|
||||
}
|
||||
|
||||
// Sorting
|
||||
if (mode === 'popular') {
|
||||
arr.sort((a: any, b: any) => (b.likes_count ?? 0) - (a.likes_count ?? 0));
|
||||
} else {
|
||||
arr.sort((a: any, b: any) => new Date(b.created_at ?? 0).getTime() - new Date(a.created_at ?? 0).getTime());
|
||||
}
|
||||
return arr.slice(0, 6);
|
||||
|
||||
return arr.slice(0, 4); // 2x2 = 4 items
|
||||
}, [photos, mode]);
|
||||
|
||||
// Helper function to generate photo title (must be before return)
|
||||
function getPhotoTitle(photo: any): string {
|
||||
if (photo.task_id) {
|
||||
return `Task: ${photo.task_title || 'Unbekannte Aufgabe'}`;
|
||||
}
|
||||
if (photo.emotion_id) {
|
||||
return `Emotion: ${photo.emotion_name || 'Gefühl'}`;
|
||||
}
|
||||
// Fallback based on creation time or placeholder
|
||||
const now = new Date();
|
||||
const created = new Date(photo.created_at || now);
|
||||
const hours = created.getHours();
|
||||
if (hours < 12) return 'Morgenmoment';
|
||||
if (hours < 18) return 'Nachmittagslicht';
|
||||
return 'Abendstimmung';
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<div className="inline-flex rounded-md border p-1 text-xs">
|
||||
<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-2 py-1 ${mode === 'latest' ? 'rounded-sm bg-muted font-medium' : ''}`}
|
||||
>Neueste</button>
|
||||
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-2 py-1 ${mode === 'popular' ? 'rounded-sm bg-muted font-medium' : ''}`}
|
||||
>Beliebt</button>
|
||||
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('myphotos')}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-full transition-all duration-200 ${
|
||||
mode === 'myphotos'
|
||||
? '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>
|
||||
</div>
|
||||
<div className="grow" />
|
||||
<Link to={`../gallery`}><Button variant="link" className="px-0">Alle ansehen →</Button></Link>
|
||||
<Link to={`/e/${slug}/gallery?mode=${mode}`} className="text-sm text-pink-600 hover:text-pink-700 font-medium">
|
||||
Alle ansehen →
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{loading && <p className="text-sm text-muted-foreground">Lädt…</p>}
|
||||
@@ -45,15 +95,28 @@ export default function GalleryPreview({ slug }: Props) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{items.map((p: any) => (
|
||||
<Link key={p.id} to={`../photo/${p.id}`} state={{ photo: p }}>
|
||||
<img
|
||||
src={p.thumbnail_path || p.file_path}
|
||||
alt="Foto"
|
||||
className="aspect-square w-full rounded object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
<Link key={p.id} to={`/e/${slug}/photo/${p.id}`} state={{ photo: p }} 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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -3,13 +3,99 @@ import { Button } from '@/components/ui/button';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet';
|
||||
import AppearanceToggleDropdown from '@/components/appearance-dropdown';
|
||||
import { Settings, ChevronDown } from 'lucide-react';
|
||||
import { Settings, ChevronDown, User } from 'lucide-react';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
import { useEventData } from '../hooks/useEventData';
|
||||
import { usePollStats } from '../polling/usePollStats';
|
||||
|
||||
export default function Header({ slug, title = '' }: { slug?: string; title?: string }) {
|
||||
if (!slug) {
|
||||
return (
|
||||
<div className="sticky top-0 z-20 flex items-center justify-between border-b bg-white/70 px-4 py-2 backdrop-blur dark:bg-black/40">
|
||||
<div className="font-semibold">{title}</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<AppearanceToggleDropdown />
|
||||
<SettingsSheet />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { event, loading: eventLoading, error: eventError } = useEventData();
|
||||
const stats = usePollStats(slug);
|
||||
|
||||
if (eventLoading) {
|
||||
return (
|
||||
<div className="sticky top-0 z-20 flex items-center justify-between border-b bg-white/70 px-4 py-2 backdrop-blur dark:bg-black/40">
|
||||
<div className="font-semibold">Lade Event...</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<AppearanceToggleDropdown />
|
||||
<SettingsSheet />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (eventError || !event) {
|
||||
return (
|
||||
<div className="sticky top-0 z-20 flex items-center justify-between border-b bg-white/70 px-4 py-2 backdrop-blur dark:bg-black/40">
|
||||
<div className="font-semibold text-red-600">Event nicht gefunden</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<AppearanceToggleDropdown />
|
||||
<SettingsSheet />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Get event icon or generate initials
|
||||
const getEventAvatar = (event: any) => {
|
||||
if (event.type?.icon) {
|
||||
return (
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-pink-100 text-pink-600 text-xl">
|
||||
{event.type.icon}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback to initials
|
||||
const getInitials = (name: string) => {
|
||||
const words = name.split(' ');
|
||||
if (words.length >= 2) {
|
||||
return `${words[0][0]}${words[1][0]}`.toUpperCase();
|
||||
}
|
||||
return name.substring(0, 2).toUpperCase();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-pink-100 text-pink-600 font-semibold text-sm">
|
||||
{getInitials(event.name)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default function Header({ title = '' }: { title?: string }) {
|
||||
return (
|
||||
<div className="sticky top-0 z-20 flex items-center justify-between border-b bg-white/70 px-4 py-2 backdrop-blur dark:bg-black/40">
|
||||
<div className="font-semibold">{title}</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{getEventAvatar(event)}
|
||||
<div className="flex flex-col">
|
||||
<div className="font-semibold text-base">{event.name}</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
{stats && (
|
||||
<>
|
||||
<span className="flex items-center gap-1">
|
||||
<User className="h-3 w-3" />
|
||||
<span>{stats.onlineGuests} online</span>
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="font-medium">{stats.tasksSolved}</span> Aufgaben gelöst
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<AppearanceToggleDropdown />
|
||||
<SettingsSheet />
|
||||
|
||||
Reference in New Issue
Block a user