feat: Enhance Guest Frontend with new features and UI improvements
This commit is contained in:
@@ -13,7 +13,8 @@ function TabLink({ to, children }: { to: string; children: React.ReactNode }) {
|
|||||||
|
|
||||||
export default function BottomNav() {
|
export default function BottomNav() {
|
||||||
const { slug } = useParams();
|
const { slug } = useParams();
|
||||||
const base = `/e/${encodeURIComponent(slug ?? 'demo')}`;
|
if (!slug) return null; // Only show bottom nav within event context
|
||||||
|
const base = `/e/${encodeURIComponent(slug)}`;
|
||||||
return (
|
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="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">
|
<div className="mx-auto flex max-w-md items-center justify-between">
|
||||||
|
|||||||
63
resources/js/guest/components/GalleryPreview.tsx
Normal file
63
resources/js/guest/components/GalleryPreview.tsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
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 items = React.useMemo(() => {
|
||||||
|
const arr = photos.slice();
|
||||||
|
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);
|
||||||
|
}, [photos, mode]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-2 flex items-center gap-2">
|
||||||
|
<div className="inline-flex rounded-md border p-1 text-xs">
|
||||||
|
<button
|
||||||
|
onClick={() => setMode('latest')}
|
||||||
|
className={`px-2 py-1 ${mode === 'latest' ? 'rounded-sm bg-muted font-medium' : ''}`}
|
||||||
|
>Neueste</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setMode('popular')}
|
||||||
|
className={`px-2 py-1 ${mode === 'popular' ? 'rounded-sm bg-muted font-medium' : ''}`}
|
||||||
|
>Beliebt</button>
|
||||||
|
</div>
|
||||||
|
<div className="grow" />
|
||||||
|
<Link to={`../gallery`}><Button variant="link" className="px-0">Alle ansehen →</Button></Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && <p className="text-sm text-muted-foreground">Lädt…</p>}
|
||||||
|
{!loading && items.length === 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-3 text-sm text-muted-foreground">
|
||||||
|
Noch keine Fotos. Starte mit deinem ersten Upload!
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{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>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet';
|
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet';
|
||||||
import AppearanceToggleDropdown from '@/components/appearance-dropdown';
|
import AppearanceToggleDropdown from '@/components/appearance-dropdown';
|
||||||
import { Settings } from 'lucide-react';
|
import { Settings, ChevronDown } from 'lucide-react';
|
||||||
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||||
|
|
||||||
export default function Header({ title = '' }: { title?: string }) {
|
export default function Header({ title = '' }: { title?: string }) {
|
||||||
return (
|
return (
|
||||||
@@ -30,25 +32,39 @@ function SettingsSheet() {
|
|||||||
<SheetTitle>Einstellungen</SheetTitle>
|
<SheetTitle>Einstellungen</SheetTitle>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
<div className="mt-4 space-y-4">
|
<div className="mt-4 space-y-4">
|
||||||
<div>
|
<Collapsible defaultOpen>
|
||||||
<div className="text-sm font-medium">Darstellung</div>
|
<div className="flex items-center justify-between">
|
||||||
<div className="text-sm text-muted-foreground">Hell, Dunkel oder System</div>
|
<div className="text-sm font-medium">Cache</div>
|
||||||
<div className="mt-2">
|
<CollapsibleTrigger asChild>
|
||||||
<AppearanceToggleDropdown />
|
<Button variant="ghost" size="sm" className="h-7 px-2">
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<CollapsibleContent>
|
||||||
<div>
|
<div className="mt-2">
|
||||||
<div className="text-sm font-medium">Cache</div>
|
<ClearCacheButton />
|
||||||
<ClearCacheButton />
|
</div>
|
||||||
</div>
|
</CollapsibleContent>
|
||||||
<div>
|
</Collapsible>
|
||||||
<div className="text-sm font-medium">Rechtliches</div>
|
|
||||||
<ul className="mt-2 list-disc pl-5 text-sm">
|
<Collapsible defaultOpen>
|
||||||
<li><a href="/legal/imprint" className="underline">Impressum</a></li>
|
<div className="flex items-center justify-between">
|
||||||
<li><a href="/legal/privacy" className="underline">Datenschutz</a></li>
|
<div className="text-sm font-medium">Rechtliches</div>
|
||||||
<li><a href="/legal/terms" className="underline">AGB</a></li>
|
<CollapsibleTrigger asChild>
|
||||||
</ul>
|
<Button variant="ghost" size="sm" className="h-7 px-2">
|
||||||
</div>
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
</div>
|
||||||
|
<CollapsibleContent>
|
||||||
|
<ul className="mt-2 list-disc pl-5 text-sm">
|
||||||
|
<li><Link to="/legal/impressum" className="underline">Impressum</Link></li>
|
||||||
|
<li><Link to="/legal/datenschutz" className="underline">Datenschutz</Link></li>
|
||||||
|
<li><Link to="/legal/agb" className="underline">AGB</Link></li>
|
||||||
|
</ul>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
</div>
|
</div>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useParams, Link } from 'react-router-dom';
|
|||||||
import { usePollStats } from '../polling/usePollStats';
|
import { usePollStats } from '../polling/usePollStats';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import GalleryPreview from '../components/GalleryPreview';
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
const { slug } = useParams();
|
const { slug } = useParams();
|
||||||
@@ -27,7 +28,7 @@ export default function HomePage() {
|
|||||||
<Link to="upload"><Button>Einfach ein Foto machen</Button></Link>
|
<Link to="upload"><Button>Einfach ein Foto machen</Button></Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-4" />
|
<div className="h-4" />
|
||||||
<Link to="gallery" className="underline">Zur Galerie</Link>
|
<GalleryPreview slug={slug!} />
|
||||||
</Page>
|
</Page>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,47 @@ import { useParams } from 'react-router-dom';
|
|||||||
|
|
||||||
export default function LegalPage() {
|
export default function LegalPage() {
|
||||||
const { page } = useParams();
|
const { page } = useParams();
|
||||||
|
const [loading, setLoading] = React.useState(true);
|
||||||
|
const [title, setTitle] = React.useState('');
|
||||||
|
const [body, setBody] = React.useState('');
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
async function load() {
|
||||||
|
setLoading(true);
|
||||||
|
const res = await fetch(`/api/v1/legal/${encodeURIComponent(page || '')}?lang=de`, { headers: { 'Cache-Control': 'no-store' }});
|
||||||
|
if (res.ok) {
|
||||||
|
const j = await res.json();
|
||||||
|
setTitle(j.title || '');
|
||||||
|
setBody(j.body_markdown || '');
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
if (page) load();
|
||||||
|
}, [page]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page title={`Rechtliches: ${page}`}>
|
<Page title={title || `Rechtliches: ${page}` }>
|
||||||
<p>Impressum / Datenschutz / AGB</p>
|
{loading ? <p>Lädt…</p> : <Markdown md={body} />}
|
||||||
</Page>
|
</Page>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Markdown({ md }: { md: string }) {
|
||||||
|
// Tiny, safe Markdown: paragraphs + basic bold/italic + links; no external dependency
|
||||||
|
const html = React.useMemo(() => {
|
||||||
|
let s = md
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>');
|
||||||
|
// bold **text**
|
||||||
|
s = s.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
||||||
|
// italic *text*
|
||||||
|
s = s.replace(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, '<em>$1</em>');
|
||||||
|
// links [text](url)
|
||||||
|
s = s.replace(/\[(.+?)\]\((https?:[^\s)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1<\/a>');
|
||||||
|
// paragraphs
|
||||||
|
s = s.split(/\n{2,}/).map(p => `<p>${p.replace(/\n/g, '<br/>')}<\/p>`).join('\n');
|
||||||
|
return s;
|
||||||
|
}, [md]);
|
||||||
|
return <div className="prose prose-sm dark:prose-invert" dangerouslySetInnerHTML={{ __html: html }} />;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user