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>
245 lines
9.1 KiB
TypeScript
245 lines
9.1 KiB
TypeScript
import { useState } from 'react';
|
|
import { Link } from 'react-router-dom';
|
|
import { Card, CardContent, CardFooter, CardHeader } from '@/components/ui/card';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
|
import { Skeleton } from '@/components/ui/skeleton';
|
|
import { Button } from '@/components/ui/button';
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
} from '@/components/ui/dropdown-menu';
|
|
import { MapPin, Store, Bookmark, MoreVertical, Flag } from 'lucide-react';
|
|
import { useAuthor } from '@/hooks/useAuthor';
|
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
|
import { genUserName } from '@/lib/genUserName';
|
|
import { ZapButton } from '@/components/ZapButton';
|
|
import { TrustBadge } from '@/components/TrustBadge';
|
|
import { AddToCollectionDialog } from '@/components/AddToCollectionDialog';
|
|
import { ReportDialog } from '@/components/ReportDialog';
|
|
import type { SkateSpot } from '@/lib/types';
|
|
import { SPOT_TYPE_LABELS, ACCESS_TYPE_INFO } from '@/lib/types';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
interface SpotCardProps {
|
|
spot: SkateSpot;
|
|
}
|
|
|
|
export function SpotCard({ spot }: SpotCardProps) {
|
|
const author = useAuthor(spot.pubkey);
|
|
const { user } = useCurrentUser();
|
|
const metadata = author.data?.metadata;
|
|
const displayName = metadata?.name ?? genUserName(spot.pubkey);
|
|
const [showCollectionDialog, setShowCollectionDialog] = useState(false);
|
|
const [showReportDialog, setShowReportDialog] = useState(false);
|
|
|
|
// Determine if this is a shop or a spot
|
|
const isShop = spot.types.includes('shop');
|
|
const isOwner = user?.pubkey === spot.pubkey;
|
|
const route = isShop ? `/shop/${spot.pubkey}/${spot.identifier}` : `/spot/${spot.pubkey}/${spot.identifier}`;
|
|
|
|
return (
|
|
<Link to={route}>
|
|
<Card className={cn(
|
|
"group overflow-hidden hover:shadow-2xl transition-all duration-300 border-2 bg-black/90 backdrop-blur",
|
|
isShop
|
|
? "border-[#ccff00]/30 hover:border-[#ccff00]/70 hover:shadow-[#ccff00]/30"
|
|
: "border-[#ff0040]/20 hover:border-[#ff0040]/60 hover:shadow-[#ff0040]/30"
|
|
)}>
|
|
{/* Image */}
|
|
<div className="relative aspect-video overflow-hidden bg-black">
|
|
{spot.images[0] ? (
|
|
<img
|
|
src={spot.images[0]}
|
|
alt={spot.title}
|
|
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110 group-hover:brightness-110"
|
|
/>
|
|
) : (
|
|
<div className={cn(
|
|
"w-full h-full flex items-center justify-center bg-gradient-to-br",
|
|
isShop
|
|
? "from-[#ccff00]/20 to-[#00ff88]/20"
|
|
: "from-[#ff0040]/20 to-[#ff6600]/20"
|
|
)}>
|
|
{isShop ? (
|
|
<Store className="h-16 w-16 text-[#ccff00]/40" />
|
|
) : (
|
|
<MapPin className="h-16 w-16 text-[#ff0040]/40" />
|
|
)}
|
|
</div>
|
|
)}
|
|
{/* Shop badge or Type badges overlay */}
|
|
<div className="absolute top-3 left-3 flex flex-col gap-1">
|
|
{isShop ? (
|
|
<Badge className="bg-[#ccff00]/90 text-black backdrop-blur-sm border-2 border-white font-black uppercase text-xs">
|
|
🏪 SKATE SHOP
|
|
</Badge>
|
|
) : (
|
|
<>
|
|
{spot.accessType && (
|
|
<Badge
|
|
className="bg-black/90 backdrop-blur-sm border-2 font-bold uppercase text-[10px]"
|
|
style={{
|
|
borderColor: ACCESS_TYPE_INFO[spot.accessType].color,
|
|
color: ACCESS_TYPE_INFO[spot.accessType].color
|
|
}}
|
|
>
|
|
{ACCESS_TYPE_INFO[spot.accessType].emoji} {ACCESS_TYPE_INFO[spot.accessType].label.split(' - ')[0]}
|
|
</Badge>
|
|
)}
|
|
{spot.types.length > 0 && (
|
|
<div className="flex flex-wrap gap-1">
|
|
{spot.types.slice(0, 2).map((type) => (
|
|
<Badge
|
|
key={type}
|
|
className="bg-black/80 text-[#ccff00] backdrop-blur-sm border border-[#ccff00]/50 font-bold uppercase text-[10px]"
|
|
>
|
|
{SPOT_TYPE_LABELS[type] || type}
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<CardHeader className="pb-2">
|
|
<h3 className={cn(
|
|
"font-bold text-lg leading-tight transition-colors line-clamp-1 uppercase tracking-wide",
|
|
isShop
|
|
? "text-white group-hover:text-[#ccff00]"
|
|
: "text-white group-hover:text-[#ff0040]"
|
|
)}>
|
|
{spot.title}
|
|
</h3>
|
|
{spot.location && (
|
|
<p className="text-sm text-gray-400 flex items-center gap-1 line-clamp-1">
|
|
{isShop ? (
|
|
<Store className="h-3 w-3 flex-shrink-0 text-[#ccff00]" />
|
|
) : (
|
|
<MapPin className="h-3 w-3 flex-shrink-0 text-[#ff0040]" />
|
|
)}
|
|
{spot.location}
|
|
</p>
|
|
)}
|
|
</CardHeader>
|
|
|
|
<CardContent className="pb-3">
|
|
{spot.description && (
|
|
<p className="text-sm text-gray-400 line-clamp-2">
|
|
{spot.description}
|
|
</p>
|
|
)}
|
|
</CardContent>
|
|
|
|
<CardFooter className="pt-0 flex items-center justify-between border-t border-[#ff0040]/20 mt-2 pt-3">
|
|
<div className="flex items-center gap-2 flex-1 min-w-0">
|
|
<Avatar className="h-6 w-6 border border-[#ff0040]/30">
|
|
<AvatarImage src={metadata?.picture} alt={displayName} />
|
|
<AvatarFallback className="text-xs bg-black text-[#ff0040]">
|
|
{displayName.slice(0, 2).toUpperCase()}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
<span className="text-xs text-gray-400 truncate max-w-[80px]">
|
|
{displayName}
|
|
</span>
|
|
{/* Trust Badge */}
|
|
<TrustBadge pubkey={spot.pubkey} size="sm" />
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2" onClick={(e) => e.preventDefault()}>
|
|
{user && !isShop && (
|
|
<Button
|
|
size="icon"
|
|
variant="ghost"
|
|
className="h-8 w-8 text-gray-400 hover:text-[#ccff00] hover:bg-[#ccff00]/10"
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setShowCollectionDialog(true);
|
|
}}
|
|
>
|
|
<Bookmark className="h-4 w-4" />
|
|
</Button>
|
|
)}
|
|
<ZapButton target={spot.event} className="text-gray-400 hover:text-[#ccff00]" />
|
|
{/* More options menu */}
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button
|
|
size="icon"
|
|
variant="ghost"
|
|
className="h-8 w-8 text-gray-400 hover:text-white"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<MoreVertical className="h-4 w-4" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" className="w-40">
|
|
{!isOwner && (
|
|
<DropdownMenuItem
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setShowReportDialog(true);
|
|
}}
|
|
className="text-red-500 focus:text-red-500"
|
|
>
|
|
<Flag className="h-4 w-4 mr-2" />
|
|
Report
|
|
</DropdownMenuItem>
|
|
)}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
</CardFooter>
|
|
</Card>
|
|
|
|
{/* Collection Dialog */}
|
|
{!isShop && (
|
|
<AddToCollectionDialog
|
|
open={showCollectionDialog}
|
|
onOpenChange={setShowCollectionDialog}
|
|
spot={spot}
|
|
/>
|
|
)}
|
|
|
|
{/* Report Dialog */}
|
|
<ReportDialog
|
|
open={showReportDialog}
|
|
onOpenChange={setShowReportDialog}
|
|
targetType={isShop ? 'shop' : 'spot'}
|
|
targetPubkey={spot.pubkey}
|
|
targetEventId={spot.id}
|
|
targetTitle={spot.title}
|
|
/>
|
|
</Link>
|
|
);
|
|
}
|
|
|
|
export function SpotCardSkeleton() {
|
|
return (
|
|
<Card className="overflow-hidden">
|
|
<Skeleton className="aspect-video w-full" />
|
|
<CardHeader className="pb-2">
|
|
<Skeleton className="h-6 w-3/4" />
|
|
<Skeleton className="h-4 w-1/2" />
|
|
</CardHeader>
|
|
<CardContent className="pb-3">
|
|
<div className="space-y-2">
|
|
<Skeleton className="h-4 w-full" />
|
|
<Skeleton className="h-4 w-4/5" />
|
|
</div>
|
|
</CardContent>
|
|
<CardFooter className="pt-0">
|
|
<div className="flex items-center gap-2">
|
|
<Skeleton className="h-6 w-6 rounded-full" />
|
|
<Skeleton className="h-4 w-20" />
|
|
</div>
|
|
</CardFooter>
|
|
</Card>
|
|
);
|
|
}
|