SkateSpots/src/components/SpotCard.tsx

341 lines
14 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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, ShieldCheck, Trash2 } from 'lucide-react';
import { useAuthor } from '@/hooks/useAuthor';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useVenuesWithPasses } from '@/hooks/useRiderCard';
import type { SpotCommunityMini } from '@/hooks/useBatchSpotCommunityScores';
import { genUserName } from '@/lib/genUserName';
import { ZapButton } from '@/components/ZapButton';
import { TrustBadge } from '@/components/TrustBadge';
import { OfficialBadge } from '@/components/OfficialBadge';
import { AddToCollectionDialog } from '@/components/AddToCollectionDialog';
import { ReportDialog } from '@/components/ReportDialog';
import { MuteMenuItem } from '@/components/MuteMenuItem';
import { DeleteEntityDialog } from '@/components/DeleteEntityDialog';
import { SpotStatusBadge } from '@/components/SpotStatusBadge';
import type { SkateSpot } from '@/lib/types';
import { ACCESS_TYPE_INFO, SHELTER_INFO, SKATE_SHOP_KIND } from '@/lib/types';
import { spotTypeLabel } from '@/lib/spotTypeLabel';
import { ACCESS_TYPE_KEYS, SHELTER_SHORT_KEYS } from '@/lib/labelKeys';
import type { AggregatedSpotStatus } from '@/hooks/useSpotStatus';
import { useTranslation } from '@/contexts/language';
import { isAdminPubkey } from '@/lib/admins';
import { cn } from '@/lib/utils';
interface SpotCardProps {
spot: SkateSpot;
/** Optional community status, passed in batched from the parent list/map. */
status?: AggregatedSpotStatus | null;
/** Optional community confidence, passed in batched from the parent list/map. */
community?: SpotCommunityMini;
}
export function SpotCard({ spot, status, community }: SpotCardProps) {
const { t } = useTranslation();
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);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
// Determine if this is a shop or a spot
const isShop = spot.types.includes('shop');
const isOwner = user?.pubkey === spot.pubkey;
const isAdmin = isAdminPubkey(user?.pubkey);
const canDelete = !!user && (isOwner || isAdmin);
const route = isShop ? `/shop/${spot.pubkey}/${spot.identifier}` : `/spot/${spot.pubkey}/${spot.identifier}`;
// Shops that actually issue access passes get a badge (one shared, cached query).
const { data: passIndex } = useVenuesWithPasses();
const hasPasses = isShop && !!passIndex?.coords.has(`${SKATE_SHOP_KIND}:${spot.pubkey}:${spot.identifier}`);
// Dim spots the community has reported as gone/demolished.
const isGone = status?.status === 'gone';
return (
<div className="group relative">
{/* Stretched link — the whole card navigates, without nesting interactive
elements (buttons, Zap, dropdowns) or dialogs inside an <a>. */}
<Link to={route} className="absolute inset-0 z-10" aria-label={spot.title} />
<Card className={cn(
"overflow-hidden group-hover:shadow-2xl transition-all duration-300 border-2 bg-black/90",
isShop
? "border-[#ccff00]/30 group-hover:border-[#ccff00]/70 group-hover:shadow-[#ccff00]/30"
: "border-[#ff0040]/20 group-hover:border-[#ff0040]/60 group-hover:shadow-[#ff0040]/30",
isGone && "opacity-60 grayscale group-hover:opacity-90"
)}>
{/* Image */}
<div className="relative aspect-video overflow-hidden bg-black">
{spot.images[0] ? (
<img
src={spot.images[0]}
alt={spot.title}
loading="lazy"
decoding="async"
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>
)}
{/* Community status badge (non-shop) */}
{!isShop && status?.status && (
<div className="absolute top-3 right-3">
<SpotStatusBadge aggregated={status} size="sm" />
</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">
🏪 {t('mapUi.skateShop')}
</Badge>
{hasPasses && (
<Badge className="bg-white/90 text-black backdrop-blur-sm border-2 border-black font-black uppercase text-[10px]">
🎟️ {t('venueAccess.badge')}
</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_KEYS[spot.accessType] ? t(ACCESS_TYPE_KEYS[spot.accessType]) : ACCESS_TYPE_INFO[spot.accessType].label).split(' - ')[0]}
</Badge>
)}
{spot.shelter && spot.shelter !== 'outdoor' && SHELTER_INFO[spot.shelter] && (
<Badge
className="bg-black/90 backdrop-blur-sm border-2 font-bold uppercase text-[10px]"
style={{
borderColor: SHELTER_INFO[spot.shelter].color,
color: SHELTER_INFO[spot.shelter].color,
}}
>
{SHELTER_INFO[spot.shelter].emoji} {SHELTER_SHORT_KEYS[spot.shelter] ? t(SHELTER_SHORT_KEYS[spot.shelter]) : SHELTER_INFO[spot.shelter].short}
</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]"
>
{spotTypeLabel(t, 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" />
{/* Official badge — only shown for verified founder */}
<OfficialBadge pubkey={spot.pubkey} size="sm" />
{/* Community confidence — batched in by the parent list/map. */}
{community?.level === 'community' && (
<span
className="inline-flex items-center gap-0.5 rounded-full bg-emerald-500/15 px-1.5 py-0.5 text-[9px] font-bold uppercase text-emerald-400"
title={t('spotUi.communityVerifiedTitle')}
>
<ShieldCheck className="h-2.5 w-2.5" /> {t('spotUi.communityChip')}
</span>
)}
{community?.level === 'seen' && (
<span className="inline-flex items-center rounded-full bg-white/5 px-1.5 py-0.5 text-[9px] font-bold uppercase text-gray-500">
{t('spotUi.seenChip')}
</span>
)}
</div>
<div className="relative z-20 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-44">
{canDelete && (
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault();
setShowDeleteDialog(true);
}}
className="text-red-500 focus:text-red-500"
>
<Trash2 className="mr-2 h-4 w-4" />
{isOwner ? t('common.delete') : t('spotUi.removeAdmin')}
</DropdownMenuItem>
)}
{!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" />
{t('mapUi.report')}
</DropdownMenuItem>
)}
<MuteMenuItem pubkey={spot.pubkey} className="text-gray-300" />
</DropdownMenuContent>
</DropdownMenu>
</div>
</CardFooter>
</Card>
{/* Collection Dialog */}
{!isShop && (
<AddToCollectionDialog
open={showCollectionDialog}
onOpenChange={setShowCollectionDialog}
spot={spot}
/>
)}
{/* Owner/admin delete confirmation */}
{canDelete && (
<DeleteEntityDialog
open={showDeleteDialog}
onOpenChange={setShowDeleteDialog}
event={spot.event}
entityLabel={isShop ? t('mapUi.skateShop') : t('spotUi.spot')}
entityName={spot.title}
/>
)}
{/* Report Dialog */}
<ReportDialog
open={showReportDialog}
onOpenChange={setShowReportDialog}
targetType={isShop ? 'shop' : 'spot'}
targetPubkey={spot.pubkey}
targetEventId={spot.id}
targetTitle={spot.title}
/>
</div>
);
}
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>
);
}