416 lines
14 KiB
TypeScript
416 lines
14 KiB
TypeScript
import React from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { XStack, YStack } from '@tamagui/stacks';
|
|
import { SizableText as Text } from '@tamagui/text';
|
|
import { Button } from '@tamagui/button';
|
|
import { Camera, Sparkles, Image as ImageIcon, Trophy, Star } from 'lucide-react';
|
|
import AppShell from '../components/AppShell';
|
|
import PhotoFrameTile from '../components/PhotoFrameTile';
|
|
import { useEventData } from '../context/EventDataContext';
|
|
import { buildEventPath } from '../lib/routes';
|
|
import { useStaggeredReveal } from '../lib/useStaggeredReveal';
|
|
import { usePollStats } from '../hooks/usePollStats';
|
|
import { fetchGallery } from '../services/photosApi';
|
|
import { useUploadQueue } from '../services/uploadApi';
|
|
import { useTranslation } from '@/guest/i18n/useTranslation';
|
|
import { useAppearance } from '@/hooks/use-appearance';
|
|
|
|
type ActionRingProps = {
|
|
label: string;
|
|
icon: React.ReactNode;
|
|
onPress: () => void;
|
|
};
|
|
|
|
type GalleryPreview = {
|
|
id: number;
|
|
imageUrl: string;
|
|
};
|
|
|
|
function ActionRing({
|
|
label,
|
|
icon,
|
|
onPress,
|
|
isDark,
|
|
}: ActionRingProps & { isDark: boolean }) {
|
|
return (
|
|
<Button unstyled onPress={onPress}>
|
|
<YStack alignItems="center" gap="$2">
|
|
<YStack
|
|
width={74}
|
|
height={74}
|
|
borderRadius={37}
|
|
backgroundColor="$surface"
|
|
borderWidth={2}
|
|
borderColor="$primary"
|
|
alignItems="center"
|
|
justifyContent="center"
|
|
style={{
|
|
backgroundImage: isDark
|
|
? 'radial-gradient(circle at 30% 30%, rgba(255, 255, 255, 0.25), rgba(255, 255, 255, 0.05))'
|
|
: 'radial-gradient(circle at 30% 30%, color-mix(in oklab, var(--guest-primary, #FF5A5F) 20%, white), rgba(255, 255, 255, 0.7))',
|
|
boxShadow: isDark
|
|
? '0 10px 24px rgba(255, 79, 216, 0.2)'
|
|
: '0 10px 24px rgba(15, 23, 42, 0.12)',
|
|
}}
|
|
>
|
|
{icon}
|
|
</YStack>
|
|
<Text fontSize="$2" fontWeight="$6" color="$color" opacity={0.9}>
|
|
{label}
|
|
</Text>
|
|
</YStack>
|
|
</Button>
|
|
);
|
|
}
|
|
|
|
function QuickStats({
|
|
reveal,
|
|
stats,
|
|
queueCount,
|
|
isDark,
|
|
}: {
|
|
reveal: number;
|
|
stats: { onlineGuests: number; tasksSolved: number };
|
|
queueCount: number;
|
|
isDark: boolean;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const cardBorder = isDark ? 'rgba(255, 255, 255, 0.12)' : 'rgba(15, 23, 42, 0.12)';
|
|
const cardShadow = isDark ? '0 16px 30px rgba(2, 6, 23, 0.35)' : '0 14px 24px rgba(15, 23, 42, 0.12)';
|
|
return (
|
|
<XStack
|
|
gap="$3"
|
|
animation="slow"
|
|
animateOnly={['transform', 'opacity']}
|
|
opacity={reveal >= 3 ? 1 : 0}
|
|
y={reveal >= 3 ? 0 : 12}
|
|
>
|
|
<YStack
|
|
flex={1}
|
|
padding="$3"
|
|
borderRadius="$card"
|
|
backgroundColor="$surface"
|
|
borderWidth={1}
|
|
borderColor={cardBorder}
|
|
gap="$1"
|
|
style={{
|
|
boxShadow: cardShadow,
|
|
}}
|
|
>
|
|
<Text fontSize="$4" fontWeight="$8">
|
|
{stats.onlineGuests}
|
|
</Text>
|
|
<Text fontSize="$2" color="$color" opacity={0.7}>
|
|
{t('home.stats.online', 'Guests online')}
|
|
</Text>
|
|
</YStack>
|
|
<YStack
|
|
flex={1}
|
|
padding="$3"
|
|
borderRadius="$card"
|
|
backgroundColor="$surface"
|
|
borderWidth={1}
|
|
borderColor={cardBorder}
|
|
gap="$1"
|
|
style={{
|
|
boxShadow: cardShadow,
|
|
}}
|
|
>
|
|
<Text fontSize="$4" fontWeight="$8">
|
|
{queueCount}
|
|
</Text>
|
|
<Text fontSize="$2" color="$color" opacity={0.7}>
|
|
{t('homeV2.stats.uploadsQueued', 'Uploads queued')}
|
|
</Text>
|
|
</YStack>
|
|
</XStack>
|
|
);
|
|
}
|
|
|
|
function 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 HomeScreen() {
|
|
const { tasksEnabled, token } = useEventData();
|
|
const navigate = useNavigate();
|
|
const revealStage = useStaggeredReveal({ steps: 4, intervalMs: 140, delayMs: 120 });
|
|
const { stats } = usePollStats(token ?? null);
|
|
const { items } = useUploadQueue();
|
|
const [preview, setPreview] = React.useState<GalleryPreview[]>([]);
|
|
const [previewLoading, setPreviewLoading] = React.useState(false);
|
|
const { t } = useTranslation();
|
|
const { resolved } = useAppearance();
|
|
const isDark = resolved === 'dark';
|
|
const cardBorder = isDark ? 'rgba(255, 255, 255, 0.12)' : 'rgba(15, 23, 42, 0.12)';
|
|
const mutedButton = isDark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(15, 23, 42, 0.06)';
|
|
const mutedButtonBorder = isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(15, 23, 42, 0.12)';
|
|
const cardShadow = isDark ? '0 18px 40px rgba(2, 6, 23, 0.4)' : '0 16px 32px rgba(15, 23, 42, 0.12)';
|
|
|
|
const goTo = (path: string) => () => navigate(buildEventPath(token, path));
|
|
|
|
const rings = [
|
|
tasksEnabled
|
|
? {
|
|
label: t('home.actions.items.tasks.label', 'Draw a task card'),
|
|
icon: <Sparkles size={20} color={isDark ? '#F8FAFF' : '#0F172A'} />,
|
|
path: '/tasks',
|
|
}
|
|
: {
|
|
label: t('home.actions.items.upload.label', 'Upload photo'),
|
|
icon: <Camera size={20} color={isDark ? '#F8FAFF' : '#0F172A'} />,
|
|
path: '/upload',
|
|
},
|
|
{
|
|
label: t('homeV2.rings.newUploads', 'New uploads'),
|
|
icon: <ImageIcon size={20} color={isDark ? '#F8FAFF' : '#0F172A'} />,
|
|
path: '/gallery',
|
|
},
|
|
{
|
|
label: t('homeV2.rings.topMoments', 'Top moments'),
|
|
icon: <Star size={20} color={isDark ? '#F8FAFF' : '#0F172A'} />,
|
|
path: '/gallery',
|
|
},
|
|
{
|
|
label: t('navigation.achievements', 'Achievements'),
|
|
icon: <Trophy size={20} color={isDark ? '#F8FAFF' : '#0F172A'} />,
|
|
path: '/achievements',
|
|
},
|
|
];
|
|
|
|
React.useEffect(() => {
|
|
if (!token) {
|
|
setPreview([]);
|
|
return;
|
|
}
|
|
|
|
let active = true;
|
|
setPreviewLoading(true);
|
|
|
|
fetchGallery(token, { limit: 3 })
|
|
.then((response) => {
|
|
if (!active) return;
|
|
const photos = Array.isArray(response.data) ? response.data : [];
|
|
const mapped = photos
|
|
.map((photo) => {
|
|
const record = photo as Record<string, unknown>;
|
|
const id = Number(record.id ?? 0);
|
|
const imageUrl = normalizeImageUrl(
|
|
(record.thumbnail_url as string | null | undefined)
|
|
?? (record.thumbnail_path as string | null | undefined)
|
|
?? (record.file_path as string | null | undefined)
|
|
?? (record.full_url as string | null | undefined)
|
|
?? (record.url as string | null | undefined)
|
|
?? (record.image_url as string | null | undefined)
|
|
);
|
|
return { id, imageUrl };
|
|
})
|
|
.filter((item) => item.id && item.imageUrl);
|
|
setPreview(mapped);
|
|
})
|
|
.catch((error) => {
|
|
console.error('Failed to load gallery preview', error);
|
|
if (active) {
|
|
setPreview([]);
|
|
}
|
|
})
|
|
.finally(() => {
|
|
if (active) {
|
|
setPreviewLoading(false);
|
|
}
|
|
});
|
|
|
|
return () => {
|
|
active = false;
|
|
};
|
|
}, [token]);
|
|
|
|
const queueCount = items.filter((item) => item.status !== 'done').length;
|
|
|
|
return (
|
|
<AppShell>
|
|
<YStack gap="$4">
|
|
<YStack
|
|
gap="$3"
|
|
animation="slow"
|
|
animateOnly={['transform', 'opacity']}
|
|
opacity={revealStage >= 1 ? 1 : 0}
|
|
y={revealStage >= 1 ? 0 : 12}
|
|
>
|
|
<XStack gap="$2" justifyContent="space-between">
|
|
{rings.map((ring) => (
|
|
<YStack key={ring.label} flex={1} alignItems="center">
|
|
<ActionRing label={ring.label} icon={ring.icon} onPress={goTo(ring.path)} isDark={isDark} />
|
|
</YStack>
|
|
))}
|
|
</XStack>
|
|
</YStack>
|
|
|
|
{tasksEnabled ? (
|
|
<YStack
|
|
padding="$4"
|
|
borderRadius="$card"
|
|
backgroundColor="$surface"
|
|
borderWidth={1}
|
|
borderColor={cardBorder}
|
|
gap="$3"
|
|
y={revealStage >= 2 ? 0 : 16}
|
|
style={{
|
|
backgroundImage: isDark
|
|
? 'linear-gradient(135deg, rgba(255, 79, 216, 0.25), rgba(79, 209, 255, 0.12))'
|
|
: 'linear-gradient(135deg, color-mix(in oklab, var(--guest-primary, #FF5A5F) 18%, white), color-mix(in oklab, var(--guest-secondary, #F43F5E) 10%, white))',
|
|
boxShadow: cardShadow,
|
|
}}
|
|
>
|
|
<XStack alignItems="center" gap="$2">
|
|
<Sparkles size={18} color="#FF4FD8" />
|
|
<Text fontSize="$3" fontWeight="$7">
|
|
{t('homeV2.promptQuest.label', 'Prompt quest')}
|
|
</Text>
|
|
</XStack>
|
|
<Text fontSize="$7" fontFamily="$display" fontWeight="$8">
|
|
{t('homeV2.promptQuest.title', 'Capture the happiest laugh')}
|
|
</Text>
|
|
<Text fontSize="$3" color="$color" opacity={0.75}>
|
|
{t('homeV2.promptQuest.subtitle', 'Earn points and keep the gallery lively.')}
|
|
</Text>
|
|
<XStack gap="$2" flexWrap="wrap">
|
|
<Button size="$4" backgroundColor="$primary" borderRadius="$pill" onPress={goTo('/upload')}>
|
|
{t('homeV2.promptQuest.ctaStart', 'Start prompt')}
|
|
</Button>
|
|
<Button
|
|
size="$4"
|
|
backgroundColor={mutedButton}
|
|
borderRadius="$pill"
|
|
borderWidth={1}
|
|
borderColor={mutedButtonBorder}
|
|
onPress={goTo('/tasks')}
|
|
>
|
|
{t('homeV2.promptQuest.ctaBrowse', 'Browse tasks')}
|
|
</Button>
|
|
</XStack>
|
|
</YStack>
|
|
) : (
|
|
<YStack
|
|
padding="$4"
|
|
borderRadius="$card"
|
|
backgroundColor="$surface"
|
|
borderWidth={1}
|
|
borderColor={cardBorder}
|
|
gap="$3"
|
|
y={revealStage >= 2 ? 0 : 16}
|
|
style={{
|
|
backgroundImage: isDark
|
|
? 'linear-gradient(135deg, rgba(79, 209, 255, 0.18), rgba(255, 79, 216, 0.12))'
|
|
: 'linear-gradient(135deg, color-mix(in oklab, var(--guest-secondary, #F43F5E) 10%, white), color-mix(in oklab, var(--guest-primary, #FF5A5F) 10%, white))',
|
|
boxShadow: cardShadow,
|
|
}}
|
|
>
|
|
<XStack alignItems="center" gap="$2">
|
|
<Camera size={18} color={isDark ? '#F8FAFF' : '#0F172A'} />
|
|
<Text fontSize="$3" fontWeight="$7">
|
|
{t('homeV2.captureReady.label', 'Capture ready')}
|
|
</Text>
|
|
</XStack>
|
|
<Text fontSize="$7" fontFamily="$display" fontWeight="$8">
|
|
{t('homeV2.captureReady.title', 'Add a photo to the shared gallery')}
|
|
</Text>
|
|
<Text fontSize="$3" color="$color" opacity={0.75}>
|
|
{t('homeV2.captureReady.subtitle', 'Quick add from your camera or device.')}
|
|
</Text>
|
|
<Button size="$4" backgroundColor="$primary" borderRadius="$pill" onPress={goTo('/upload')}>
|
|
{t('homeV2.captureReady.cta', 'Upload / Take photo')}
|
|
</Button>
|
|
</YStack>
|
|
)}
|
|
|
|
<QuickStats
|
|
reveal={revealStage}
|
|
stats={{ onlineGuests: stats.onlineGuests, tasksSolved: stats.tasksSolved }}
|
|
queueCount={queueCount}
|
|
isDark={isDark}
|
|
/>
|
|
|
|
<YStack
|
|
padding="$4"
|
|
borderRadius="$card"
|
|
backgroundColor="$surface"
|
|
borderWidth={1}
|
|
borderColor={cardBorder}
|
|
gap="$3"
|
|
animation="slow"
|
|
animateOnly={['transform', 'opacity']}
|
|
opacity={revealStage >= 4 ? 1 : 0}
|
|
y={revealStage >= 4 ? 0 : 16}
|
|
style={{
|
|
boxShadow: cardShadow,
|
|
}}
|
|
>
|
|
<XStack alignItems="center" justifyContent="space-between">
|
|
<Text fontSize="$4" fontWeight="$7">
|
|
{t('homeV2.galleryPreview.title', 'Gallery preview')}
|
|
</Text>
|
|
<Button
|
|
size="$3"
|
|
backgroundColor={mutedButton}
|
|
borderRadius="$pill"
|
|
borderWidth={1}
|
|
borderColor={mutedButtonBorder}
|
|
onPress={goTo('/gallery')}
|
|
>
|
|
{t('homeV2.galleryPreview.cta', 'View all')}
|
|
</Button>
|
|
</XStack>
|
|
<XStack
|
|
gap="$2"
|
|
style={{
|
|
overflowX: 'auto',
|
|
WebkitOverflowScrolling: 'touch',
|
|
paddingBottom: 6,
|
|
}}
|
|
>
|
|
{(previewLoading || preview.length === 0 ? [1, 2, 3, 4] : preview).map((tile, index) => {
|
|
if (typeof tile === 'number') {
|
|
return (
|
|
<YStack key={tile} flexShrink={0} width={140}>
|
|
<PhotoFrameTile height={110} shimmer shimmerDelayMs={tile * 140} />
|
|
</YStack>
|
|
);
|
|
}
|
|
return (
|
|
<YStack key={tile.id} flexShrink={0} width={140}>
|
|
<PhotoFrameTile height={110}>
|
|
<YStack
|
|
flex={1}
|
|
width="100%"
|
|
height="100%"
|
|
style={{
|
|
backgroundImage: `url(${tile.imageUrl})`,
|
|
backgroundSize: 'cover',
|
|
backgroundPosition: 'center',
|
|
}}
|
|
/>
|
|
</PhotoFrameTile>
|
|
</YStack>
|
|
);
|
|
})}
|
|
</XStack>
|
|
</YStack>
|
|
</YStack>
|
|
</AppShell>
|
|
);
|
|
}
|