SkateSpots/src/pages/EventDetailPage.tsx

416 lines
15 KiB
TypeScript

import { useMemo } from 'react';
import { useSeoMeta } from '@unhead/react';
import { useParams, Link } from 'react-router-dom';
import ngeohash from 'ngeohash';
import { Layout } from '@/components/Layout';
import { SpotMap } from '@/components/SpotMap';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Skeleton } from '@/components/ui/skeleton';
import { Button } from '@/components/ui/button';
import { ZapButton } from '@/components/ZapButton';
import { CommentsSection } from '@/components/comments/CommentsSection';
import { useSkateEvent } from '@/hooks/useSkateEvents';
import { useAuthor } from '@/hooks/useAuthor';
import { genUserName } from '@/lib/genUserName';
import { EVENT_TYPE_LABELS, SKATE_CREW_KIND, SKATE_MUSICIAN_KIND } from '@/lib/types';
import { Users } from 'lucide-react';
import NotFound from './NotFound';
import {
Calendar,
Clock,
MapPin,
ArrowLeft,
Share2,
Navigation,
CalendarPlus,
Pencil,
Music,
} from 'lucide-react';
import { useCurrentUser } from '@/hooks/useCurrentUser';
function formatDate(timestamp: number): string {
const date = new Date(timestamp * 1000);
return date.toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
});
}
function formatTime(timestamp: number): string {
const date = new Date(timestamp * 1000);
return date.toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit',
});
}
function EventDetailSkeleton() {
return (
<Layout>
<div className="container px-4 py-8">
<div className="grid lg:grid-cols-3 gap-8">
<div className="lg:col-span-2 space-y-6">
<Skeleton className="aspect-video w-full rounded-xl" />
<div className="space-y-4">
<Skeleton className="h-10 w-3/4" />
<Skeleton className="h-6 w-1/2" />
<div className="flex gap-2">
<Skeleton className="h-6 w-20" />
<Skeleton className="h-6 w-20" />
</div>
<Skeleton className="h-24 w-full" />
</div>
</div>
<div className="space-y-6">
<Skeleton className="h-48 w-full rounded-xl" />
<Skeleton className="h-32 w-full rounded-xl" />
</div>
</div>
</div>
</Layout>
);
}
export default function EventDetailPage() {
const { pubkey, identifier } = useParams<{ pubkey: string; identifier: string }>();
const { data: event, isLoading, error } = useSkateEvent(identifier || '', pubkey);
const author = useAuthor(event?.pubkey);
const { user } = useCurrentUser();
const isOwner = user && event && user.pubkey === event.pubkey;
useSeoMeta({
title: event ? `${event.title} - SkateSpots` : 'Skate Event - SkateSpots',
description: event?.description || 'View this skateboarding event on SkateSpots.',
});
const metadata = author.data?.metadata;
const displayName = metadata?.name ?? genUserName(event?.pubkey || '');
const coordinates = useMemo(() => {
if (!event?.geohash) return null;
const { latitude, longitude } = ngeohash.decode(event.geohash);
return { lat: latitude, lng: longitude };
}, [event?.geohash]);
const isUpcoming = event ? event.start > Date.now() / 1000 : false;
const isPast = event ? event.start < Date.now() / 1000 : false;
// Crew host — an `a` tag pointing at a crew coordinate (35006:pubkey:d).
const crewHost = useMemo(() => {
if (!event) return null;
const ref = event.event.tags
.filter(([t]) => t === 'a')
.map(([, v]) => v)
.find((v) => v?.startsWith(`${SKATE_CREW_KIND}:`));
if (!ref) return null;
const [, hPubkey, hId] = ref.split(':');
const name = event.event.tags.find(([t]) => t === 'crew-name')?.[1];
return hPubkey && hId ? { pubkey: hPubkey, identifier: hId, name } : null;
}, [event]);
// Featured musician — an `a` tag pointing at a musician coordinate (35011:pubkey:d).
const musicAct = useMemo(() => {
if (!event) return null;
const ref = event.event.tags
.filter(([t]) => t === 'a')
.map(([, v]) => v)
.find((v) => v?.startsWith(`${SKATE_MUSICIAN_KIND}:`));
if (!ref) return null;
const [, mPubkey, mId] = ref.split(':');
const name = event.event.tags.find(([t]) => t === 'music-act')?.[1];
return mPubkey && mId ? { pubkey: mPubkey, identifier: mId, name } : null;
}, [event]);
const handleShare = async () => {
if (navigator.share) {
try {
await navigator.share({
title: event?.title,
text: event?.description || `Check out this skate event: ${event?.title}`,
url: window.location.href,
});
} catch {
// User cancelled or share failed
}
} else {
navigator.clipboard.writeText(window.location.href);
}
};
const handleDirections = () => {
if (coordinates) {
window.open(
`https://www.google.com/maps/dir/?api=1&destination=${coordinates.lat},${coordinates.lng}`,
'_blank'
);
}
};
const handleAddToCalendar = () => {
if (!event) return;
const startDate = new Date(event.start * 1000);
const endDate = event.end ? new Date(event.end * 1000) : new Date(event.start * 1000 + 3600000); // Default 1 hour
const formatICSDate = (date: Date) => {
return date.toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z';
};
const icsContent = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'BEGIN:VEVENT',
`DTSTART:${formatICSDate(startDate)}`,
`DTEND:${formatICSDate(endDate)}`,
`SUMMARY:${event.title}`,
`DESCRIPTION:${event.description || ''}`,
`LOCATION:${event.location || ''}`,
'END:VEVENT',
'END:VCALENDAR',
].join('\n');
const blob = new Blob([icsContent], { type: 'text/calendar' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${event.title.replace(/[^a-z0-9]/gi, '-')}.ics`;
link.click();
URL.revokeObjectURL(url);
};
if (isLoading) {
return <EventDetailSkeleton />;
}
if (error || !event) {
return <NotFound />;
}
return (
<Layout>
<div className="container px-4 py-8">
{/* Back link */}
<Link
to="/events"
className="inline-flex items-center text-muted-foreground hover:text-foreground transition-colors mb-6"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Events
</Link>
<div className="grid lg:grid-cols-3 gap-8">
{/* Main Content */}
<div className="lg:col-span-2 space-y-6">
{/* Cover Image */}
{event.image ? (
<div className="aspect-video rounded-xl overflow-hidden bg-muted">
<img
src={event.image}
alt={event.title}
className="w-full h-full object-cover"
/>
</div>
) : (
<div className="aspect-video rounded-xl bg-gradient-to-br from-primary/20 to-accent/20 flex items-center justify-center">
<Calendar className="h-16 w-16 text-primary/40" />
</div>
)}
{/* Event Info */}
<div className="space-y-4">
<div className="flex items-start justify-between gap-4">
<div>
<div className="flex items-center gap-2 mb-2">
{isUpcoming && (
<Badge className="bg-green-500">Upcoming</Badge>
)}
{isPast && (
<Badge variant="secondary">Past Event</Badge>
)}
</div>
<h1 className="text-3xl font-bold">{event.title}</h1>
<div className="mt-2 flex flex-wrap items-center gap-2">
{crewHost && (
<Link
to={`/crew/${crewHost.pubkey}/${crewHost.identifier}`}
className="inline-flex items-center gap-1.5 rounded-full border border-teal-500/40 bg-teal-500/10 px-3 py-1 text-sm font-bold text-teal-300 transition-colors hover:bg-teal-500/20"
>
<Users className="h-3.5 w-3.5" />
Hosted by {crewHost.name || 'a crew'}
</Link>
)}
{musicAct && (
<Link
to={`/coach/${musicAct.pubkey}/${musicAct.identifier}`}
className="inline-flex items-center gap-1.5 rounded-full border border-fuchsia-500/40 bg-fuchsia-500/10 px-3 py-1 text-sm font-bold text-fuchsia-300 transition-colors hover:bg-fuchsia-500/20"
>
<Music className="h-3.5 w-3.5" />
Live: {musicAct.name || 'live music'}
</Link>
)}
</div>
</div>
<div className="flex items-center gap-2">
{isOwner && (
<Link to={`/edit-event/${event.pubkey}/${event.identifier}`}>
<Button variant="outline" size="icon">
<Pencil className="h-4 w-4" />
</Button>
</Link>
)}
<Button variant="outline" size="icon" onClick={handleShare}>
<Share2 className="h-4 w-4" />
</Button>
{isUpcoming && (
<Button variant="outline" size="icon" onClick={handleAddToCalendar}>
<CalendarPlus className="h-4 w-4" />
</Button>
)}
</div>
</div>
{/* Date & Time */}
<div className="flex flex-wrap gap-4 text-muted-foreground">
<div className="flex items-center gap-2">
<Calendar className="h-5 w-5" />
<span>{formatDate(event.start)}</span>
</div>
<div className="flex items-center gap-2">
<Clock className="h-5 w-5" />
<span>
{formatTime(event.start)}
{event.end && ` - ${formatTime(event.end)}`}
</span>
</div>
</div>
{/* Location */}
{event.location && (
<p className="text-muted-foreground flex items-center gap-2">
<MapPin className="h-5 w-5" />
{event.location}
</p>
)}
{/* Types */}
{event.types.length > 0 && (
<div className="flex flex-wrap gap-2">
{event.types.map((type) => (
<Badge key={type} variant="secondary">
{EVENT_TYPE_LABELS[type] || type}
</Badge>
))}
</div>
)}
{/* Description */}
{event.description && (
<Card>
<CardContent className="pt-6">
<p className="whitespace-pre-wrap">{event.description}</p>
</CardContent>
</Card>
)}
{/* Comments Section */}
{event.event && <CommentsSection root={event.event} />}
</div>
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* Date Card */}
<Card className="bg-gradient-to-br from-primary/5 to-accent/5">
<CardContent className="pt-6">
<div className="text-center">
<div className="text-sm font-medium text-primary uppercase mb-1">
{new Date(event.start * 1000).toLocaleDateString('en-US', { month: 'long' })}
</div>
<div className="text-5xl font-bold mb-2">
{new Date(event.start * 1000).getDate()}
</div>
<div className="text-muted-foreground">
{new Date(event.start * 1000).toLocaleDateString('en-US', { weekday: 'long' })}
</div>
<div className="text-lg font-medium mt-2">
{formatTime(event.start)}
</div>
</div>
{isUpcoming && (
<Button className="w-full mt-4 gap-2" onClick={handleAddToCalendar}>
<CalendarPlus className="h-4 w-4" />
Add to Calendar
</Button>
)}
</CardContent>
</Card>
{/* Map (if has location) */}
{coordinates && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-lg flex items-center gap-2">
<MapPin className="h-5 w-5 text-primary" />
Location
</CardTitle>
</CardHeader>
<CardContent>
<div className="aspect-square rounded-lg overflow-hidden">
<SpotMap
spots={[]}
center={[coordinates.lat, coordinates.lng]}
zoom={15}
className="h-full"
/>
</div>
<Button
variant="outline"
className="w-full mt-4 gap-2"
onClick={handleDirections}
>
<Navigation className="h-4 w-4" />
Get Directions
</Button>
</CardContent>
</Card>
)}
{/* Organizer Card */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-lg">Organizer</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center gap-3">
<Avatar className="h-12 w-12">
<AvatarImage src={metadata?.picture} alt={displayName} />
<AvatarFallback>
{displayName.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{displayName}</p>
<p className="text-sm text-muted-foreground">Event Organizer</p>
</div>
</div>
<div className="mt-4 flex items-center gap-2">
<ZapButton
target={event.event}
className="flex-1 justify-center"
/>
</div>
</CardContent>
</Card>
</div>
</div>
</div>
</Layout>
);
}