416 lines
15 KiB
TypeScript
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>
|
|
);
|
|
}
|