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>
378 lines
15 KiB
TypeScript
378 lines
15 KiB
TypeScript
import { nip19 } from 'nostr-tools';
|
|
import { useParams, Link } from 'react-router-dom';
|
|
import { Layout } from '@/components/Layout';
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Skeleton } from '@/components/ui/skeleton';
|
|
import { ZapButton } from '@/components/ZapButton';
|
|
import { useAuthor } from '@/hooks/useAuthor';
|
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
|
import { useUserSkateSpots } from '@/hooks/useSkateSpots';
|
|
import { useUserShops } from '@/hooks/useShops';
|
|
import { SpotCard, SpotCardSkeleton } from '@/components/SpotCard';
|
|
import { genUserName } from '@/lib/genUserName';
|
|
import NotFound from './NotFound';
|
|
import {
|
|
MapPin,
|
|
Store,
|
|
MessageCircle,
|
|
Globe,
|
|
Zap,
|
|
Calendar,
|
|
ExternalLink,
|
|
ArrowLeft,
|
|
Copy,
|
|
Check,
|
|
} from 'lucide-react';
|
|
import { useState } from 'react';
|
|
import { UserStatusIndicator } from '@/components/UserStatusIndicator';
|
|
|
|
function ProfilePage({ pubkey }: { pubkey: string }) {
|
|
const author = useAuthor(pubkey);
|
|
const { user } = useCurrentUser();
|
|
const { data: spots, isLoading: spotsLoading } = useUserSkateSpots(pubkey);
|
|
const { data: shops, isLoading: shopsLoading } = useUserShops(pubkey);
|
|
const [copied, setCopied] = useState(false);
|
|
|
|
const metadata = author.data?.metadata;
|
|
const displayName = metadata?.name ?? genUserName(pubkey);
|
|
const avatar = metadata?.picture;
|
|
const banner = metadata?.banner;
|
|
const about = metadata?.about;
|
|
const website = metadata?.website;
|
|
const nip05 = metadata?.nip05;
|
|
const lud16 = metadata?.lud16;
|
|
|
|
const npub = nip19.npubEncode(pubkey);
|
|
const isOwnProfile = user?.pubkey === pubkey;
|
|
|
|
const handleCopyNpub = () => {
|
|
navigator.clipboard.writeText(npub);
|
|
setCopied(true);
|
|
setTimeout(() => setCopied(false), 2000);
|
|
};
|
|
|
|
// Create a mock event for zapping
|
|
const profileEvent = author.data?.event;
|
|
|
|
if (author.isLoading) {
|
|
return (
|
|
<Layout>
|
|
<div className="container px-4 py-8 max-w-4xl">
|
|
<Skeleton className="h-48 w-full rounded-xl mb-4" />
|
|
<div className="flex items-end gap-4 -mt-16 px-4 mb-6">
|
|
<Skeleton className="h-32 w-32 rounded-full border-4 border-black" />
|
|
<div className="flex-1 space-y-2 pb-2">
|
|
<Skeleton className="h-8 w-48" />
|
|
<Skeleton className="h-4 w-32" />
|
|
</div>
|
|
</div>
|
|
<div className="space-y-4">
|
|
<Skeleton className="h-20 w-full" />
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<Skeleton className="h-24" />
|
|
<Skeleton className="h-24" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Layout>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Layout>
|
|
<div className="container px-4 py-6 max-w-4xl">
|
|
{/* Back Button */}
|
|
<Link
|
|
to="/bounties"
|
|
className="inline-flex items-center text-gray-400 hover:text-white transition-colors mb-4"
|
|
>
|
|
<ArrowLeft className="h-4 w-4 mr-2" />
|
|
Back to Leaderboard
|
|
</Link>
|
|
|
|
{/* Banner */}
|
|
<div className="relative h-32 sm:h-48 rounded-xl overflow-hidden bg-gradient-to-br from-[#ff0040]/30 to-[#ccff00]/20 mb-4">
|
|
{banner && (
|
|
<img src={banner} alt="Banner" className="w-full h-full object-cover" />
|
|
)}
|
|
<div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent" />
|
|
</div>
|
|
|
|
{/* Profile Header */}
|
|
<div className="flex flex-col sm:flex-row items-center sm:items-end gap-4 -mt-16 sm:-mt-20 px-4 mb-6 relative z-10">
|
|
<div className="relative">
|
|
<Avatar className="h-28 w-28 sm:h-32 sm:w-32 border-4 border-black shadow-xl">
|
|
<AvatarImage src={avatar} alt={displayName} />
|
|
<AvatarFallback className="bg-[#ff0040]/20 text-[#ff0040] text-2xl sm:text-3xl font-black">
|
|
{displayName.slice(0, 2).toUpperCase()}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
{/* User Status Indicator */}
|
|
<div className="absolute -bottom-1 -right-1">
|
|
<UserStatusIndicator pubkey={pubkey} size="lg" showLabel />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex-1 text-center sm:text-left pb-2">
|
|
<div className="flex items-center justify-center sm:justify-start gap-2 mb-1">
|
|
<h1 className="text-2xl sm:text-3xl font-black uppercase text-white">
|
|
{displayName}
|
|
</h1>
|
|
<UserStatusIndicator pubkey={pubkey} size="md" showLabel className="hidden sm:flex" />
|
|
</div>
|
|
{nip05 && (
|
|
<p className="text-sm text-[#ccff00] font-medium flex items-center justify-center sm:justify-start gap-1">
|
|
<Check className="h-3 w-3" />
|
|
{nip05}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
<div className="flex items-center gap-2">
|
|
{!isOwnProfile && user && (
|
|
<Link to={`/messages/${npub}`}>
|
|
<Button variant="outline" className="gap-2 border-[#ff0040]/50 text-[#ff0040] hover:bg-[#ff0040]/10">
|
|
<MessageCircle className="h-4 w-4" />
|
|
<span className="hidden sm:inline">Message</span>
|
|
</Button>
|
|
</Link>
|
|
)}
|
|
{profileEvent && !isOwnProfile && (
|
|
<ZapButton
|
|
target={profileEvent}
|
|
className="bg-[#ccff00] text-black hover:bg-[#ccff00]/90 font-bold"
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Profile Info */}
|
|
<div className="grid lg:grid-cols-3 gap-6">
|
|
{/* Main Content */}
|
|
<div className="lg:col-span-2 space-y-6">
|
|
{/* About */}
|
|
{about && (
|
|
<Card className="bg-black/90 border-2 border-gray-800">
|
|
<CardContent className="p-4 sm:p-6">
|
|
<p className="text-gray-300 whitespace-pre-wrap">{about}</p>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Spots by this user */}
|
|
<div>
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h2 className="text-xl font-black uppercase flex items-center gap-2">
|
|
<MapPin className="h-5 w-5 text-[#ff0040]" />
|
|
Spots Added
|
|
{spots && spots.length > 0 && (
|
|
<Badge className="bg-[#ff0040]/20 text-[#ff0040] border-[#ff0040]/50">
|
|
{spots.length}
|
|
</Badge>
|
|
)}
|
|
</h2>
|
|
</div>
|
|
|
|
{spotsLoading ? (
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
{Array.from({ length: 2 }).map((_, i) => (
|
|
<SpotCardSkeleton key={i} />
|
|
))}
|
|
</div>
|
|
) : spots && spots.length > 0 ? (
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
{spots.slice(0, 4).map((spot) => (
|
|
<SpotCard key={spot.id} spot={spot} />
|
|
))}
|
|
</div>
|
|
) : (
|
|
<Card className="border-dashed border-2 border-gray-800">
|
|
<CardContent className="py-8 text-center">
|
|
<MapPin className="h-10 w-10 mx-auto mb-3 text-gray-600" />
|
|
<p className="text-gray-500">No spots added yet</p>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
|
|
{/* Shops by this user */}
|
|
<div>
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h2 className="text-xl font-black uppercase flex items-center gap-2">
|
|
<Store className="h-5 w-5 text-[#ccff00]" />
|
|
Shops Added
|
|
{shops && shops.length > 0 && (
|
|
<Badge className="bg-[#ccff00]/20 text-[#ccff00] border-[#ccff00]/50">
|
|
{shops.length}
|
|
</Badge>
|
|
)}
|
|
</h2>
|
|
</div>
|
|
|
|
{shopsLoading ? (
|
|
<div className="grid grid-cols-1 gap-3">
|
|
{Array.from({ length: 2 }).map((_, i) => (
|
|
<Skeleton key={i} className="h-20" />
|
|
))}
|
|
</div>
|
|
) : shops && shops.length > 0 ? (
|
|
<div className="space-y-3">
|
|
{shops.slice(0, 4).map((shop) => (
|
|
<Link key={shop.id} to={`/shop/${shop.pubkey}/${shop.identifier}`}>
|
|
<Card className="bg-black/90 border-2 border-[#ccff00]/30 hover:border-[#ccff00]/60 transition-all">
|
|
<CardContent className="p-4 flex items-center gap-4">
|
|
<div className="w-12 h-12 rounded-lg bg-[#ccff00]/20 flex items-center justify-center flex-shrink-0">
|
|
<Store className="h-6 w-6 text-[#ccff00]" />
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<h3 className="font-bold text-white truncate">{shop.name}</h3>
|
|
{shop.location && (
|
|
<p className="text-sm text-gray-400 truncate">{shop.location}</p>
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</Link>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<Card className="border-dashed border-2 border-gray-800">
|
|
<CardContent className="py-8 text-center">
|
|
<Store className="h-10 w-10 mx-auto mb-3 text-gray-600" />
|
|
<p className="text-gray-500">No shops added yet</p>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Sidebar */}
|
|
<div className="space-y-4">
|
|
{/* Quick Info Card */}
|
|
<Card className="bg-black/90 border-2 border-gray-800">
|
|
<CardHeader className="pb-3">
|
|
<CardTitle className="text-lg">Info</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-3">
|
|
{website && (
|
|
<a
|
|
href={website.startsWith('http') ? website : `https://${website}`}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="flex items-center gap-3 p-3 rounded-lg bg-gray-900 hover:bg-gray-800 transition-colors"
|
|
>
|
|
<Globe className="h-4 w-4 text-blue-400" />
|
|
<span className="text-gray-300 text-sm truncate">
|
|
{website.replace(/^https?:\/\//, '')}
|
|
</span>
|
|
<ExternalLink className="h-3 w-3 text-gray-500 ml-auto flex-shrink-0" />
|
|
</a>
|
|
)}
|
|
|
|
{lud16 && (
|
|
<div className="flex items-center gap-3 p-3 rounded-lg bg-gray-900">
|
|
<Zap className="h-4 w-4 text-yellow-400" />
|
|
<span className="text-gray-300 text-sm truncate">{lud16}</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Npub Copy */}
|
|
<button
|
|
onClick={handleCopyNpub}
|
|
className="flex items-center gap-3 p-3 rounded-lg bg-gray-900 hover:bg-gray-800 transition-colors w-full text-left"
|
|
>
|
|
{copied ? (
|
|
<Check className="h-4 w-4 text-green-400" />
|
|
) : (
|
|
<Copy className="h-4 w-4 text-gray-400" />
|
|
)}
|
|
<span className="text-gray-400 text-sm truncate flex-1">
|
|
{copied ? 'Copied!' : `${npub.slice(0, 12)}...${npub.slice(-8)}`}
|
|
</span>
|
|
</button>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Stats Card */}
|
|
<Card className="bg-gradient-to-br from-[#ff0040]/10 to-[#ccff00]/10 border-2 border-[#ff0040]/30">
|
|
<CardContent className="p-4">
|
|
<h3 className="text-sm font-bold uppercase text-gray-400 mb-3">Contributions</h3>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div className="text-center p-3 rounded-lg bg-black/40">
|
|
<p className="text-2xl font-black text-[#ff0040]">{spots?.length || 0}</p>
|
|
<p className="text-xs text-gray-500 uppercase">Spots</p>
|
|
</div>
|
|
<div className="text-center p-3 rounded-lg bg-black/40">
|
|
<p className="text-2xl font-black text-[#ccff00]">{shops?.length || 0}</p>
|
|
<p className="text-xs text-gray-500 uppercase">Shops</p>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Tip Card */}
|
|
{!isOwnProfile && profileEvent && (
|
|
<Card className="bg-gradient-to-br from-[#ccff00]/10 to-transparent border-2 border-[#ccff00]/30">
|
|
<CardContent className="p-4 text-center">
|
|
<Zap className="h-8 w-8 mx-auto mb-2 text-[#ccff00]" />
|
|
<h3 className="font-bold text-white mb-1">Support this mapper</h3>
|
|
<p className="text-xs text-gray-400 mb-3">
|
|
Send a zap to thank them for their contributions
|
|
</p>
|
|
<ZapButton
|
|
target={profileEvent}
|
|
className="w-full justify-center bg-[#ccff00] text-black hover:bg-[#ccff00]/90 font-bold"
|
|
/>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Layout>
|
|
);
|
|
}
|
|
|
|
export function NIP19Page() {
|
|
const { nip19: identifier } = useParams<{ nip19: string }>();
|
|
|
|
if (!identifier) {
|
|
return <NotFound />;
|
|
}
|
|
|
|
let decoded;
|
|
try {
|
|
decoded = nip19.decode(identifier);
|
|
} catch {
|
|
return <NotFound />;
|
|
}
|
|
|
|
const { type, data } = decoded;
|
|
|
|
switch (type) {
|
|
case 'npub':
|
|
return <ProfilePage pubkey={data as string} />;
|
|
|
|
case 'nprofile':
|
|
return <ProfilePage pubkey={(data as { pubkey: string }).pubkey} />;
|
|
|
|
case 'note':
|
|
// AI agent should implement note view here
|
|
return <NotFound />;
|
|
|
|
case 'nevent':
|
|
// AI agent should implement event view here
|
|
return <NotFound />;
|
|
|
|
case 'naddr':
|
|
// AI agent should implement addressable event view here
|
|
return <NotFound />;
|
|
|
|
default:
|
|
return <NotFound />;
|
|
}
|
|
}
|