SkateSpots/src/pages/EventDetailPage.tsx
Igor Bubelov 571257e5c3 fix(mobile): stop detail-page grids from overflowing horizontally
The spot page (and the other detail pages) used `grid lg:grid-cols-3` with
no explicit mobile column. Below the `lg` breakpoint the grid falls back to
a single implicit `auto` track, whose minimum is the min-content of its
items. A long mapper name ("Tip the Mapper" card) gave the sidebar a
~559px min-content, so the whole grid widened past the viewport and was
clipped by the global `html, body { overflow-x: hidden }` — content cut off
on the right with no way to scroll to it.

Fixes:
- Add `grid-cols-1` to detail-page grids so the mobile track is
  `minmax(0, 1fr)` and can never exceed the container. Applied to Spot,
  Shop, Event, NIP-19, Crew and Coach detail pages.
- Add `min-w-0` to their sidebar/main columns so long content can shrink
  and truncate instead of forcing the track wider.
- Make the Spot Chat header wrap (`flex-wrap` + `gap-y`) so the trust
  filter and channel badge no longer get clipped on narrow phones.
2026-09-26 15:13:59 +07:00

633 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useMemo, useState } 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 { RouteDialog } from '@/components/RouteDialog';
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 { RsvpButtons } from '@/components/RsvpButtons';
import { AttendeeList } from '@/components/AttendeeList';
import { EventUpdates } from '@/components/EventUpdates';
import { EventCountdown } from '@/components/EventCountdown';
import { EventSponsors } from '@/components/EventSponsors';
import { CommentsSection } from '@/components/comments/CommentsSection';
import { DeleteEntityDialog } from '@/components/DeleteEntityDialog';
import { useSkateEvent } from '@/hooks/useSkateEvents';
import { useEventRsvps, eventCoord } from '@/hooks/useEventRsvps';
import { useEventUpdates } from '@/hooks/useEventUpdates';
import { useAuthor } from '@/hooks/useAuthor';
import { genUserName } from '@/lib/genUserName';
import { cn } from '@/lib/utils';
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,
Megaphone,
XCircle,
CalendarClock,
} from 'lucide-react';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useToast } from '@/hooks/useToast';
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 grid-cols-1 lg:grid-cols-3 gap-8">
<div className="lg:col-span-2 space-y-6 min-w-0">
<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 min-w-0">
<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 { toast } = useToast();
const isOwner = user && event && user.pubkey === event.pubkey;
// Canonical, shareable URL for this event (HashRouter → keep the /#/ prefix).
const shareUrl = event
? `https://skate-spots.org/#/event/${event.pubkey}/${encodeURIComponent(event.identifier)}`
: 'https://skate-spots.org';
// og:image must be an absolute URL. Fall back to the site social card when the
// event has no cover so shared links never render blank.
const ogImage = event?.image || 'https://skate-spots.org/images/icon-1024.png';
// Compose a punchy share description: date + location, then the blurb.
const shareDescription = (() => {
if (!event) return 'View this skateboarding event on SkateSpots.';
const bits: string[] = [];
if (event.start) {
bits.push(
new Date(event.start * 1000).toLocaleDateString(undefined, {
weekday: 'short',
month: 'short',
day: 'numeric',
}),
);
}
if (event.location) bits.push(`📍 ${event.location}`);
const prefix = bits.length ? `${bits.join(' · ')} — ` : '';
const body = event.description?.trim() || 'A skateboarding event on SkateSpots.';
return `${prefix}${body}`.slice(0, 200);
})();
const shareTitle = event ? `${event.title} · SkateSpots` : 'Skate Event · SkateSpots';
useSeoMeta({
title: shareTitle,
description: shareDescription,
ogTitle: shareTitle,
ogDescription: shareDescription,
ogType: 'article',
ogUrl: shareUrl,
ogImage,
ogSiteName: 'SkateSpots',
twitterCard: 'summary_large_image',
twitterTitle: shareTitle,
twitterDescription: shareDescription,
twitterImage: ogImage,
});
const metadata = author.data?.metadata;
const displayName = metadata?.name ?? genUserName(event?.pubkey || '');
const [showRouteDialog, setShowRouteDialog] = useState(false);
const coordinates = useMemo(() => {
if (!event?.geohash) return null;
const { latitude, longitude } = ngeohash.decode(event.geohash);
return { lat: latitude, lng: longitude };
}, [event?.geohash]);
// NIP-52 RSVPs for this event (counts + attendee avatars).
const rsvpSummary = useEventRsvps(event ? eventCoord(event) : undefined);
// Host updates → detect if the host cancelled or rescheduled, so we can warn
// attendees loudly instead of letting them show up to a dead jam.
const { data: eventUpdates } = useEventUpdates(event ? eventCoord(event) : undefined);
const latestCancellation = useMemo(
() => eventUpdates?.find((u) => u.updateKind === 'cancelled') ?? null,
[eventUpdates],
);
const latestReschedule = useMemo(() => {
if (latestCancellation) return null; // cancellation wins
return eventUpdates?.find((u) => u.updateKind === 'reschedule' || u.updateKind === 'relocation') ?? null;
}, [eventUpdates, latestCancellation]);
const isCancelled = !!latestCancellation;
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 () => {
const shareText = event
? `${event.title} — a skate event on SkateSpots`
: 'Check out this skate event on SkateSpots';
if (navigator.share) {
try {
await navigator.share({
title: event?.title,
text: shareText,
url: shareUrl,
});
return;
} catch {
// User cancelled or share failed — fall through to clipboard.
}
}
try {
await navigator.clipboard.writeText(shareUrl);
toast({
title: 'Link copied',
description: 'Event link copied to your clipboard — paste it anywhere.',
});
} catch {
toast({
title: 'Copy failed',
description: shareUrl,
variant: 'destructive',
});
}
};
const handleDirections = () => {
if (coordinates) {
// In-app routing — nothing leaves to Google/Apple Maps.
setShowRouteDialog(true);
}
};
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 + owner/admin controls */}
<div className="mb-6 flex items-center justify-between gap-2">
<Link
to="/events"
className="inline-flex items-center text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Events
</Link>
<DeleteEntityDialog
event={event.event}
entityLabel="event"
entityName={event.title}
redirectTo="/events"
/>
</div>
{/* Cancelled / rescheduled banner — loud, above everything */}
{isCancelled && (
<div className="mb-6 flex items-start gap-3 rounded-xl border-2 border-red-500/60 bg-red-500/10 p-4">
<XCircle className="mt-0.5 h-6 w-6 flex-shrink-0 text-red-400" />
<div className="min-w-0">
<p className="text-base font-black uppercase tracking-wide text-red-300">
This event was cancelled
</p>
{latestCancellation?.message && (
<p className="mt-1 whitespace-pre-wrap break-words text-sm text-red-100/90">
{latestCancellation.message}
</p>
)}
</div>
</div>
)}
{!isCancelled && latestReschedule && (
<div className="mb-6 flex items-start gap-3 rounded-xl border-2 border-amber-500/50 bg-amber-500/10 p-4">
<CalendarClock className="mt-0.5 h-6 w-6 flex-shrink-0 text-amber-400" />
<div className="min-w-0">
<p className="text-base font-black uppercase tracking-wide text-amber-300">
{latestReschedule.updateKind === 'relocation' ? 'New location' : 'Time changed'} — check the update
</p>
{latestReschedule.message && (
<p className="mt-1 whitespace-pre-wrap break-words text-sm text-amber-100/90">
{latestReschedule.message}
</p>
)}
</div>
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Main Content */}
<div className="lg:col-span-2 space-y-6 min-w-0">
{/* ── Immersive hero ── */}
<div className="relative overflow-hidden rounded-2xl border border-white/10">
{/* Cover */}
<div className="relative aspect-[16/10] sm:aspect-[16/9] w-full bg-muted">
{event.image ? (
<img
src={event.image}
alt={event.title}
className={cn(
'h-full w-full object-cover',
(isPast || isCancelled) && 'grayscale-[35%] brightness-75',
)}
/>
) : (
<div className="flex h-full w-full items-center justify-center bg-gradient-to-br from-[#ccff00]/20 via-black to-[#ff0040]/20">
<Calendar className="h-16 w-16 text-white/20" />
</div>
)}
{/* Legibility gradient */}
<div className="absolute inset-0 bg-gradient-to-t from-black via-black/50 to-black/10" />
{/* Top row: status pill + actions */}
<div className="absolute inset-x-0 top-0 flex items-start justify-between gap-2 p-4">
<EventCountdown start={event.start} end={event.end} />
<div className="flex items-center gap-1.5">
{isOwner && (
<Link to={`/edit-event/${event.pubkey}/${event.identifier}`}>
<Button
size="icon"
className="h-9 w-9 bg-black/50 text-white backdrop-blur hover:bg-black/70"
aria-label="Edit event"
>
<Pencil className="h-4 w-4" />
</Button>
</Link>
)}
{isUpcoming && (
<Button
size="icon"
onClick={handleAddToCalendar}
className="h-9 w-9 bg-black/50 text-white backdrop-blur hover:bg-black/70"
aria-label="Add to calendar"
>
<CalendarPlus className="h-4 w-4" />
</Button>
)}
<Button
size="icon"
onClick={handleShare}
className="h-9 w-9 bg-black/50 text-white backdrop-blur hover:bg-black/70"
aria-label="Share event"
>
<Share2 className="h-4 w-4" />
</Button>
</div>
</div>
{/* Bottom: title block */}
<div className="absolute inset-x-0 bottom-0 p-4 sm:p-6">
{event.types.length > 0 && (
<div className="mb-2 flex flex-wrap gap-1.5">
{event.types.slice(0, 3).map((type) => (
<span
key={type}
className="rounded-full bg-[#ccff00] px-2.5 py-0.5 text-[10px] font-black uppercase tracking-wide text-black"
>
{EVENT_TYPE_LABELS[type] || type}
</span>
))}
</div>
)}
<h1 className="text-3xl font-black uppercase leading-none tracking-tight text-white sm:text-4xl drop-shadow-lg">
{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-400/50 bg-teal-500/20 px-3 py-1 text-sm font-bold text-teal-200 backdrop-blur transition-colors hover:bg-teal-500/30"
>
<Users className="h-3.5 w-3.5" />
{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-400/50 bg-fuchsia-500/20 px-3 py-1 text-sm font-bold text-fuchsia-200 backdrop-blur transition-colors hover:bg-fuchsia-500/30"
>
<Music className="h-3.5 w-3.5" />
Live: {musicAct.name || 'live music'}
</Link>
)}
</div>
</div>
</div>
{/* Meta bar: date · time · location */}
<div className="grid grid-cols-1 divide-y divide-white/10 bg-white/[0.03] sm:grid-cols-3 sm:divide-x sm:divide-y-0">
<div className="flex items-center gap-3 p-4">
<div className="flex h-11 w-11 flex-shrink-0 flex-col items-center justify-center rounded-lg border-2 border-[#ccff00]/40 bg-[#ccff00]/10">
<span className="text-[9px] font-black uppercase leading-none text-[#ccff00]">
{new Date(event.start * 1000).toLocaleDateString(undefined, { month: 'short' })}
</span>
<span className="text-lg font-black leading-none text-white">
{new Date(event.start * 1000).getDate()}
</span>
</div>
<div className="min-w-0">
<p className="text-[10px] font-black uppercase tracking-widest text-gray-500">Date</p>
<p className="truncate text-sm font-bold text-white">
{new Date(event.start * 1000).toLocaleDateString(undefined, { weekday: 'long' })}
</p>
</div>
</div>
<div className="flex items-center gap-3 p-4">
<div className="flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-lg border border-white/10 bg-white/5">
<Clock className="h-5 w-5 text-[#ff6600]" />
</div>
<div className="min-w-0">
<p className="text-[10px] font-black uppercase tracking-widest text-gray-500">Time</p>
<p className="truncate text-sm font-bold text-white">
{formatTime(event.start)}
{event.end && ` – ${formatTime(event.end)}`}
</p>
</div>
</div>
<div className="flex items-center gap-3 p-4">
<div className="flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-lg border border-white/10 bg-white/5">
<MapPin className="h-5 w-5 text-[#ff0040]" />
</div>
<div className="min-w-0">
<p className="text-[10px] font-black uppercase tracking-widest text-gray-500">Where</p>
<p className="truncate text-sm font-bold text-white">
{event.location || 'See map'}
</p>
</div>
</div>
</div>
</div>
{/* Event body */}
<div className="space-y-4">
{/* Sponsors */}
{event.sponsors && event.sponsors.length > 0 && (
<Card>
<CardContent className="pt-6">
<EventSponsors sponsors={event.sponsors} />
</CardContent>
</Card>
)}
{/* Description */}
{event.description && (
<Card>
<CardContent className="pt-6">
<p className="whitespace-pre-wrap">{event.description}</p>
</CardContent>
</Card>
)}
{/* Host Updates */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-lg flex items-center gap-2">
<Megaphone className="h-5 w-5 text-[#ccff00]" />
Updates from the host
</CardTitle>
</CardHeader>
<CardContent>
<EventUpdates event={event} />
</CardContent>
</Card>
{/* Comments Section */}
{event.event && <CommentsSection root={event.event} />}
</div>
</div>
{/* Sidebar */}
<div className="space-y-6 min-w-0">
{/* 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>
{/* RSVP / Attendance */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-lg flex items-center gap-2">
<Users className="h-5 w-5 text-primary" />
{isPast ? 'Who came' : 'Who\u2019s going'}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{isCancelled ? (
<p className="rounded-lg border border-red-500/30 bg-red-500/5 px-3 py-2 text-sm font-medium text-red-300">
RSVPs are closed — this event was cancelled by the host.
</p>
) : (
!isPast && <RsvpButtons event={event} />
)}
{(rsvpSummary.goingCount > 0 || rsvpSummary.tentativeCount > 0) ? (
<AttendeeList going={rsvpSummary.going} tentative={rsvpSummary.tentative} />
) : (
!rsvpSummary.isLoading &&
isPast && (
<p className="text-sm text-muted-foreground">No RSVPs were recorded.</p>
)
)}
</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>
{/* In-app route (no external maps) */}
<RouteDialog
open={showRouteDialog}
onOpenChange={setShowRouteDialog}
destination={coordinates}
destinationLabel={event?.title ?? 'Event'}
/>
</Layout>
);
}