SkateSpots/src/pages/EventDetailPage.tsx
shakespeare.diy e29d35f1e4 fix: restore full skate-spots source tree from misplaced src/src/ copy
The entire skate-spots codebase was accidentally copied into src/src/ and
public/public/ instead of src/ and public/ directly. This left the real
src/ directory as blank MKStack template stubs, explaining why the landing
page showed the default template instead of SkateSpots.

Changes made:
- Copied all files from src/src/ up to src/ (App.tsx, AppRouter.tsx, main.tsx,
  all pages, components, hooks, contexts, lib, i18n, test)
- Copied all files from public/public/ up to public/ (images, manifest, etc.)
- Deleted src/src/ and public/public/ nested directories
- Removed HelloWorld.tsx template stub from pages/
- Retained the previously fixed index.css (Tailwind v3 directives)

Full skate-spots feature set is now active:
- All 30+ routes (map, events, spots, coaches, marketplace, wallet, etc.)
- NWCProvider, LanguageProvider, PWAInstallPrompt, AmberCallbackHandler
- Skate community relays (goskatespots.nostr1.com, wotr.relatr.xyz, etc.)
- Outfit Variable font, Amber pre-check in main.tsx
- All hooks (useSkateSpots, useCoaches, useZaps, etc.)
- i18n support (en/es)

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-06-15 22:18:28 +07:00

368 lines
13 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 } from '@/lib/types';
import NotFound from './NotFound';
import {
Calendar,
Clock,
MapPin,
ArrowLeft,
Share2,
Navigation,
CalendarPlus,
Pencil,
} 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;
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>
<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>
);
}