- New src/lib/dateFnsI18n.ts re-exports date-fns with formatDistanceToNow /
formatDistanceToNowStrict / formatDistance / format defaulting to the active
locale, plus dateLocaleCode() for native toLocale{Date,Time}String calls.
- LanguageProvider keeps the locale in sync during render, so the first paint is
already in-language.
- Codemod: 11 date-fns imports now use the shim; ~39 native date/time calls pass
the app locale code instead of undefined/[]/hardcoded 'en-US'. Number
.toLocaleString() calls are deliberately untouched.
235 lines
8.8 KiB
TypeScript
235 lines
8.8 KiB
TypeScript
import { useState } from 'react';
|
|
import { formatDistanceToNow } from '@/lib/dateFnsI18n';
|
|
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 { isAdminPubkey } from '@/lib/admins';
|
|
import { cn } from '@/lib/utils';
|
|
import { useTranslation } from '@/contexts/language';
|
|
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: 'placesUi.updates.kindUpdate', color: 'text-sky-400', ring: 'bg-sky-500/15' },
|
|
reschedule: { icon: CalendarClock, label: 'placesUi.updates.kindNewTime', color: 'text-amber-400', ring: 'bg-amber-500/15' },
|
|
relocation: { icon: MapPin, label: 'placesUi.updates.kindNewSpot', color: 'text-[#ff0040]', ring: 'bg-[#ff0040]/15' },
|
|
cancelled: { icon: XCircle, label: 'placesUi.updates.kindCancelled', color: 'text-red-400', ring: 'bg-red-500/15' },
|
|
};
|
|
|
|
function UpdateItem({ update }: { update: EventUpdate }) {
|
|
const { t } = useTranslation();
|
|
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.authorRole === 'admin' && (
|
|
<span className="rounded-full border border-yellow-500/40 bg-yellow-500/15 px-1.5 py-0.5 text-[9px] font-black uppercase tracking-wide text-yellow-400">
|
|
{t('placesUi.updates.admin')}
|
|
</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,
|
|
)}
|
|
>
|
|
{t(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 { t } = useTranslation();
|
|
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 isAdmin = isAdminPubkey(user?.pubkey);
|
|
// Admins can moderate any event; the host runs their own.
|
|
const canPost = isHost || isAdmin;
|
|
|
|
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: t('placesUi.updates.posted'),
|
|
description: reach > 0 ? t('placesUi.updates.notifiedCount', { count: reach }) : t('placesUi.updates.notifiedEveryone'),
|
|
});
|
|
} catch (err) {
|
|
toast({
|
|
title: t('placesUi.updates.postError'),
|
|
description: err instanceof Error ? err.message : t('placesUi.updates.tryAgain'),
|
|
variant: 'destructive',
|
|
});
|
|
}
|
|
};
|
|
|
|
const hasUpdates = (updates?.length ?? 0) > 0;
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* Composer — host or SkateSpots admin */}
|
|
{canPost && (
|
|
<div
|
|
className={cn(
|
|
'rounded-xl border p-3',
|
|
isHost
|
|
? 'border-[#ccff00]/25 bg-[#ccff00]/[0.04]'
|
|
: 'border-yellow-500/30 bg-yellow-500/[0.05]',
|
|
)}
|
|
>
|
|
<div
|
|
className={cn(
|
|
'mb-2 flex items-center gap-2 text-sm font-black uppercase tracking-wide',
|
|
isHost ? 'text-[#ccff00]' : 'text-yellow-400',
|
|
)}
|
|
>
|
|
<Megaphone className="h-4 w-4" />
|
|
{isHost ? t('placesUi.updates.postUpdate') : t('placesUi.updates.postAdminUpdate')}
|
|
</div>
|
|
{!isHost && (
|
|
<p className="mb-2 text-[11px] text-yellow-200/80">
|
|
{t('placesUi.updates.adminNote')}
|
|
</p>
|
|
)}
|
|
<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" />
|
|
{t(m.label)}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
<Textarea
|
|
value={message}
|
|
onChange={(e) => setMessage(e.target.value)}
|
|
placeholder={t('placesUi.updates.placeholder')}
|
|
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
|
|
? t('placesUi.updates.notifiesRsvps', { count: new Set([...going, ...tentative]).size })
|
|
: t('placesUi.updates.visibleToEveryone')}
|
|
</span>
|
|
<Button
|
|
size="sm"
|
|
onClick={handlePost}
|
|
disabled={isPending || !message.trim()}
|
|
className={cn(
|
|
'gap-1.5 font-bold text-black',
|
|
isHost
|
|
? 'bg-[#ccff00] hover:bg-[#ccff00]/90'
|
|
: 'bg-yellow-400 hover:bg-yellow-400/90',
|
|
)}
|
|
>
|
|
{isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Send className="h-3.5 w-3.5" />}
|
|
{t('placesUi.updates.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" /> {t('placesUi.updates.loading')}
|
|
</div>
|
|
) : hasUpdates ? (
|
|
<div className="space-y-4">
|
|
{updates!.map((u) => (
|
|
<UpdateItem key={u.id} update={u} />
|
|
))}
|
|
</div>
|
|
) : (
|
|
!canPost && (
|
|
<p className="text-sm text-muted-foreground">
|
|
{t('placesUi.updates.noUpdates')}
|
|
</p>
|
|
)
|
|
)}
|
|
</div>
|
|
);
|
|
}
|