The entire skate-spots codebase was accidentally copied into src/src/ and public/public/ instead of src/ and public/ directly. This left the real src/ directory as blank MKStack template stubs, explaining why the landing page showed the default template instead of SkateSpots. Changes made: - Copied all files from src/src/ up to src/ (App.tsx, AppRouter.tsx, main.tsx, all pages, components, hooks, contexts, lib, i18n, test) - Copied all files from public/public/ up to public/ (images, manifest, etc.) - Deleted src/src/ and public/public/ nested directories - Removed HelloWorld.tsx template stub from pages/ - Retained the previously fixed index.css (Tailwind v3 directives) Full skate-spots feature set is now active: - All 30+ routes (map, events, spots, coaches, marketplace, wallet, etc.) - NWCProvider, LanguageProvider, PWAInstallPrompt, AmberCallbackHandler - Skate community relays (goskatespots.nostr1.com, wotr.relatr.xyz, etc.) - Outfit Variable font, Amber pre-check in main.tsx - All hooks (useSkateSpots, useCoaches, useZaps, etc.) - i18n support (en/es) Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
360 lines
14 KiB
TypeScript
360 lines
14 KiB
TypeScript
import { useState, useMemo } from 'react';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Label } from '@/components/ui/label';
|
|
import { Slider } from '@/components/ui/slider';
|
|
import { Card, CardContent } from '@/components/ui/card';
|
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
|
import { useToast } from '@/hooks/useToast';
|
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
|
import { useSkateSpots } from '@/hooks/useSkateSpots';
|
|
import { useUserStatus, useSetUserStatus, useClearUserStatus, SKATING_STATUSES, type SkatingStatusId } from '@/hooks/useUserStatus';
|
|
import type { SkateSpot } from '@/lib/types';
|
|
import { cn } from '@/lib/utils';
|
|
import { Loader2, MapPin, Clock, X, Zap, Search } from 'lucide-react';
|
|
|
|
interface SetStatusDialogProps {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
preselectedSpot?: SkateSpot;
|
|
}
|
|
|
|
export function SetStatusDialog({
|
|
open,
|
|
onOpenChange,
|
|
preselectedSpot,
|
|
}: SetStatusDialogProps) {
|
|
const { user } = useCurrentUser();
|
|
const { toast } = useToast();
|
|
const { data: currentStatus } = useUserStatus(user?.pubkey);
|
|
const setStatus = useSetUserStatus();
|
|
const clearStatus = useClearUserStatus();
|
|
|
|
const [selectedStatusId, setSelectedStatusId] = useState<SkatingStatusId | null>(null);
|
|
const [customStatus, setCustomStatus] = useState('');
|
|
const [linkedSpot, setLinkedSpot] = useState<{ pubkey: string; identifier: string; title?: string } | null>(
|
|
preselectedSpot ? { pubkey: preselectedSpot.pubkey, identifier: preselectedSpot.identifier, title: preselectedSpot.title } : null
|
|
);
|
|
const [expirationMinutes, setExpirationMinutes] = useState(60); // Default 1 hour
|
|
const [showSpotPicker, setShowSpotPicker] = useState(false);
|
|
const [spotSearchQuery, setSpotSearchQuery] = useState('');
|
|
|
|
// Get spots for picker
|
|
const { data: allSpots } = useSkateSpots(100);
|
|
const filteredSpots = useMemo(() => {
|
|
if (!allSpots) return [];
|
|
if (!spotSearchQuery) return allSpots.slice(0, 10);
|
|
const query = spotSearchQuery.toLowerCase();
|
|
return allSpots
|
|
.filter(s =>
|
|
s.title.toLowerCase().includes(query) ||
|
|
s.location?.toLowerCase().includes(query)
|
|
)
|
|
.slice(0, 10);
|
|
}, [allSpots, spotSearchQuery]);
|
|
|
|
const handleSetStatus = async () => {
|
|
try {
|
|
await setStatus.mutateAsync({
|
|
statusId: selectedStatusId || undefined,
|
|
customStatus: !selectedStatusId ? customStatus : undefined,
|
|
linkedSpot: linkedSpot || undefined,
|
|
expirationMinutes,
|
|
});
|
|
|
|
toast({
|
|
title: 'Status updated!',
|
|
description: 'Your skating status is now visible to others.',
|
|
});
|
|
onOpenChange(false);
|
|
// Reset form
|
|
setSelectedStatusId(null);
|
|
setCustomStatus('');
|
|
if (!preselectedSpot) setLinkedSpot(null);
|
|
} catch (error) {
|
|
toast({
|
|
title: 'Failed to set status',
|
|
description: error instanceof Error ? error.message : 'Something went wrong',
|
|
variant: 'destructive',
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleClearStatus = async () => {
|
|
try {
|
|
await clearStatus.mutateAsync();
|
|
toast({
|
|
title: 'Status cleared',
|
|
description: 'Your status has been removed.',
|
|
});
|
|
onOpenChange(false);
|
|
} catch (error) {
|
|
toast({
|
|
title: 'Failed to clear status',
|
|
description: error instanceof Error ? error.message : 'Something went wrong',
|
|
variant: 'destructive',
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleSpotSelected = (spot: SkateSpot) => {
|
|
setLinkedSpot({ pubkey: spot.pubkey, identifier: spot.identifier, title: spot.title });
|
|
setShowSpotPicker(false);
|
|
setSpotSearchQuery('');
|
|
};
|
|
|
|
const formatExpiration = (minutes: number) => {
|
|
if (minutes < 60) return `${minutes} min`;
|
|
if (minutes === 60) return '1 hour';
|
|
if (minutes < 1440) return `${Math.round(minutes / 60)} hours`;
|
|
return `${Math.round(minutes / 1440)} day${minutes >= 2880 ? 's' : ''}`;
|
|
};
|
|
|
|
if (!user) return null;
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="sm:max-w-[500px] bg-black border-[#ccff00]/50">
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-2 text-white">
|
|
<Zap className="h-5 w-5 text-[#ccff00]" />
|
|
Set Your Status
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
Let others know what you're up to. Your status will be visible on your profile and at linked spots.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-6 py-4">
|
|
{/* Current Status */}
|
|
{currentStatus && (
|
|
<Card className="bg-green-500/10 border-green-500/30">
|
|
<CardContent className="p-3">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<span className="relative flex">
|
|
<span className="animate-ping absolute inline-flex h-2 w-2 rounded-full bg-green-400 opacity-75" />
|
|
<span className="relative inline-flex h-2 w-2 rounded-full bg-green-500" />
|
|
</span>
|
|
<span className="text-green-400 font-medium text-sm">
|
|
{currentStatus.emoji} {currentStatus.status}
|
|
</span>
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={handleClearStatus}
|
|
disabled={clearStatus.isPending}
|
|
className="h-7 px-2 text-xs text-red-400 hover:text-red-300 hover:bg-red-500/10"
|
|
>
|
|
{clearStatus.isPending ? (
|
|
<Loader2 className="h-3 w-3 animate-spin" />
|
|
) : (
|
|
<>
|
|
<X className="h-3 w-3 mr-1" />
|
|
Clear
|
|
</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Quick Status Options */}
|
|
<div className="space-y-2">
|
|
<Label className="text-sm font-bold text-[#ccff00]">Quick Status</Label>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
{SKATING_STATUSES.map((status) => (
|
|
<Card
|
|
key={status.id}
|
|
className={cn(
|
|
'cursor-pointer transition-all border-2',
|
|
selectedStatusId === status.id
|
|
? 'bg-[#ccff00]/20 border-[#ccff00] ring-2 ring-[#ccff00]/50'
|
|
: 'border-gray-700 hover:border-[#ccff00]/50'
|
|
)}
|
|
onClick={() => {
|
|
setSelectedStatusId(status.id);
|
|
setCustomStatus('');
|
|
}}
|
|
>
|
|
<CardContent className="p-3 flex items-center gap-2">
|
|
<span className="text-xl">{status.emoji}</span>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="font-bold text-sm text-white truncate">{status.label}</p>
|
|
</div>
|
|
{selectedStatusId === status.id && (
|
|
<div className="w-4 h-4 rounded-full bg-[#ccff00] flex items-center justify-center flex-shrink-0">
|
|
<span className="text-black text-[10px]">✓</span>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Custom Status */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="custom-status" className="text-sm font-bold text-gray-400">
|
|
Or write your own
|
|
</Label>
|
|
<Input
|
|
id="custom-status"
|
|
placeholder="What are you up to?"
|
|
value={customStatus}
|
|
onChange={(e) => {
|
|
setCustomStatus(e.target.value);
|
|
setSelectedStatusId(null);
|
|
}}
|
|
className="bg-black border-gray-700"
|
|
/>
|
|
</div>
|
|
|
|
{/* Link to Spot */}
|
|
<div className="space-y-2">
|
|
<Label className="text-sm font-bold text-[#ff0040] flex items-center gap-1">
|
|
<MapPin className="h-3 w-3" />
|
|
Link to a Spot (optional)
|
|
</Label>
|
|
{linkedSpot ? (
|
|
<Card className="bg-[#ff0040]/10 border-[#ff0040]/30">
|
|
<CardContent className="p-3 flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<MapPin className="h-4 w-4 text-[#ff0040]" />
|
|
<span className="text-sm font-medium text-white">
|
|
{linkedSpot.title || 'Spot selected'}
|
|
</span>
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setLinkedSpot(null)}
|
|
className="h-7 px-2 text-xs"
|
|
disabled={!!preselectedSpot}
|
|
>
|
|
<X className="h-3 w-3" />
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
) : showSpotPicker ? (
|
|
<Card className="bg-gray-900/50 border-gray-700">
|
|
<CardContent className="p-3 space-y-3">
|
|
<div className="flex items-center gap-2">
|
|
<div className="relative flex-1">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-500" />
|
|
<Input
|
|
placeholder="Search spots..."
|
|
value={spotSearchQuery}
|
|
onChange={(e) => setSpotSearchQuery(e.target.value)}
|
|
className="bg-gray-800 border-gray-700 pl-9 h-9"
|
|
autoFocus
|
|
/>
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => {
|
|
setShowSpotPicker(false);
|
|
setSpotSearchQuery('');
|
|
}}
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
|
|
<ScrollArea className="h-48">
|
|
<div className="space-y-1.5">
|
|
{filteredSpots.map((spot) => (
|
|
<button
|
|
key={`${spot.pubkey}:${spot.identifier}`}
|
|
type="button"
|
|
className="w-full flex items-center gap-2 p-2 rounded-lg text-left transition-colors text-sm hover:bg-gray-800"
|
|
onClick={() => handleSpotSelected(spot)}
|
|
>
|
|
<MapPin className="h-4 w-4 flex-shrink-0 text-[#ff0040]" />
|
|
<div className="flex-1 min-w-0">
|
|
<div className="font-medium truncate text-white">{spot.title}</div>
|
|
{spot.location && (
|
|
<div className="text-xs text-gray-400 truncate">{spot.location}</div>
|
|
)}
|
|
</div>
|
|
</button>
|
|
))}
|
|
{filteredSpots.length === 0 && (
|
|
<p className="text-sm text-gray-400 text-center py-4">
|
|
No spots found.
|
|
</p>
|
|
)}
|
|
</div>
|
|
</ScrollArea>
|
|
</CardContent>
|
|
</Card>
|
|
) : (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setShowSpotPicker(true)}
|
|
className="w-full border-gray-700 text-gray-400"
|
|
>
|
|
<MapPin className="h-4 w-4 mr-2" />
|
|
Select a spot
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Auto-clear Timer */}
|
|
<div className="space-y-3">
|
|
<Label className="text-sm font-bold text-orange-400 flex items-center gap-1">
|
|
<Clock className="h-3 w-3" />
|
|
Auto-clear after: {formatExpiration(expirationMinutes)}
|
|
</Label>
|
|
<Slider
|
|
value={[expirationMinutes]}
|
|
onValueChange={([value]) => setExpirationMinutes(value)}
|
|
min={15}
|
|
max={1440}
|
|
step={15}
|
|
className="w-full"
|
|
/>
|
|
<div className="flex justify-between text-xs text-gray-500">
|
|
<span>15 min</span>
|
|
<span>1 hr</span>
|
|
<span>4 hrs</span>
|
|
<span>24 hrs</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Submit Button */}
|
|
<Button
|
|
onClick={handleSetStatus}
|
|
disabled={(!selectedStatusId && !customStatus) || setStatus.isPending}
|
|
className="w-full bg-[#ccff00] hover:bg-[#ccff00]/90 text-black font-bold"
|
|
>
|
|
{setStatus.isPending ? (
|
|
<>
|
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
|
Setting Status...
|
|
</>
|
|
) : (
|
|
<>
|
|
<Zap className="h-4 w-4 mr-2" />
|
|
Set Status
|
|
</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|