- AppRouter wraps every route (except /amber-callback and the 404) in ONE
<Route element={<Layout/>}> with an <Outlet/>; the chrome now persists across
navigation instead of being torn down and rebuilt on every page.
- Layout: children optional (falls back to <Outlet/>); the map is full-bleed via
a route check (isFullBleed) instead of a per-page noPadding prop.
- All 59 pages + CraftProfileForm/ErrorState/NewsletterIssueView now render
fragments instead of <Layout> (structure-preserving), imports dropped.
Removes the whole remount class: chrome no longer re-runs its hooks per
navigation, Sasquatch chat state survives navigation, and the header can't
re-open Amber on every page change.
633 lines
22 KiB
TypeScript
633 lines
22 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
import { useSeoMeta } from '@unhead/react';
|
|
import { useNavigate, useParams } from 'react-router-dom';
|
|
import ngeohash from 'ngeohash';
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Textarea } from '@/components/ui/textarea';
|
|
import { Label } from '@/components/ui/label';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Skeleton } from '@/components/ui/skeleton';
|
|
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
|
import { useUploadFile } from '@/hooks/useUploadFile';
|
|
import { useToast } from '@/hooks/useToast';
|
|
import { useSkateEvent } from '@/hooks/useSkateEvents';
|
|
import { useEventRsvps, eventCoord } from '@/hooks/useEventRsvps';
|
|
import { usePostEventUpdate } from '@/hooks/useEventUpdates';
|
|
import { LoginArea } from '@/components/auth/LoginArea';
|
|
import { CALENDAR_EVENT_KIND, EVENT_TYPE_LABELS, type EventUpdateKind } from '@/lib/types';
|
|
import { cn } from '@/lib/utils';
|
|
import {
|
|
Calendar,
|
|
Upload,
|
|
X,
|
|
Loader2,
|
|
ArrowLeft,
|
|
MapPin,
|
|
AlertTriangle,
|
|
} from 'lucide-react';
|
|
import { Link } from 'react-router-dom';
|
|
import NotFound from './NotFound';
|
|
|
|
const EVENT_TYPES = Object.entries(EVENT_TYPE_LABELS);
|
|
|
|
function formatDateForInput(timestamp: number): string {
|
|
const date = new Date(timestamp * 1000);
|
|
return date.toISOString().split('T')[0];
|
|
}
|
|
|
|
function formatTimeForInput(timestamp: number): string {
|
|
const date = new Date(timestamp * 1000);
|
|
return date.toTimeString().slice(0, 5);
|
|
}
|
|
|
|
function EditEventSkeleton() {
|
|
return (
|
|
<>
|
|
<div className="container px-4 py-8 max-w-2xl">
|
|
<Skeleton className="h-6 w-32 mb-4" />
|
|
<Skeleton className="h-10 w-64 mb-2" />
|
|
<Skeleton className="h-5 w-48 mb-6" />
|
|
<Card>
|
|
<CardHeader>
|
|
<Skeleton className="h-6 w-32" />
|
|
<Skeleton className="h-4 w-64" />
|
|
</CardHeader>
|
|
<CardContent className="space-y-6">
|
|
<Skeleton className="h-10 w-full" />
|
|
<Skeleton className="h-24 w-full" />
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<Skeleton className="h-10 w-full" />
|
|
<Skeleton className="h-10 w-full" />
|
|
</div>
|
|
<Skeleton className="h-10 w-full" />
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
export default function EditEventPage() {
|
|
const { pubkey, identifier } = useParams<{ pubkey: string; identifier: string }>();
|
|
const navigate = useNavigate();
|
|
const { user } = useCurrentUser();
|
|
const { data: event, isLoading, error } = useSkateEvent(identifier || '', pubkey);
|
|
const { mutateAsync: createEvent, isPending: isPublishing } = useNostrPublish();
|
|
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
|
|
const { toast } = useToast();
|
|
|
|
// RSVP audience + host-update poster, so edits can auto-notify attendees.
|
|
const rsvpSummary = useEventRsvps(event ? eventCoord(event) : undefined);
|
|
const { mutateAsync: postUpdate } = usePostEventUpdate();
|
|
|
|
// Form state
|
|
const [title, setTitle] = useState('');
|
|
const [description, setDescription] = useState('');
|
|
const [location, setLocation] = useState('');
|
|
const [latitude, setLatitude] = useState('');
|
|
const [longitude, setLongitude] = useState('');
|
|
const [startDate, setStartDate] = useState('');
|
|
const [startTime, setStartTime] = useState('');
|
|
const [endDate, setEndDate] = useState('');
|
|
const [endTime, setEndTime] = useState('');
|
|
const [selectedTypes, setSelectedTypes] = useState<string[]>([]);
|
|
const [image, setImage] = useState<string | null>(null);
|
|
const [isGettingLocation, setIsGettingLocation] = useState(false);
|
|
const [initialized, setInitialized] = useState(false);
|
|
|
|
useSeoMeta({
|
|
title: event ? `Edit ${event.title} - SkateSpots` : 'Edit Event - SkateSpots',
|
|
description: 'Edit your skateboarding event.',
|
|
});
|
|
|
|
// Initialize form with existing data
|
|
useEffect(() => {
|
|
if (event && !initialized) {
|
|
setTitle(event.title);
|
|
setDescription(event.description || '');
|
|
setLocation(event.location || '');
|
|
setSelectedTypes(event.types);
|
|
setImage(event.image || null);
|
|
|
|
// Set dates and times
|
|
setStartDate(formatDateForInput(event.start));
|
|
setStartTime(formatTimeForInput(event.start));
|
|
|
|
if (event.end) {
|
|
setEndDate(formatDateForInput(event.end));
|
|
setEndTime(formatTimeForInput(event.end));
|
|
}
|
|
|
|
// Decode geohash to get coordinates
|
|
if (event.geohash) {
|
|
const { latitude: lat, longitude: lng } = ngeohash.decode(event.geohash);
|
|
setLatitude(lat.toFixed(6));
|
|
setLongitude(lng.toFixed(6));
|
|
}
|
|
|
|
setInitialized(true);
|
|
}
|
|
}, [event, initialized]);
|
|
|
|
const toggleType = (type: string) => {
|
|
setSelectedTypes((prev) =>
|
|
prev.includes(type) ? prev.filter((t) => t !== type) : [...prev, type]
|
|
);
|
|
};
|
|
|
|
const getCurrentLocation = () => {
|
|
if (!navigator.geolocation) {
|
|
toast({
|
|
title: 'Geolocation not supported',
|
|
description: 'Your browser does not support geolocation.',
|
|
variant: 'destructive',
|
|
});
|
|
return;
|
|
}
|
|
|
|
setIsGettingLocation(true);
|
|
navigator.geolocation.getCurrentPosition(
|
|
(position) => {
|
|
setLatitude(position.coords.latitude.toFixed(6));
|
|
setLongitude(position.coords.longitude.toFixed(6));
|
|
setIsGettingLocation(false);
|
|
toast({
|
|
title: 'Location found',
|
|
description: 'Your current location has been set.',
|
|
});
|
|
},
|
|
(error) => {
|
|
setIsGettingLocation(false);
|
|
toast({
|
|
title: 'Location error',
|
|
description: error.message,
|
|
variant: 'destructive',
|
|
});
|
|
}
|
|
);
|
|
};
|
|
|
|
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const files = e.target.files;
|
|
if (!files || files.length === 0) return;
|
|
|
|
try {
|
|
const file = files[0];
|
|
const tags = await uploadFile(file);
|
|
const url = tags[0]?.[1];
|
|
if (url) {
|
|
setImage(url);
|
|
toast({
|
|
title: 'Image uploaded',
|
|
description: 'Your image has been uploaded successfully.',
|
|
});
|
|
}
|
|
} catch (error) {
|
|
toast({
|
|
title: 'Upload failed',
|
|
description: error instanceof Error ? error.message : 'Failed to upload image',
|
|
variant: 'destructive',
|
|
});
|
|
}
|
|
|
|
e.target.value = '';
|
|
};
|
|
|
|
const removeImage = () => {
|
|
setImage(null);
|
|
};
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
|
|
if (!event) return;
|
|
|
|
if (!title.trim()) {
|
|
toast({
|
|
title: 'Title required',
|
|
description: 'Please enter a name for this event.',
|
|
variant: 'destructive',
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (!startDate || !startTime) {
|
|
toast({
|
|
title: 'Start time required',
|
|
description: 'Please enter when the event starts.',
|
|
variant: 'destructive',
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Parse start time
|
|
const startDateTime = new Date(`${startDate}T${startTime}`);
|
|
const startTimestamp = Math.floor(startDateTime.getTime() / 1000);
|
|
|
|
if (isNaN(startTimestamp)) {
|
|
toast({
|
|
title: 'Invalid date',
|
|
description: 'Please enter a valid start date and time.',
|
|
variant: 'destructive',
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Parse end time if provided
|
|
let endTimestamp: number | undefined;
|
|
if (endDate && endTime) {
|
|
const endDateTime = new Date(`${endDate}T${endTime}`);
|
|
endTimestamp = Math.floor(endDateTime.getTime() / 1000);
|
|
|
|
if (isNaN(endTimestamp) || endTimestamp <= startTimestamp) {
|
|
toast({
|
|
title: 'Invalid end time',
|
|
description: 'End time must be after start time.',
|
|
variant: 'destructive',
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
|
|
try {
|
|
// Build tags - use the same identifier to update the addressable event
|
|
const tags: string[][] = [
|
|
['d', event.identifier],
|
|
['title', title.trim()],
|
|
['start', startTimestamp.toString()],
|
|
['t', 'skateboarding'],
|
|
['alt', `Skate event: ${title.trim()}`],
|
|
];
|
|
|
|
if (endTimestamp) {
|
|
tags.push(['end', endTimestamp.toString()]);
|
|
}
|
|
|
|
if (location.trim()) {
|
|
tags.push(['location', location.trim()]);
|
|
}
|
|
|
|
// Add geohash if coordinates provided
|
|
if (latitude && longitude) {
|
|
const lat = parseFloat(latitude);
|
|
const lng = parseFloat(longitude);
|
|
if (!isNaN(lat) && !isNaN(lng) && lat >= -90 && lat <= 90 && lng >= -180 && lng <= 180) {
|
|
const geohash = ngeohash.encode(lat, lng, 7);
|
|
tags.push(['g', geohash]);
|
|
}
|
|
}
|
|
|
|
for (const type of selectedTypes) {
|
|
tags.push(['t', type]);
|
|
}
|
|
|
|
if (image) {
|
|
tags.push(['image', image]);
|
|
}
|
|
|
|
// Carry through every tag the edit form doesn't manage, untouched. This
|
|
// is an addressable event, so re-publishing REPLACES the whole tag set —
|
|
// if we only rebuilt the fields on this form we'd silently strip linked
|
|
// spots/crews/tours/musicians, the entry fee, the paid "featured" (Pro)
|
|
// listing, sponsors, and any other tags an event was created with.
|
|
const MANAGED_TAGS = new Set([
|
|
'd', 'title', 'start', 'end', 'location', 'g', 'image', 'alt',
|
|
]);
|
|
// `t` is special: we fully rebuild the topic tags this form owns
|
|
// (skateboarding + selected types), but must preserve OTHER `t` values
|
|
// like 'paid' that other parts of the app set.
|
|
const managedTopics = new Set(['skateboarding', ...selectedTypes]);
|
|
for (const tag of event.event.tags) {
|
|
const [name, value] = tag;
|
|
if (MANAGED_TAGS.has(name)) continue;
|
|
if (name === 't') {
|
|
if (!managedTopics.has(value)) tags.push(tag); // keep foreign topics (e.g. 'paid')
|
|
continue;
|
|
}
|
|
tags.push(tag); // a-refs, *-name, entry-fee, featured, sponsor, etc.
|
|
}
|
|
|
|
// Detect the changes attendees care about (time / location) BEFORE we
|
|
// overwrite the event, so we can auto-notify RSVPers.
|
|
const newLocation = location.trim();
|
|
const timeChanged = startTimestamp !== event.start || endTimestamp !== event.end;
|
|
const locationChanged = newLocation !== (event.location || '');
|
|
|
|
await createEvent({
|
|
kind: CALENDAR_EVENT_KIND,
|
|
content: description.trim(),
|
|
tags,
|
|
});
|
|
|
|
// Auto-alert attendees when the when/where changed.
|
|
const audience = [...rsvpSummary.going, ...rsvpSummary.tentative];
|
|
if ((timeChanged || locationChanged) && audience.length > 0) {
|
|
const updateKind: EventUpdateKind = timeChanged ? 'reschedule' : 'relocation';
|
|
const parts: string[] = [];
|
|
if (timeChanged) {
|
|
parts.push(
|
|
`New time: ${startDateTime.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' })} at ${startDateTime.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' })}.`,
|
|
);
|
|
}
|
|
if (locationChanged) {
|
|
parts.push(newLocation ? `New spot: ${newLocation}.` : 'Location updated.');
|
|
}
|
|
try {
|
|
await postUpdate({
|
|
event: { ...event, start: startTimestamp, end: endTimestamp, location: newLocation },
|
|
message: `${parts.join(' ')} — see the event for details.`,
|
|
updateKind,
|
|
notifyPubkeys: audience,
|
|
});
|
|
} catch {
|
|
// Non-fatal: the edit still succeeded even if the alert didn't post.
|
|
}
|
|
}
|
|
|
|
const notified = (timeChanged || locationChanged) && audience.length > 0;
|
|
toast({
|
|
title: 'Event updated!',
|
|
description: notified
|
|
? `Changes published — ${new Set(audience).size} attendee${new Set(audience).size === 1 ? '' : 's'} notified.`
|
|
: 'Your changes have been published to the network.',
|
|
});
|
|
|
|
navigate(`/event/${user?.pubkey}/${event.identifier}`);
|
|
} catch (error) {
|
|
toast({
|
|
title: 'Failed to update',
|
|
description: error instanceof Error ? error.message : 'Something went wrong',
|
|
variant: 'destructive',
|
|
});
|
|
}
|
|
};
|
|
|
|
if (isLoading) {
|
|
return <EditEventSkeleton />;
|
|
}
|
|
|
|
if (error || !event) {
|
|
return <NotFound />;
|
|
}
|
|
|
|
// Check if user is logged in
|
|
if (!user) {
|
|
return (
|
|
<>
|
|
<div className="container px-4 py-8">
|
|
<Card className="max-w-xl mx-auto border-dashed">
|
|
<CardContent className="py-12 text-center">
|
|
<Calendar className="h-12 w-12 mx-auto mb-4 text-muted-foreground/50" />
|
|
<h2 className="text-xl font-bold mb-2">Login Required</h2>
|
|
<p className="text-muted-foreground mb-6">
|
|
You need to log in to edit this event.
|
|
</p>
|
|
<LoginArea className="justify-center" />
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
// Check if user is the owner
|
|
if (user.pubkey !== event.pubkey) {
|
|
return (
|
|
<>
|
|
<div className="container px-4 py-8">
|
|
<Card className="max-w-xl mx-auto border-destructive">
|
|
<CardContent className="py-12 text-center">
|
|
<AlertTriangle className="h-12 w-12 mx-auto mb-4 text-destructive" />
|
|
<h2 className="text-xl font-bold mb-2">Access Denied</h2>
|
|
<p className="text-muted-foreground mb-6">
|
|
You can only edit events that you created.
|
|
</p>
|
|
<Link to={`/event/${event.pubkey}/${event.identifier}`}>
|
|
<Button variant="outline">Back to Event</Button>
|
|
</Link>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<div className="container px-4 py-8 max-w-2xl">
|
|
<div className="mb-6">
|
|
<Link
|
|
to={`/event/${event.pubkey}/${event.identifier}`}
|
|
className="inline-flex items-center text-muted-foreground hover:text-foreground transition-colors mb-4"
|
|
>
|
|
<ArrowLeft className="h-4 w-4 mr-2" />
|
|
Back to Event
|
|
</Link>
|
|
<h1 className="text-3xl font-bold">Edit Event</h1>
|
|
<p className="text-muted-foreground mt-1">
|
|
Update the details for {event.title}
|
|
</p>
|
|
</div>
|
|
|
|
<form onSubmit={handleSubmit}>
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Event Details</CardTitle>
|
|
<CardDescription>
|
|
Update the information about this event. All fields marked with * are required.
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-6">
|
|
{/* Title */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="title">Event Name *</Label>
|
|
<Input
|
|
id="title"
|
|
placeholder="e.g., Summer Skate Session"
|
|
value={title}
|
|
onChange={(e) => setTitle(e.target.value)}
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
{/* Description */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="description">Description</Label>
|
|
<Textarea
|
|
id="description"
|
|
placeholder="Describe the event, what to expect, rules, prizes, etc."
|
|
value={description}
|
|
onChange={(e) => setDescription(e.target.value)}
|
|
rows={4}
|
|
/>
|
|
</div>
|
|
|
|
{/* Start Date/Time */}
|
|
<div className="space-y-2">
|
|
<Label>Start Date & Time *</Label>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<Input
|
|
type="date"
|
|
value={startDate}
|
|
onChange={(e) => setStartDate(e.target.value)}
|
|
required
|
|
/>
|
|
<Input
|
|
type="time"
|
|
value={startTime}
|
|
onChange={(e) => setStartTime(e.target.value)}
|
|
required
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* End Date/Time */}
|
|
<div className="space-y-2">
|
|
<Label>End Date & Time (optional)</Label>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<Input
|
|
type="date"
|
|
value={endDate}
|
|
onChange={(e) => setEndDate(e.target.value)}
|
|
/>
|
|
<Input
|
|
type="time"
|
|
value={endTime}
|
|
onChange={(e) => setEndTime(e.target.value)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Location Address */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="location">Venue / Address</Label>
|
|
<Input
|
|
id="location"
|
|
placeholder="e.g., Venice Beach Skatepark, CA"
|
|
value={location}
|
|
onChange={(e) => setLocation(e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
{/* Coordinates (optional for events) */}
|
|
<div className="space-y-2">
|
|
<Label>Coordinates (optional)</Label>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<Input
|
|
placeholder="Latitude"
|
|
value={latitude}
|
|
onChange={(e) => setLatitude(e.target.value)}
|
|
/>
|
|
<Input
|
|
placeholder="Longitude"
|
|
value={longitude}
|
|
onChange={(e) => setLongitude(e.target.value)}
|
|
/>
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={getCurrentLocation}
|
|
disabled={isGettingLocation}
|
|
className="mt-2"
|
|
>
|
|
{isGettingLocation ? (
|
|
<>
|
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
|
Getting location...
|
|
</>
|
|
) : (
|
|
<>
|
|
<MapPin className="h-4 w-4 mr-2" />
|
|
Use my current location
|
|
</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Event Types */}
|
|
<div className="space-y-2">
|
|
<Label>Event Type(s)</Label>
|
|
<div className="flex flex-wrap gap-2">
|
|
{EVENT_TYPES.map(([type, label]) => (
|
|
<Badge
|
|
key={type}
|
|
variant={selectedTypes.includes(type) ? 'default' : 'outline'}
|
|
className={cn(
|
|
'cursor-pointer transition-colors',
|
|
selectedTypes.includes(type)
|
|
? 'bg-primary hover:bg-primary/90'
|
|
: 'hover:bg-muted'
|
|
)}
|
|
onClick={() => toggleType(type)}
|
|
>
|
|
{label}
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Cover Image */}
|
|
<div className="space-y-2">
|
|
<Label>Cover Image</Label>
|
|
{image ? (
|
|
<div className="relative aspect-video rounded-lg overflow-hidden bg-muted max-w-xs">
|
|
<img src={image} alt="Event cover" className="w-full h-full object-cover" />
|
|
<button
|
|
type="button"
|
|
onClick={removeImage}
|
|
className="absolute top-2 right-2 p-1 rounded-full bg-background/80 hover:bg-destructive hover:text-destructive-foreground transition-colors"
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<label className="block max-w-xs aspect-video rounded-lg border-2 border-dashed flex flex-col items-center justify-center cursor-pointer hover:bg-muted/50 transition-colors">
|
|
<input
|
|
type="file"
|
|
accept="image/*"
|
|
className="hidden"
|
|
onChange={handleImageUpload}
|
|
disabled={isUploading}
|
|
/>
|
|
{isUploading ? (
|
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
|
) : (
|
|
<>
|
|
<Upload className="h-6 w-6 text-muted-foreground mb-1" />
|
|
<span className="text-sm text-muted-foreground">Upload cover image</span>
|
|
</>
|
|
)}
|
|
</label>
|
|
)}
|
|
</div>
|
|
|
|
{/* Submit */}
|
|
<Button
|
|
type="submit"
|
|
className="w-full"
|
|
disabled={isPublishing || isUploading}
|
|
>
|
|
{isPublishing ? (
|
|
<>
|
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
|
Saving...
|
|
</>
|
|
) : (
|
|
<>
|
|
<Calendar className="h-4 w-4 mr-2" />
|
|
Save Changes
|
|
</>
|
|
)}
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
</form>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|