Host tools: attendee list w/ trust, event updates, edit-alerts, Hosting tab
- AttendeeList: tabbed Going/Interested with faces + names + a "who you trust" signal (reuses batch trust scores); replaces the plain avatar stack - Event Updates (kind 7103): usePostEventUpdate + useEventUpdates; host-only composer with update/reschedule/relocation/cancelled categories that p-tags every RSVPer so they get notified; public feed on the event page - EditEventPage: auto-post a reschedule/relocation update to notify attendees when the host changes time or location - Activity bell: new 'event-update' notification type for RSVPers - EventsPage: "Hosting" tab (logged-in) with a HostingDashboard listing the user's upcoming/past events, live RSVP counts, and quick edit/update actions - Document Event Update kind 7103 in NIP.md Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
parent
7439b980bb
commit
78f4a6d09a
11 changed files with 819 additions and 31 deletions
26
NIP.md
26
NIP.md
|
|
@ -368,6 +368,32 @@ Attendance uses the standard **NIP-52 RSVP** (kind `31925`). A skater responds t
|
||||||
|
|
||||||
Clients query an event's RSVPs with `{ kinds: [31925], "#a": ["31923:<host>:<d>"] }` and keep the newest per pubkey. Hosts see incoming RSVPs in their notification center via `{ kinds: [31925], "#p": ["<host-pubkey>"] }`.
|
Clients query an event's RSVPs with `{ kinds: [31925], "#a": ["31923:<host>:<d>"] }` and keep the newest per pubkey. Hosts see incoming RSVPs in their notification center via `{ kinds: [31925], "#p": ["<host-pubkey>"] }`.
|
||||||
|
|
||||||
|
### Event Updates (Host broadcasts)
|
||||||
|
|
||||||
|
Hosts broadcast changes ("rain — moved to Sunday", "meet at the back ledge") with a **SkateSpots-native Event Update** (kind `7103`, regular event) pinned to a calendar event. Every current RSVPer is `p`-tagged so they get a notification; anyone viewing the event also sees the update thread.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"kind": 7103,
|
||||||
|
"content": "Rain — moved to Sunday 2pm. Meet at the back ledge.",
|
||||||
|
"tags": [
|
||||||
|
["a", "31923:<event-author-pubkey>:<event-d>", "", ""],
|
||||||
|
["e", "<event-id>"],
|
||||||
|
["update-kind", "reschedule"],
|
||||||
|
["t", "skateboarding"],
|
||||||
|
["p", "<rsvper-pubkey>"],
|
||||||
|
["p", "<rsvper-pubkey>"],
|
||||||
|
["alt", "Update to a skate event"]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `update-kind` is one of `update` (default), `reschedule`, `relocation`, or `cancelled` — used for iconography/emphasis.
|
||||||
|
- Only the event's own author should post updates (clients enforce this).
|
||||||
|
- When a host edits an event's **time or location**, the client auto-publishes a `reschedule`/`relocation` update to notify RSVPers.
|
||||||
|
|
||||||
|
Clients query an event's updates with `{ kinds: [7103], "#a": ["31923:<host>:<d>"] }`.
|
||||||
|
|
||||||
## Social Interaction
|
## Social Interaction
|
||||||
|
|
||||||
Comments and reactions to skate spots and events use standard Nostr kinds:
|
Comments and reactions to skate spots and events use standard Nostr kinds:
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { formatDistanceToNow } from 'date-fns';
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
import { Heart, Repeat2, Zap, AtSign, MessageCircle, CalendarCheck } from 'lucide-react';
|
import { Heart, Repeat2, Zap, AtSign, MessageCircle, CalendarCheck, Megaphone } from 'lucide-react';
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
import { useAuthor } from '@/hooks/useAuthor';
|
import { useAuthor } from '@/hooks/useAuthor';
|
||||||
import { genUserName } from '@/lib/genUserName';
|
import { genUserName } from '@/lib/genUserName';
|
||||||
|
|
@ -22,6 +22,7 @@ const TYPE_META: Record<
|
||||||
mention: { icon: AtSign, color: 'text-sky-400', ring: 'bg-sky-500/15' },
|
mention: { icon: AtSign, color: 'text-sky-400', ring: 'bg-sky-500/15' },
|
||||||
reply: { icon: MessageCircle, color: 'text-teal-400', ring: 'bg-teal-500/15' },
|
reply: { icon: MessageCircle, color: 'text-teal-400', ring: 'bg-teal-500/15' },
|
||||||
rsvp: { icon: CalendarCheck, color: 'text-emerald-400', ring: 'bg-emerald-500/15' },
|
rsvp: { icon: CalendarCheck, color: 'text-emerald-400', ring: 'bg-emerald-500/15' },
|
||||||
|
'event-update': { icon: Megaphone, color: 'text-[#ccff00]', ring: 'bg-[#ccff00]/15' },
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -58,7 +59,10 @@ export function ActivityNotificationCard({
|
||||||
});
|
});
|
||||||
|
|
||||||
const snippet =
|
const snippet =
|
||||||
(notification.type === 'reply' || notification.type === 'mention') && notification.content
|
(notification.type === 'reply' ||
|
||||||
|
notification.type === 'mention' ||
|
||||||
|
notification.type === 'event-update') &&
|
||||||
|
notification.content
|
||||||
? notification.content.slice(0, 120)
|
? notification.content.slice(0, 120)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
|
|
||||||
150
src/components/AttendeeList.tsx
Normal file
150
src/components/AttendeeList.tsx
Normal file
|
|
@ -0,0 +1,150 @@
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { nip19 } from 'nostr-tools';
|
||||||
|
import { ShieldCheck, Users } from 'lucide-react';
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
|
import { useAuthor } from '@/hooks/useAuthor';
|
||||||
|
import { useBatchTrustScores } from '@/hooks/useBatchTrustScores';
|
||||||
|
import { genUserName } from '@/lib/genUserName';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const TRUSTED_THRESHOLD = 60;
|
||||||
|
|
||||||
|
function AttendeeRow({ pubkey, trusted }: { pubkey: string; trusted: boolean }) {
|
||||||
|
const author = useAuthor(pubkey);
|
||||||
|
const name = author.data?.metadata?.name ?? genUserName(pubkey);
|
||||||
|
const pic = author.data?.metadata?.picture;
|
||||||
|
|
||||||
|
const npub = (() => {
|
||||||
|
try {
|
||||||
|
return nip19.npubEncode(pubkey);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
const inner = (
|
||||||
|
<div className="flex items-center gap-3 rounded-lg px-2 py-1.5 transition-colors hover:bg-white/5">
|
||||||
|
<Avatar className="h-8 w-8">
|
||||||
|
<AvatarImage src={pic} alt={name} />
|
||||||
|
<AvatarFallback className="text-[10px] font-bold">
|
||||||
|
{name.slice(0, 2).toUpperCase()}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<span className="min-w-0 flex-1 truncate text-sm font-medium text-gray-200">{name}</span>
|
||||||
|
{trusted && (
|
||||||
|
<span
|
||||||
|
className="flex items-center gap-1 rounded-full bg-[#ccff00]/15 px-2 py-0.5 text-[10px] font-black uppercase tracking-wide text-[#ccff00]"
|
||||||
|
title="Someone you follow or a trusted mapper"
|
||||||
|
>
|
||||||
|
<ShieldCheck className="h-3 w-3" />
|
||||||
|
Trusted
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!npub) return inner;
|
||||||
|
return <Link to={`/${npub}`}>{inner}</Link>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full attendee list for a host: tabbed Going / Interested, each row showing a
|
||||||
|
* face + name, plus a "trust signal" summarizing how many of the Going crowd
|
||||||
|
* are people the viewer follows or trusted mappers (skate events live or die on
|
||||||
|
* whether real locals show up).
|
||||||
|
*/
|
||||||
|
export function AttendeeList({
|
||||||
|
going,
|
||||||
|
tentative,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
going: string[];
|
||||||
|
tentative: string[];
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const [tab, setTab] = useState<'going' | 'tentative'>('going');
|
||||||
|
|
||||||
|
const allPubkeys = useMemo(() => [...new Set([...going, ...tentative])], [going, tentative]);
|
||||||
|
const { data: trustScores } = useBatchTrustScores(allPubkeys);
|
||||||
|
|
||||||
|
const trustedGoing = useMemo(
|
||||||
|
() => going.filter((pk) => (trustScores?.get(pk) ?? 0) >= TRUSTED_THRESHOLD).length,
|
||||||
|
[going, trustScores],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (going.length === 0 && tentative.length === 0) {
|
||||||
|
return (
|
||||||
|
<p className={cn('text-sm text-muted-foreground', className)}>
|
||||||
|
No RSVPs yet — be the first to say you're going.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = tab === 'going' ? going : tentative;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('space-y-3', className)}>
|
||||||
|
{/* Trust signal */}
|
||||||
|
{going.length > 0 && (
|
||||||
|
<div className="flex items-center gap-2 rounded-lg border border-[#ccff00]/20 bg-[#ccff00]/5 px-3 py-2">
|
||||||
|
<Users className="h-4 w-4 flex-shrink-0 text-[#ccff00]" />
|
||||||
|
<p className="text-xs text-gray-300">
|
||||||
|
<span className="font-black text-white">{going.length}</span> going
|
||||||
|
{trustedGoing > 0 && (
|
||||||
|
<>
|
||||||
|
{' · '}
|
||||||
|
<span className="font-black text-[#ccff00]">{trustedGoing}</span> you trust
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTab('going')}
|
||||||
|
className={cn(
|
||||||
|
'flex-1 rounded-lg py-1.5 text-xs font-black uppercase tracking-wide transition-all',
|
||||||
|
tab === 'going'
|
||||||
|
? 'bg-green-500 text-black'
|
||||||
|
: 'bg-white/5 text-gray-400 hover:text-white',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Going ({going.length})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTab('tentative')}
|
||||||
|
className={cn(
|
||||||
|
'flex-1 rounded-lg py-1.5 text-xs font-black uppercase tracking-wide transition-all',
|
||||||
|
tab === 'tentative'
|
||||||
|
? 'bg-[#ccff00] text-black'
|
||||||
|
: 'bg-white/5 text-gray-400 hover:text-white',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Interested ({tentative.length})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* List */}
|
||||||
|
{list.length > 0 ? (
|
||||||
|
<div className="max-h-72 space-y-0.5 overflow-y-auto pr-1">
|
||||||
|
{list.map((pk) => (
|
||||||
|
<AttendeeRow
|
||||||
|
key={pk}
|
||||||
|
pubkey={pk}
|
||||||
|
trusted={(trustScores?.get(pk) ?? 0) >= TRUSTED_THRESHOLD}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="py-4 text-center text-sm text-muted-foreground">
|
||||||
|
{tab === 'going' ? 'Nobody has confirmed yet.' : 'Nobody marked interested yet.'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
201
src/components/EventUpdates.tsx
Normal file
201
src/components/EventUpdates.tsx
Normal file
|
|
@ -0,0 +1,201 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
|
import { Megaphone, Loader2, Send, CalendarClock, MapPin, XCircle, Info } from 'lucide-react';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
|
import { useAuthor } from '@/hooks/useAuthor';
|
||||||
|
import { useToast } from '@/hooks/useToast';
|
||||||
|
import { useEventUpdates, usePostEventUpdate } from '@/hooks/useEventUpdates';
|
||||||
|
import { useEventRsvps, eventCoord } from '@/hooks/useEventRsvps';
|
||||||
|
import { genUserName } from '@/lib/genUserName';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import type { EventUpdate, EventUpdateKind, SkateEvent } from '@/lib/types';
|
||||||
|
|
||||||
|
const UPDATE_KIND_META: Record<
|
||||||
|
EventUpdateKind,
|
||||||
|
{ icon: typeof Info; label: string; color: string; ring: string }
|
||||||
|
> = {
|
||||||
|
update: { icon: Info, label: 'Update', color: 'text-sky-400', ring: 'bg-sky-500/15' },
|
||||||
|
reschedule: { icon: CalendarClock, label: 'New time', color: 'text-amber-400', ring: 'bg-amber-500/15' },
|
||||||
|
relocation: { icon: MapPin, label: 'New spot', color: 'text-[#ff0040]', ring: 'bg-[#ff0040]/15' },
|
||||||
|
cancelled: { icon: XCircle, label: 'Cancelled', color: 'text-red-400', ring: 'bg-red-500/15' },
|
||||||
|
};
|
||||||
|
|
||||||
|
function UpdateItem({ update }: { update: EventUpdate }) {
|
||||||
|
const author = useAuthor(update.pubkey);
|
||||||
|
const name = author.data?.metadata?.name ?? genUserName(update.pubkey);
|
||||||
|
const pic = author.data?.metadata?.picture;
|
||||||
|
const meta = UPDATE_KIND_META[update.updateKind];
|
||||||
|
const Icon = meta.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<div className="relative flex-shrink-0">
|
||||||
|
<Avatar className="h-8 w-8">
|
||||||
|
<AvatarImage src={pic} alt={name} />
|
||||||
|
<AvatarFallback className="text-[10px] font-bold">
|
||||||
|
{name.slice(0, 2).toUpperCase()}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'absolute -bottom-1 -right-1 flex h-4 w-4 items-center justify-center rounded-full ring-2 ring-background',
|
||||||
|
meta.ring,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className={cn('h-2.5 w-2.5', meta.color)} />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-bold text-white">{name}</span>
|
||||||
|
{update.updateKind !== 'update' && (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'rounded-full px-1.5 py-0.5 text-[9px] font-black uppercase tracking-wide',
|
||||||
|
meta.ring,
|
||||||
|
meta.color,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{meta.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-[11px] text-gray-600">
|
||||||
|
{formatDistanceToNow(new Date(update.createdAt * 1000), { addSuffix: true })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-0.5 whitespace-pre-wrap break-words text-sm text-gray-300">
|
||||||
|
{update.message}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Host update composer (only for the event author) + a public feed of updates.
|
||||||
|
* Posting p-tags every RSVPer so they get a notification.
|
||||||
|
*/
|
||||||
|
export function EventUpdates({ event }: { event: SkateEvent }) {
|
||||||
|
const { user } = useCurrentUser();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const coord = eventCoord(event);
|
||||||
|
const { data: updates, isLoading } = useEventUpdates(coord);
|
||||||
|
const { going, tentative } = useEventRsvps(coord);
|
||||||
|
const { mutateAsync: post, isPending } = usePostEventUpdate();
|
||||||
|
|
||||||
|
const [message, setMessage] = useState('');
|
||||||
|
const [kind, setKind] = useState<EventUpdateKind>('update');
|
||||||
|
|
||||||
|
const isHost = user?.pubkey === event.pubkey;
|
||||||
|
|
||||||
|
const handlePost = async () => {
|
||||||
|
const text = message.trim();
|
||||||
|
if (!text) return;
|
||||||
|
try {
|
||||||
|
await post({
|
||||||
|
event,
|
||||||
|
message: text,
|
||||||
|
updateKind: kind,
|
||||||
|
notifyPubkeys: [...going, ...tentative],
|
||||||
|
});
|
||||||
|
setMessage('');
|
||||||
|
setKind('update');
|
||||||
|
const reach = new Set([...going, ...tentative]).size;
|
||||||
|
toast({
|
||||||
|
title: 'Update posted',
|
||||||
|
description: reach > 0 ? `${reach} ${reach === 1 ? 'person' : 'people'} notified.` : 'Everyone will see it on the event.',
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
toast({
|
||||||
|
title: 'Could not post update',
|
||||||
|
description: err instanceof Error ? err.message : 'Please try again.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasUpdates = (updates?.length ?? 0) > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Composer — host only */}
|
||||||
|
{isHost && (
|
||||||
|
<div className="rounded-xl border border-[#ccff00]/25 bg-[#ccff00]/[0.04] p-3">
|
||||||
|
<div className="mb-2 flex items-center gap-2 text-sm font-black uppercase tracking-wide text-[#ccff00]">
|
||||||
|
<Megaphone className="h-4 w-4" />
|
||||||
|
Post an update
|
||||||
|
</div>
|
||||||
|
<div className="mb-2 flex flex-wrap gap-1.5">
|
||||||
|
{(Object.keys(UPDATE_KIND_META) as EventUpdateKind[]).map((k) => {
|
||||||
|
const m = UPDATE_KIND_META[k];
|
||||||
|
const Icon = m.icon;
|
||||||
|
const active = kind === k;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={k}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setKind(k)}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-1 rounded-full border px-2.5 py-1 text-[10px] font-bold uppercase tracking-wide transition-all active:scale-95',
|
||||||
|
active
|
||||||
|
? cn('border-transparent', m.ring, m.color)
|
||||||
|
: 'border-white/10 bg-white/5 text-gray-500 hover:text-white',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="h-3 w-3" />
|
||||||
|
{m.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<Textarea
|
||||||
|
value={message}
|
||||||
|
onChange={(e) => setMessage(e.target.value)}
|
||||||
|
placeholder="Rain — moved to Sunday 2pm. Meet at the back ledge…"
|
||||||
|
rows={3}
|
||||||
|
maxLength={500}
|
||||||
|
className="resize-none border-white/10 bg-black/40 text-sm"
|
||||||
|
/>
|
||||||
|
<div className="mt-2 flex items-center justify-between">
|
||||||
|
<span className="text-[11px] text-gray-500">
|
||||||
|
{going.length + tentative.length > 0
|
||||||
|
? `Notifies ${new Set([...going, ...tentative]).size} RSVP${new Set([...going, ...tentative]).size === 1 ? '' : "'s"}`
|
||||||
|
: 'Visible to everyone on this event'}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={handlePost}
|
||||||
|
disabled={isPending || !message.trim()}
|
||||||
|
className="gap-1.5 bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90"
|
||||||
|
>
|
||||||
|
{isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Send className="h-3.5 w-3.5" />}
|
||||||
|
Post
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Feed */}
|
||||||
|
{isLoading && !hasUpdates ? (
|
||||||
|
<div className="flex items-center gap-2 py-4 text-sm text-gray-500">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" /> Loading updates…
|
||||||
|
</div>
|
||||||
|
) : hasUpdates ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{updates!.map((u) => (
|
||||||
|
<UpdateItem key={u.id} update={u} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
!isHost && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
No updates from the host yet. RSVP and you'll be notified if anything changes.
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
171
src/components/HostingDashboard.tsx
Normal file
171
src/components/HostingDashboard.tsx
Normal file
|
|
@ -0,0 +1,171 @@
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { Calendar, Users, Pencil, Megaphone, Plus, CalendarClock } from 'lucide-react';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
|
import { useSkateEvents } from '@/hooks/useSkateEvents';
|
||||||
|
import { useEventRsvps, eventCoord } from '@/hooks/useEventRsvps';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import type { SkateEvent } from '@/lib/types';
|
||||||
|
|
||||||
|
function formatWhen(ts: number): string {
|
||||||
|
return new Date(ts * 1000).toLocaleDateString(undefined, {
|
||||||
|
weekday: 'short',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A single hosted-event row with live RSVP counts + quick actions. */
|
||||||
|
function HostedEventRow({ event, isPast }: { event: SkateEvent; isPast: boolean }) {
|
||||||
|
const { goingCount, tentativeCount } = useEventRsvps(eventCoord(event));
|
||||||
|
const to = `/event/${event.pubkey}/${event.identifier}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className={cn('border-white/10 transition-colors hover:border-[#ccff00]/40', isPast && 'opacity-70')}>
|
||||||
|
<CardContent className="flex items-center gap-4 p-4">
|
||||||
|
{/* Date chip */}
|
||||||
|
<div className="flex-shrink-0 w-14 text-center">
|
||||||
|
<div className="rounded-lg border-2 border-[#ccff00]/40 bg-[#ccff00]/10 p-1.5">
|
||||||
|
<div className="text-[10px] font-black uppercase text-[#ccff00]">
|
||||||
|
{new Date(event.start * 1000).toLocaleDateString(undefined, { month: 'short' })}
|
||||||
|
</div>
|
||||||
|
<div className="text-xl font-black text-white leading-none">
|
||||||
|
{new Date(event.start * 1000).getDate()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info */}
|
||||||
|
<Link to={to} className="min-w-0 flex-1">
|
||||||
|
<h3 className="truncate font-bold uppercase tracking-wide text-white hover:text-[#ccff00]">
|
||||||
|
{event.title}
|
||||||
|
</h3>
|
||||||
|
<p className="flex items-center gap-1 text-xs text-gray-500">
|
||||||
|
<CalendarClock className="h-3 w-3" />
|
||||||
|
{formatWhen(event.start)}
|
||||||
|
</p>
|
||||||
|
<div className="mt-1 flex items-center gap-3 text-xs">
|
||||||
|
<span className="flex items-center gap-1 font-bold text-green-400">
|
||||||
|
<Users className="h-3 w-3" />
|
||||||
|
{goingCount} going
|
||||||
|
</span>
|
||||||
|
{tentativeCount > 0 && (
|
||||||
|
<span className="text-[#ccff00]">{tentativeCount} interested</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Quick actions */}
|
||||||
|
<div className="flex flex-shrink-0 items-center gap-1.5">
|
||||||
|
<Link to={to} title="Post update">
|
||||||
|
<Button size="icon" variant="ghost" className="h-8 w-8 text-gray-400 hover:text-[#ccff00]">
|
||||||
|
<Megaphone className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<Link to={`/edit-event/${event.pubkey}/${event.identifier}`} title="Edit">
|
||||||
|
<Button size="icon" variant="ghost" className="h-8 w-8 text-gray-400 hover:text-white">
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Hosting" dashboard — the events the logged-in user created, split into
|
||||||
|
* upcoming and past, each with live RSVP counts and quick edit/update actions.
|
||||||
|
* The host's home base.
|
||||||
|
*/
|
||||||
|
export function HostingDashboard() {
|
||||||
|
const { user } = useCurrentUser();
|
||||||
|
const { data: events, isLoading } = useSkateEvents(200);
|
||||||
|
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
|
const mine = useMemo(() => {
|
||||||
|
if (!user || !events) return { upcoming: [], past: [] };
|
||||||
|
const owned = events.filter((e) => e.pubkey === user.pubkey);
|
||||||
|
return {
|
||||||
|
upcoming: owned.filter((e) => e.start >= now).sort((a, b) => a.start - b.start),
|
||||||
|
past: owned.filter((e) => e.start < now).sort((a, b) => b.start - a.start),
|
||||||
|
};
|
||||||
|
}, [user, events, now]);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return (
|
||||||
|
<Card className="border-dashed">
|
||||||
|
<CardContent className="py-12 text-center">
|
||||||
|
<Calendar className="mx-auto mb-3 h-8 w-8 text-muted-foreground" />
|
||||||
|
<p className="text-sm text-muted-foreground">Log in to see the events you're hosting.</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{[0, 1, 2].map((i) => (
|
||||||
|
<Skeleton key={i} className="h-20 w-full rounded-xl" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = mine.upcoming.length + mine.past.length;
|
||||||
|
|
||||||
|
if (total === 0) {
|
||||||
|
return (
|
||||||
|
<Card className="border-dashed">
|
||||||
|
<CardContent className="py-12 text-center">
|
||||||
|
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl bg-[#ccff00]/10">
|
||||||
|
<Calendar className="h-7 w-7 text-[#ccff00]" />
|
||||||
|
</div>
|
||||||
|
<h3 className="mb-1 font-black uppercase tracking-wide text-white">You're not hosting anything yet</h3>
|
||||||
|
<p className="mx-auto mb-5 max-w-xs text-sm text-muted-foreground">
|
||||||
|
Run a jam, comp or session. Create an event and skaters can RSVP, get updates, and roll through.
|
||||||
|
</p>
|
||||||
|
<Link to="/create-event">
|
||||||
|
<Button className="gap-2 bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90">
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Create an event
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{mine.upcoming.length > 0 && (
|
||||||
|
<div className="space-y-2.5">
|
||||||
|
<h2 className="text-xs font-black uppercase tracking-widest text-[#ccff00]">
|
||||||
|
Upcoming · {mine.upcoming.length}
|
||||||
|
</h2>
|
||||||
|
{mine.upcoming.map((e) => (
|
||||||
|
<HostedEventRow key={e.id} event={e} isPast={false} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mine.past.length > 0 && (
|
||||||
|
<div className="space-y-2.5">
|
||||||
|
<h2 className="text-xs font-black uppercase tracking-widest text-gray-500">
|
||||||
|
Past · {mine.past.length}
|
||||||
|
</h2>
|
||||||
|
{mine.past.map((e) => (
|
||||||
|
<HostedEventRow key={e.id} event={e} isPast />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -16,7 +16,7 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
* load minimal (a single filter) per the project's query-efficiency rules.
|
* load minimal (a single filter) per the project's query-efficiency rules.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type ActivityType = 'reaction' | 'repost' | 'zap' | 'mention' | 'reply' | 'rsvp';
|
export type ActivityType = 'reaction' | 'repost' | 'zap' | 'mention' | 'reply' | 'rsvp' | 'event-update';
|
||||||
|
|
||||||
export interface ActivityNotification {
|
export interface ActivityNotification {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -42,6 +42,7 @@ const ZAP_RECEIPT_KIND = 9735;
|
||||||
const TEXT_NOTE_KIND = 1;
|
const TEXT_NOTE_KIND = 1;
|
||||||
const COMMENT_KIND = 1111;
|
const COMMENT_KIND = 1111;
|
||||||
const RSVP_KIND = 31925;
|
const RSVP_KIND = 31925;
|
||||||
|
const EVENT_UPDATE_KIND = 7103;
|
||||||
|
|
||||||
/** Parse the sats amount out of a zap receipt (9735) if we can. */
|
/** Parse the sats amount out of a zap receipt (9735) if we can. */
|
||||||
function parseZapAmount(event: NostrEvent): number | undefined {
|
function parseZapAmount(event: NostrEvent): number | undefined {
|
||||||
|
|
@ -106,6 +107,7 @@ export function useActivityNotifications() {
|
||||||
TEXT_NOTE_KIND,
|
TEXT_NOTE_KIND,
|
||||||
COMMENT_KIND,
|
COMMENT_KIND,
|
||||||
RSVP_KIND,
|
RSVP_KIND,
|
||||||
|
EVENT_UPDATE_KIND,
|
||||||
],
|
],
|
||||||
'#p': [pubkey],
|
'#p': [pubkey],
|
||||||
limit: 120,
|
limit: 120,
|
||||||
|
|
@ -189,6 +191,17 @@ export function useActivityNotifications() {
|
||||||
targetEventId,
|
targetEventId,
|
||||||
rsvpStatus: status,
|
rsvpStatus: status,
|
||||||
});
|
});
|
||||||
|
} else if (event.kind === EVENT_UPDATE_KIND) {
|
||||||
|
// A host posted an update to an event I RSVP'd to (I'm p-tagged).
|
||||||
|
out.push({
|
||||||
|
id: event.id,
|
||||||
|
type: 'event-update',
|
||||||
|
pubkey: event.pubkey,
|
||||||
|
createdAt: event.created_at,
|
||||||
|
event,
|
||||||
|
content: event.content,
|
||||||
|
targetEventId,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -223,6 +236,8 @@ export function activityVerb(n: ActivityNotification): string {
|
||||||
: n.rsvpStatus === 'tentative'
|
: n.rsvpStatus === 'tentative'
|
||||||
? 'is interested in your event'
|
? 'is interested in your event'
|
||||||
: "can't make your event";
|
: "can't make your event";
|
||||||
|
case 'event-update':
|
||||||
|
return 'posted an event update';
|
||||||
default:
|
default:
|
||||||
return 'interacted with you';
|
return 'interacted with you';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
97
src/hooks/useEventUpdates.ts
Normal file
97
src/hooks/useEventUpdates.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
|
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||||
|
import { useSkateRelays } from '@/hooks/useSkateRelays';
|
||||||
|
import {
|
||||||
|
EVENT_UPDATE_KIND,
|
||||||
|
parseEventUpdate,
|
||||||
|
type EventUpdate,
|
||||||
|
type EventUpdateKind,
|
||||||
|
type SkateEvent,
|
||||||
|
} from '@/lib/types';
|
||||||
|
import { eventCoord } from '@/hooks/useEventRsvps';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Host "Event Updates" (kind 7103) — a public broadcast pinned to a calendar
|
||||||
|
* event. Hosts use it to say "rain, moved to Sunday" / "meet at the back ledge".
|
||||||
|
* Everyone who RSVP'd is p-tagged so they get a notification; anyone browsing
|
||||||
|
* the event page also sees the update thread.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function useEventUpdates(coord: string | undefined) {
|
||||||
|
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
||||||
|
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['event-updates', coord, getQueryKeySuffix()],
|
||||||
|
enabled: !!coord,
|
||||||
|
staleTime: 30_000,
|
||||||
|
queryFn: async (c) => {
|
||||||
|
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(6000)]);
|
||||||
|
const events = await querySkateContent(
|
||||||
|
[{ kinds: [EVENT_UPDATE_KIND], '#a': [coord!], limit: 100 }],
|
||||||
|
{ signal },
|
||||||
|
);
|
||||||
|
|
||||||
|
const updates: EventUpdate[] = [];
|
||||||
|
for (const ev of events) {
|
||||||
|
const u = parseEventUpdate(ev);
|
||||||
|
if (u && u.eventCoord === coord) updates.push(u);
|
||||||
|
}
|
||||||
|
// Newest first.
|
||||||
|
return updates.sort((a, b) => b.createdAt - a.createdAt);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publish a host update. Only the event's own author should call this (the UI
|
||||||
|
* gates it). All `notifyPubkeys` (RSVPers) are p-tagged so they get pinged.
|
||||||
|
*/
|
||||||
|
export function usePostEventUpdate() {
|
||||||
|
const { mutateAsync: publish, isPending } = useNostrPublish();
|
||||||
|
const { user } = useCurrentUser();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: async (opts: {
|
||||||
|
event: SkateEvent;
|
||||||
|
message: string;
|
||||||
|
updateKind?: EventUpdateKind;
|
||||||
|
/** RSVPer pubkeys to notify (deduped, minus the host). */
|
||||||
|
notifyPubkeys?: string[];
|
||||||
|
}) => {
|
||||||
|
if (!user) throw new Error('You must be logged in to post an update.');
|
||||||
|
if (user.pubkey !== opts.event.pubkey) {
|
||||||
|
throw new Error('Only the event host can post updates.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const coord = eventCoord(opts.event);
|
||||||
|
const kind = opts.updateKind ?? 'update';
|
||||||
|
|
||||||
|
const notify = Array.from(
|
||||||
|
new Set((opts.notifyPubkeys ?? []).filter((pk) => pk && pk !== user.pubkey)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const tags: string[][] = [
|
||||||
|
['a', coord, '', ''],
|
||||||
|
['e', opts.event.id],
|
||||||
|
['update-kind', kind],
|
||||||
|
['t', 'skateboarding'],
|
||||||
|
['alt', `Update to a skate event: ${opts.event.title}`],
|
||||||
|
...notify.map((pk) => ['p', pk]),
|
||||||
|
];
|
||||||
|
|
||||||
|
return publish({
|
||||||
|
kind: EVENT_UPDATE_KIND,
|
||||||
|
content: opts.message.trim(),
|
||||||
|
tags,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: (_data, variables) => {
|
||||||
|
const coord = eventCoord(variables.event);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['event-updates', coord] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { ...mutation, isPending: isPending || mutation.isPending };
|
||||||
|
}
|
||||||
|
|
@ -47,6 +47,7 @@ export const SKATE_SPOT_KIND = 30818; // Addressable event - editable per pubkey
|
||||||
export const SKATE_SHOP_KIND = 30017; // NIP-15 Stall (marketplace)
|
export const SKATE_SHOP_KIND = 30017; // NIP-15 Stall (marketplace)
|
||||||
export const CALENDAR_EVENT_KIND = 31923;
|
export const CALENDAR_EVENT_KIND = 31923;
|
||||||
export const CALENDAR_RSVP_KIND = 31925; // NIP-52 Calendar Event RSVP (accepted/tentative/declined)
|
export const CALENDAR_RSVP_KIND = 31925; // NIP-52 Calendar Event RSVP (accepted/tentative/declined)
|
||||||
|
export const EVENT_UPDATE_KIND = 7103; // Regular event - host broadcast/update pinned to a calendar event. SkateSpots-native.
|
||||||
export const SKATE_SKATER_KIND = 35000; // Skater profile — the app-native "I skate here" identity
|
export const SKATE_SKATER_KIND = 35000; // Skater profile — the app-native "I skate here" identity
|
||||||
export const SKATE_COACH_KIND = 35001;
|
export const SKATE_COACH_KIND = 35001;
|
||||||
export const SKATE_FILMER_KIND = 35002; // Skate filmer (video) profiles
|
export const SKATE_FILMER_KIND = 35002; // Skate filmer (video) profiles
|
||||||
|
|
@ -891,6 +892,55 @@ export function parseEventRsvp(event: NostrEvent): EventRsvp | null {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Event Update (kind 7103) — host broadcast pinned to a calendar event ────
|
||||||
|
|
||||||
|
export type EventUpdateKind = 'update' | 'reschedule' | 'relocation' | 'cancelled';
|
||||||
|
|
||||||
|
export interface EventUpdate {
|
||||||
|
id: string;
|
||||||
|
pubkey: string;
|
||||||
|
/** Coordinate of the calendar event ("31923:pubkey:d"). */
|
||||||
|
eventCoord: string;
|
||||||
|
/** The calendar event's raw id, if referenced. */
|
||||||
|
eventId?: string;
|
||||||
|
message: string;
|
||||||
|
/** Category of update, for iconography / emphasis. */
|
||||||
|
updateKind: EventUpdateKind;
|
||||||
|
createdAt: number;
|
||||||
|
event: NostrEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a host Event Update (kind 7103). Requires an `a` coordinate to a
|
||||||
|
* calendar event and non-empty content.
|
||||||
|
*/
|
||||||
|
export function parseEventUpdate(event: NostrEvent): EventUpdate | null {
|
||||||
|
if (event.kind !== EVENT_UPDATE_KIND) return null;
|
||||||
|
|
||||||
|
const eventCoord = event.tags.find(([name]) => name === 'a')?.[1];
|
||||||
|
if (!eventCoord || !eventCoord.startsWith(`${CALENDAR_EVENT_KIND}:`)) return null;
|
||||||
|
|
||||||
|
const message = event.content?.trim();
|
||||||
|
if (!message) return null;
|
||||||
|
|
||||||
|
const rawKind = event.tags.find(([name]) => name === 'update-kind')?.[1];
|
||||||
|
const updateKind: EventUpdateKind =
|
||||||
|
rawKind === 'reschedule' || rawKind === 'relocation' || rawKind === 'cancelled'
|
||||||
|
? rawKind
|
||||||
|
: 'update';
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: event.id,
|
||||||
|
pubkey: event.pubkey,
|
||||||
|
eventCoord,
|
||||||
|
eventId: event.tags.find(([name]) => name === 'e')?.[1],
|
||||||
|
message: message.slice(0, MAX_DESCRIPTION_LEN),
|
||||||
|
updateKind,
|
||||||
|
createdAt: event.created_at,
|
||||||
|
event,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Main spot categories (mutually exclusive - user picks ONE)
|
// Main spot categories (mutually exclusive - user picks ONE)
|
||||||
export const SPOT_CATEGORIES = {
|
export const SPOT_CATEGORIES = {
|
||||||
street: { label: 'Street', emoji: '🛣️', description: 'Random found spots, urban terrain' },
|
street: { label: 'Street', emoji: '🛣️', description: 'Random found spots, urban terrain' },
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,10 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
import { useUploadFile } from '@/hooks/useUploadFile';
|
import { useUploadFile } from '@/hooks/useUploadFile';
|
||||||
import { useToast } from '@/hooks/useToast';
|
import { useToast } from '@/hooks/useToast';
|
||||||
import { useSkateEvent } from '@/hooks/useSkateEvents';
|
import { useSkateEvent } from '@/hooks/useSkateEvents';
|
||||||
|
import { useEventRsvps, eventCoord } from '@/hooks/useEventRsvps';
|
||||||
|
import { usePostEventUpdate } from '@/hooks/useEventUpdates';
|
||||||
import { LoginArea } from '@/components/auth/LoginArea';
|
import { LoginArea } from '@/components/auth/LoginArea';
|
||||||
import { CALENDAR_EVENT_KIND, EVENT_TYPE_LABELS } from '@/lib/types';
|
import { CALENDAR_EVENT_KIND, EVENT_TYPE_LABELS, type EventUpdateKind } from '@/lib/types';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import {
|
import {
|
||||||
Calendar,
|
Calendar,
|
||||||
|
|
@ -78,6 +80,10 @@ export default function EditEventPage() {
|
||||||
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
|
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
|
||||||
const { toast } = useToast();
|
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
|
// Form state
|
||||||
const [title, setTitle] = useState('');
|
const [title, setTitle] = useState('');
|
||||||
const [description, setDescription] = useState('');
|
const [description, setDescription] = useState('');
|
||||||
|
|
@ -283,15 +289,49 @@ export default function EditEventPage() {
|
||||||
tags.push(['image', image]);
|
tags.push(['image', image]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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({
|
await createEvent({
|
||||||
kind: CALENDAR_EVENT_KIND,
|
kind: CALENDAR_EVENT_KIND,
|
||||||
content: description.trim(),
|
content: description.trim(),
|
||||||
tags,
|
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({
|
toast({
|
||||||
title: 'Event updated!',
|
title: 'Event updated!',
|
||||||
description: 'Your changes have been published to the network.',
|
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}`);
|
navigate(`/event/${user?.pubkey}/${event.identifier}`);
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,8 @@ import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { ZapButton } from '@/components/ZapButton';
|
import { ZapButton } from '@/components/ZapButton';
|
||||||
import { RsvpButtons } from '@/components/RsvpButtons';
|
import { RsvpButtons } from '@/components/RsvpButtons';
|
||||||
import { AttendeeStack } from '@/components/AttendeeStack';
|
import { AttendeeList } from '@/components/AttendeeList';
|
||||||
|
import { EventUpdates } from '@/components/EventUpdates';
|
||||||
import { CommentsSection } from '@/components/comments/CommentsSection';
|
import { CommentsSection } from '@/components/comments/CommentsSection';
|
||||||
import { DeleteEntityDialog } from '@/components/DeleteEntityDialog';
|
import { DeleteEntityDialog } from '@/components/DeleteEntityDialog';
|
||||||
import { useSkateEvent } from '@/hooks/useSkateEvents';
|
import { useSkateEvent } from '@/hooks/useSkateEvents';
|
||||||
|
|
@ -32,6 +33,7 @@ import {
|
||||||
CalendarPlus,
|
CalendarPlus,
|
||||||
Pencil,
|
Pencil,
|
||||||
Music,
|
Music,
|
||||||
|
Megaphone,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
|
|
||||||
|
|
@ -333,6 +335,19 @@ export default function EventDetailPage() {
|
||||||
</Card>
|
</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 */}
|
{/* Comments Section */}
|
||||||
{event.event && <CommentsSection root={event.event} />}
|
{event.event && <CommentsSection root={event.event} />}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -378,20 +393,8 @@ export default function EventDetailPage() {
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
{!isPast && <RsvpButtons event={event} />}
|
{!isPast && <RsvpButtons event={event} />}
|
||||||
|
|
||||||
{rsvpSummary.goingCount > 0 ? (
|
{(rsvpSummary.goingCount > 0 || rsvpSummary.tentativeCount > 0) ? (
|
||||||
<div>
|
<AttendeeList going={rsvpSummary.going} tentative={rsvpSummary.tentative} />
|
||||||
<div className="mb-2 flex items-center justify-between">
|
|
||||||
<span className="text-xs font-black uppercase tracking-widest text-muted-foreground">
|
|
||||||
Going · {rsvpSummary.goingCount}
|
|
||||||
</span>
|
|
||||||
{rsvpSummary.tentativeCount > 0 && (
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{rsvpSummary.tentativeCount} interested
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<AttendeeStack pubkeys={rsvpSummary.going} max={8} />
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
!rsvpSummary.isLoading &&
|
!rsvpSummary.isLoading &&
|
||||||
isPast && (
|
isPast && (
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { Link } from 'react-router-dom';
|
||||||
import { Layout } from '@/components/Layout';
|
import { Layout } from '@/components/Layout';
|
||||||
import { EventCard, EventCardSkeleton } from '@/components/EventCard';
|
import { EventCard, EventCardSkeleton } from '@/components/EventCard';
|
||||||
import { TourCard, TourCardSkeleton } from '@/components/TourCard';
|
import { TourCard, TourCardSkeleton } from '@/components/TourCard';
|
||||||
|
import { HostingDashboard } from '@/components/HostingDashboard';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
|
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
|
||||||
import { type TrustFilterLevel, filterByTrustLevel } from '@/components/TrustFilter';
|
import { type TrustFilterLevel, filterByTrustLevel } from '@/components/TrustFilter';
|
||||||
|
|
@ -36,6 +37,7 @@ import {
|
||||||
Users,
|
Users,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
|
Megaphone,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
|
@ -60,9 +62,18 @@ const ACCENT = {
|
||||||
badge: 'bg-cyan-500/15 text-cyan-400 border-cyan-500/30',
|
badge: 'bg-cyan-500/15 text-cyan-400 border-cyan-500/30',
|
||||||
stripe: 'bg-cyan-500',
|
stripe: 'bg-cyan-500',
|
||||||
},
|
},
|
||||||
|
hosting: {
|
||||||
|
active: 'bg-orange-500 text-white border-orange-500',
|
||||||
|
chip: 'bg-orange-500 text-white border-orange-500',
|
||||||
|
chipHover: 'hover:border-orange-500/60 hover:text-orange-400',
|
||||||
|
border: 'border-orange-500/40',
|
||||||
|
icon: 'text-orange-400',
|
||||||
|
badge: 'bg-orange-500/15 text-orange-400 border-orange-500/30',
|
||||||
|
stripe: 'bg-orange-500',
|
||||||
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
type MainView = 'events' | 'tours';
|
type MainView = 'events' | 'tours' | 'hosting';
|
||||||
|
|
||||||
// ─── Date window filter (skaters plan by weekend) ─────────────────────────────
|
// ─── Date window filter (skaters plan by weekend) ─────────────────────────────
|
||||||
|
|
||||||
|
|
@ -172,6 +183,9 @@ interface ToolbarProps {
|
||||||
mainView: MainView;
|
mainView: MainView;
|
||||||
onViewChange: (v: MainView) => void;
|
onViewChange: (v: MainView) => void;
|
||||||
counts: { events: number; tours: number };
|
counts: { events: number; tours: number };
|
||||||
|
/** Show the "Hosting" tab (logged-in users only). */
|
||||||
|
showHosting: boolean;
|
||||||
|
hostingCount: number;
|
||||||
// Events
|
// Events
|
||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
onSearchChange: (q: string) => void;
|
onSearchChange: (q: string) => void;
|
||||||
|
|
@ -198,6 +212,8 @@ function EventsToolbar({
|
||||||
mainView,
|
mainView,
|
||||||
onViewChange,
|
onViewChange,
|
||||||
counts,
|
counts,
|
||||||
|
showHosting,
|
||||||
|
hostingCount,
|
||||||
searchQuery,
|
searchQuery,
|
||||||
onSearchChange,
|
onSearchChange,
|
||||||
locationQuery,
|
locationQuery,
|
||||||
|
|
@ -221,14 +237,14 @@ function EventsToolbar({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="sticky top-14 md:top-16 z-30 bg-black/95 backdrop-blur border-b border-white/5 -mx-4 px-4 py-3 space-y-2.5">
|
<div className="sticky top-14 md:top-16 z-30 bg-black/95 backdrop-blur border-b border-white/5 -mx-4 px-4 py-3 space-y-2.5">
|
||||||
{/* Main view tabs: Events / Tours */}
|
{/* Main view tabs: Events / Tours / Hosting */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{(['events', 'tours'] as MainView[]).map((view) => {
|
{(['events', 'tours', ...(showHosting ? ['hosting'] as const : [])] as MainView[]).map((view) => {
|
||||||
const isActive = mainView === view;
|
const isActive = mainView === view;
|
||||||
const a = ACCENT[view];
|
const a = ACCENT[view];
|
||||||
const Icon = view === 'events' ? Calendar : Route;
|
const Icon = view === 'events' ? Calendar : view === 'tours' ? Route : Megaphone;
|
||||||
const label = view === 'events' ? 'Events' : 'Tours';
|
const label = view === 'events' ? 'Events' : view === 'tours' ? 'Tours' : 'Hosting';
|
||||||
const count = counts[view];
|
const count = view === 'hosting' ? hostingCount : counts[view as 'events' | 'tours'];
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={view}
|
key={view}
|
||||||
|
|
@ -291,7 +307,8 @@ function EventsToolbar({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Search + Location + Filter row */}
|
{/* Search + Location + Filter row (not shown in Hosting) */}
|
||||||
|
{mainView !== 'hosting' && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{/* Search */}
|
{/* Search */}
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
|
|
@ -346,9 +363,10 @@ function EventsToolbar({
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Active filter chips — only shown when something is selected */}
|
{/* Active filter chips — only shown when something is selected */}
|
||||||
{hasActiveChips && (
|
{mainView !== 'hosting' && hasActiveChips && (
|
||||||
<div className="flex items-center gap-1.5 flex-wrap">
|
<div className="flex items-center gap-1.5 flex-wrap">
|
||||||
{selectedTypes.map((type) => (
|
{selectedTypes.map((type) => (
|
||||||
<button
|
<button
|
||||||
|
|
@ -385,7 +403,7 @@ function EventsToolbar({
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Results count */}
|
{/* Results count */}
|
||||||
{!isLoading && (
|
{mainView !== 'hosting' && !isLoading && (
|
||||||
<p className="text-[11px] text-gray-500 font-semibold leading-none">
|
<p className="text-[11px] text-gray-500 font-semibold leading-none">
|
||||||
{totalCount === 0 ? 'No results' : `${totalCount} result${totalCount !== 1 ? 's' : ''}`}
|
{totalCount === 0 ? 'No results' : `${totalCount} result${totalCount !== 1 ? 's' : ''}`}
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -819,6 +837,12 @@ export default function EventsPage() {
|
||||||
tours: tours?.length ?? 0,
|
tours: tours?.length ?? 0,
|
||||||
}), [events, tours]);
|
}), [events, tours]);
|
||||||
|
|
||||||
|
// Events the logged-in user is hosting (for the Hosting tab badge).
|
||||||
|
const hostingCount = useMemo(
|
||||||
|
() => (user && events ? events.filter((e) => e.pubkey === user.pubkey).length : 0),
|
||||||
|
[user, events],
|
||||||
|
);
|
||||||
|
|
||||||
// ── Filter events ─────────────────────────────────────────────────────────────
|
// ── Filter events ─────────────────────────────────────────────────────────────
|
||||||
const filteredEvents = useMemo(() => {
|
const filteredEvents = useMemo(() => {
|
||||||
if (!events) return { upcoming: [], past: [] };
|
if (!events) return { upcoming: [], past: [] };
|
||||||
|
|
@ -999,13 +1023,15 @@ export default function EventsPage() {
|
||||||
<h1 className="text-4xl md:text-5xl font-black uppercase leading-none mb-1">
|
<h1 className="text-4xl md:text-5xl font-black uppercase leading-none mb-1">
|
||||||
<span className="neon-lime">SKATE</span>{' '}
|
<span className="neon-lime">SKATE</span>{' '}
|
||||||
<span className="text-white">
|
<span className="text-white">
|
||||||
{mainView === 'events' ? 'EVENTS' : 'TOURS'}
|
{mainView === 'events' ? 'EVENTS' : mainView === 'tours' ? 'TOURS' : 'HOSTING'}
|
||||||
</span>
|
</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-gray-500 text-sm">
|
<p className="text-gray-500 text-sm">
|
||||||
{mainView === 'events'
|
{mainView === 'events'
|
||||||
? t('events.findEvents')
|
? t('events.findEvents')
|
||||||
: 'Local guides, road trips & world destruction competitions'}
|
: mainView === 'tours'
|
||||||
|
? 'Local guides, road trips & world destruction competitions'
|
||||||
|
: 'Your events — RSVPs, updates & quick edits'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -1023,6 +1049,8 @@ export default function EventsPage() {
|
||||||
mainView={mainView}
|
mainView={mainView}
|
||||||
onViewChange={handleViewChange}
|
onViewChange={handleViewChange}
|
||||||
counts={rawCounts}
|
counts={rawCounts}
|
||||||
|
showHosting={!!user}
|
||||||
|
hostingCount={hostingCount}
|
||||||
searchQuery={currentSearchQuery}
|
searchQuery={currentSearchQuery}
|
||||||
onSearchChange={handleSearchChange}
|
onSearchChange={handleSearchChange}
|
||||||
locationQuery={currentLocationQuery}
|
locationQuery={currentLocationQuery}
|
||||||
|
|
@ -1070,6 +1098,9 @@ export default function EventsPage() {
|
||||||
{/* ── Results ── */}
|
{/* ── Results ── */}
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
|
|
||||||
|
{/* Hosting dashboard */}
|
||||||
|
{mainView === 'hosting' && <HostingDashboard />}
|
||||||
|
|
||||||
{/* Events */}
|
{/* Events */}
|
||||||
{mainView === 'events' && (
|
{mainView === 'events' && (
|
||||||
eventsLoading ? (
|
eventsLoading ? (
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue