Solves the "spots that change appearance or disappear" problem with crowd-sourced, trust-weighted status reports — since the spot owner often isn't the one who notices a spot got knobbed/demolished/busted. New custom kind 2259 (regular, append-only): any user can report a spot's current physical state — active / bust / skate-stopped / closed / gone — with an optional note, referencing the spot via its `a` coordinate. Client aggregation (useSpotStatus / useBatchSpotStatus): - recency-weighted (30-day half-life) so fresh reports dominate and a rebuilt spot naturally recovers - trust-weighted via existing useBatchTrustScores (0.2–1.0 multiplier) - most-recent-report-per-reporter (no ballot stuffing) - reports older than 180 days ignored UI: - SpotStatusBadge (colored consensus badge + tooltip w/ report count) - ReportSpotStatusDialog (status picker, optional note, recent reports) - Badge + Report Status button on SpotDetailPage - Batched status badge on SpotCard; "gone" spots dimmed/grayscaled - "gone" spots hidden from map markers (still listed dimmed) to keep the map uncluttered while preserving the record Documented the kind, tags, status values, and aggregation rules in NIP.md. Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
212 lines
7.2 KiB
TypeScript
212 lines
7.2 KiB
TypeScript
import { useState } from 'react';
|
|
import { Loader2, Flag, Check } from 'lucide-react';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Textarea } from '@/components/ui/textarea';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogTrigger,
|
|
} from '@/components/ui/dialog';
|
|
import { LoginArea } from '@/components/auth/LoginArea';
|
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
|
import { useToast } from '@/hooks/useToast';
|
|
import { usePublishSpotStatus, useSpotStatus } from '@/hooks/useSpotStatus';
|
|
import { SpotStatusBadge } from '@/components/SpotStatusBadge';
|
|
import { SPOT_STATUS_META, SPOT_STATUS_ORDER, statusTimeAgo } from '@/lib/spotStatus';
|
|
import { useAuthor } from '@/hooks/useAuthor';
|
|
import { genUserName } from '@/lib/genUserName';
|
|
import type { SpotStatusValue, SpotStatusReport } from '@/lib/types';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
interface ReportSpotStatusDialogProps {
|
|
spotPubkey: string;
|
|
spotIdentifier: string;
|
|
spotTitle: string;
|
|
trigger?: React.ReactNode;
|
|
}
|
|
|
|
function ReportRow({ report }: { report: SpotStatusReport }) {
|
|
const author = useAuthor(report.pubkey);
|
|
const name = author.data?.metadata?.name ?? genUserName(report.pubkey);
|
|
const meta = SPOT_STATUS_META[report.status];
|
|
|
|
return (
|
|
<div className="flex items-start gap-2 py-2 border-b border-border/50 last:border-0">
|
|
<span
|
|
className="mt-1.5 inline-block rounded-full flex-shrink-0"
|
|
style={{ backgroundColor: meta.dot, width: 8, height: 8 }}
|
|
/>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center gap-2 text-xs">
|
|
<span className="font-semibold">{meta.label}</span>
|
|
<span className="text-muted-foreground truncate">by {name}</span>
|
|
<span className="text-muted-foreground ml-auto flex-shrink-0">
|
|
{statusTimeAgo(report.createdAt)}
|
|
</span>
|
|
</div>
|
|
{report.note && (
|
|
<p className="text-xs text-muted-foreground mt-0.5 break-words">{report.note}</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function ReportSpotStatusDialog({
|
|
spotPubkey,
|
|
spotIdentifier,
|
|
spotTitle,
|
|
trigger,
|
|
}: ReportSpotStatusDialogProps) {
|
|
const { user } = useCurrentUser();
|
|
const { toast } = useToast();
|
|
const [open, setOpen] = useState(false);
|
|
const [selected, setSelected] = useState<SpotStatusValue | null>(null);
|
|
const [note, setNote] = useState('');
|
|
|
|
const { aggregated, reports } = useSpotStatus(spotPubkey, spotIdentifier);
|
|
const { mutateAsync: publishStatus, isPending } = usePublishSpotStatus();
|
|
|
|
const handleSubmit = async () => {
|
|
if (!selected) return;
|
|
try {
|
|
await publishStatus({
|
|
spotPubkey,
|
|
spotIdentifier,
|
|
spotTitle,
|
|
status: selected,
|
|
note: note.trim() || undefined,
|
|
});
|
|
toast({
|
|
title: 'Status reported',
|
|
description: 'Thanks for keeping the map fresh. Your report is live.',
|
|
});
|
|
setSelected(null);
|
|
setNote('');
|
|
setOpen(false);
|
|
} catch (error) {
|
|
toast({
|
|
title: 'Could not submit',
|
|
description: error instanceof Error ? error.message : 'Something went wrong.',
|
|
variant: 'destructive',
|
|
});
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={setOpen}>
|
|
<DialogTrigger asChild>
|
|
{trigger ?? (
|
|
<Button variant="outline" size="sm" className="gap-2">
|
|
<Flag className="h-4 w-4" />
|
|
Report Status
|
|
</Button>
|
|
)}
|
|
</DialogTrigger>
|
|
<DialogContent className="sm:max-w-md max-h-[85vh] overflow-y-auto">
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-2">
|
|
<Flag className="h-5 w-5 text-[#ff0040]" />
|
|
Spot Status
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
Is "{spotTitle}" still skateable? Let the community know its current state.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
{/* Current consensus */}
|
|
<div className="flex items-center gap-2 text-sm">
|
|
<span className="text-muted-foreground">Current:</span>
|
|
{aggregated.status ? (
|
|
<SpotStatusBadge aggregated={aggregated} />
|
|
) : (
|
|
<span className="text-muted-foreground italic">No reports yet</span>
|
|
)}
|
|
</div>
|
|
|
|
{user ? (
|
|
<div className="space-y-4">
|
|
{/* Status picker */}
|
|
<div className="grid grid-cols-1 gap-2">
|
|
{SPOT_STATUS_ORDER.map((value) => {
|
|
const meta = SPOT_STATUS_META[value];
|
|
const isSel = selected === value;
|
|
return (
|
|
<button
|
|
key={value}
|
|
type="button"
|
|
onClick={() => setSelected(value)}
|
|
className={cn(
|
|
'flex items-center gap-3 rounded-lg border p-3 text-left transition-colors',
|
|
isSel
|
|
? 'border-[#ff0040] bg-[#ff0040]/10'
|
|
: 'border-border hover:border-border/80 hover:bg-muted/50',
|
|
)}
|
|
>
|
|
<span
|
|
className="inline-block rounded-full flex-shrink-0"
|
|
style={{ backgroundColor: meta.dot, width: 12, height: 12 }}
|
|
/>
|
|
<div className="min-w-0 flex-1">
|
|
<p className="font-semibold text-sm">{meta.label}</p>
|
|
<p className="text-xs text-muted-foreground">{meta.description}</p>
|
|
</div>
|
|
{isSel && <Check className="h-4 w-4 text-[#ff0040] flex-shrink-0" />}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Optional note */}
|
|
<Textarea
|
|
placeholder="Optional: add details (e.g. 'new knobs on the ledge', 'guard kicks out after 6pm')"
|
|
value={note}
|
|
onChange={(e) => setNote(e.target.value.slice(0, 500))}
|
|
rows={2}
|
|
className="resize-none"
|
|
/>
|
|
|
|
<Button
|
|
onClick={handleSubmit}
|
|
disabled={!selected || isPending}
|
|
className="w-full bg-[#ff0040] hover:bg-[#ff0040]/90"
|
|
>
|
|
{isPending ? (
|
|
<>
|
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
|
Submitting...
|
|
</>
|
|
) : (
|
|
'Submit Report'
|
|
)}
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-2">
|
|
<p className="text-sm text-muted-foreground">
|
|
Log in to report this spot's status.
|
|
</p>
|
|
<LoginArea className="w-full" />
|
|
</div>
|
|
)}
|
|
|
|
{/* Recent reports */}
|
|
{reports.length > 0 && (
|
|
<div className="mt-2">
|
|
<h4 className="text-xs font-semibold uppercase text-muted-foreground mb-1">
|
|
Recent reports
|
|
</h4>
|
|
<div className="max-h-48 overflow-y-auto">
|
|
{reports.slice(0, 20).map((r) => (
|
|
<ReportRow key={r.id} report={r} />
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|