SkateSpots/src/pages/BecomeEditorPage.tsx
shakespeare.diy 4e227f0367 feat: License page, Video Editors in Lab, Buy/Sell Gear, Stash locations, Supporters page
1. /license page
   - New LicensePage with "YOU ARE FREE TO SKATEBOARD" license text
   - Copyright 2025-∞ with neon THUG2 styling
   - Footer: "License" and "Supporters" links added next to OpenSats

2. /lab — Video Editor category
   - New SKATE_EDITOR_KIND (35003) in types.ts
   - EDITOR_SERVICE_LABELS/DESCRIPTIONS (8 edit types)
   - EDITOR_SKILL_LABELS/DESCRIPTIONS (8 software/hardware skills)
   - SkateEditor interface + parseSkateEditor() parser
   - useLabProfiles updated to query kind 35003 + 'skateeditor' tag
   - LabPage: new "Video Editors" tab (purple, Scissors icon)
   - LabPage: "Offer Editing" button in header
   - BecomeEditorPage: full form with edit types, skills, hardware,
     software, portfolio, contact, location, wallet requirement
   - Route /become-editor registered in AppRouter

3. /map Quick Actions
   - "Sell Gear" → "Buy/Sell Gear" with ArrowRightLeft icon
   - Button now links to /marketplace (browse + sell)

4. /stash — Storage locations
   - StashItem.storageLocation field: home/warehouse/friends/parents/wardrobe/other
   - AddItemDialog: 6-button grid to tag where gear is stored
   - StashItemCard: shows location badge on each card
   - Filter bar: pill filter chips to filter stash by location
   - storageLocation saved/restored via onAdd handler

5. /supporters page
   - btcmap.org-style supporters page
   - Live Geyser GraphQL leaderboard (skatespots project)
   - Avatar grid with sat amounts, anon fallbacks
   - Mission section + "Send Sats" section (Lightning address + Geyser)
   - Added to footer and desktop "More" dropdown and mobile menu

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-06-16 11:05:03 +07:00

411 lines
20 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 { useSeoMeta } from '@unhead/react';
import { useNavigate, Link } from 'react-router-dom';
import ngeohash from 'ngeohash';
import { nip19 } from 'nostr-tools';
import { Layout } from '@/components/Layout';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAuthor } from '@/hooks/useAuthor';
import { useUploadFile } from '@/hooks/useUploadFile';
import { useToast } from '@/hooks/useToast';
import { useWallet } from '@/hooks/useWallet';
import { WalletModal } from '@/components/WalletModal';
import { LoginArea } from '@/components/auth/LoginArea';
import {
SKATE_EDITOR_KIND,
EDITOR_SERVICE_LABELS,
EDITOR_SERVICE_DESCRIPTIONS,
EDITOR_SKILL_LABELS,
EDITOR_SKILL_DESCRIPTIONS,
} from '@/lib/types';
import { cn } from '@/lib/utils';
import { Scissors, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink, Monitor } from 'lucide-react';
const SERVICE_ENTRIES = Object.entries(EDITOR_SERVICE_LABELS);
const SKILL_ENTRIES = Object.entries(EDITOR_SKILL_LABELS);
export default function BecomeEditorPage() {
useSeoMeta({
title: 'Become a Video Editor - SkateSpots',
description: 'Create your skateboarding video editor profile on SkateSpots.',
});
const navigate = useNavigate();
const { user } = useCurrentUser();
const author = useAuthor(user?.pubkey || '');
const { mutateAsync: createEvent, isPending: isPublishing } = useNostrPublish();
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
const { toast } = useToast();
const { webln, activeNWC } = useWallet();
const hasWallet = !!webln || !!activeNWC;
const [bio, setBio] = useState('');
const [location, setLocation] = useState('');
const [latitude, setLatitude] = useState('');
const [longitude, setLongitude] = useState('');
const [selectedServices, setSelectedServices] = useState<string[]>([]);
const [selectedSkills, setSelectedSkills] = useState<string[]>([]);
const [image, setImage] = useState<string | null>(null);
const [experience, setExperience] = useState('');
const [rate, setRate] = useState('');
const [phone, setPhone] = useState('');
const [email, setEmail] = useState('');
const [website, setWebsite] = useState('');
const [portfolio, setPortfolio] = useState('');
const [social, setSocial] = useState('');
const [hardware, setHardware] = useState('');
const [software, setSoftware] = useState('');
const [isGettingLocation, setIsGettingLocation] = useState(false);
const getDisplayName = () => {
if (author.data?.metadata?.name) return author.data.metadata.name;
if (user?.pubkey) {
const npub = nip19.npubEncode(user.pubkey);
return `${npub.slice(0, 12)}...${npub.slice(-4)}`;
}
return 'Anonymous Editor';
};
const toggleService = (s: string) =>
setSelectedServices((prev) => prev.includes(s) ? prev.filter((x) => x !== s) : [...prev, s]);
const toggleSkill = (s: string) =>
setSelectedSkills((prev) => prev.includes(s) ? prev.filter((x) => x !== s) : [...prev, s]);
const getCurrentLocation = () => {
if (!navigator.geolocation) {
toast({ title: 'Geolocation not supported', variant: 'destructive' });
return;
}
setIsGettingLocation(true);
navigator.geolocation.getCurrentPosition(
(pos) => {
setLatitude(pos.coords.latitude.toFixed(6));
setLongitude(pos.coords.longitude.toFixed(6));
setIsGettingLocation(false);
toast({ title: 'Location found' });
},
(err) => {
setIsGettingLocation(false);
toast({ title: 'Location error', description: err.message, variant: 'destructive' });
}
);
};
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files || files.length === 0) return;
try {
const tags = await uploadFile(files[0]);
const url = tags[0]?.[1];
if (url) { setImage(url); toast({ title: 'Image uploaded' }); }
} catch (err) {
toast({ title: 'Upload failed', description: err instanceof Error ? err.message : 'Error', variant: 'destructive' });
}
e.target.value = '';
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (selectedServices.length === 0) {
toast({ title: 'Services required', description: 'Select at least one edit type.', variant: 'destructive' });
return;
}
try {
const identifier = 'editor-profile';
const displayName = getDisplayName();
const tags: string[][] = [
['d', identifier],
['title', displayName],
['t', 'skateeditor'],
['alt', `Skate video editor: ${displayName}`],
];
if (location.trim()) tags.push(['location', location.trim()]);
if (latitude && longitude) {
const lat = parseFloat(latitude);
const lng = parseFloat(longitude);
if (!isNaN(lat) && !isNaN(lng)) tags.push(['g', ngeohash.encode(lat, lng, 7)]);
}
for (const s of selectedServices) tags.push(['t', s]);
for (const s of selectedSkills) tags.push(['t', s]);
if (image) tags.push(['image', image]);
if (experience.trim()) tags.push(['experience', experience.trim()]);
if (rate.trim()) tags.push(['rate', rate.trim()]);
if (phone.trim()) tags.push(['phone', phone.trim()]);
if (email.trim()) tags.push(['email', email.trim()]);
if (website.trim()) tags.push(['website', website.trim()]);
if (portfolio.trim()) tags.push(['portfolio', portfolio.trim()]);
if (social.trim()) tags.push(['social', social.trim()]);
if (hardware.trim()) tags.push(['hardware', hardware.trim()]);
if (software.trim()) tags.push(['software', software.trim()]);
await createEvent({ kind: SKATE_EDITOR_KIND, content: bio.trim(), tags });
toast({ title: 'Editor profile created!', description: 'Your profile has been published to SkateSpots.' });
navigate(`/coach/${user?.pubkey}/${identifier}`);
} catch (err) {
toast({ title: 'Failed to publish', description: err instanceof Error ? err.message : 'Error', variant: 'destructive' });
}
};
if (!user) {
return (
<Layout>
<div className="container px-4 py-8">
<Card className="max-w-xl mx-auto bg-gray-900 border-gray-700">
<CardContent className="py-12 text-center">
<Scissors className="h-12 w-12 mx-auto mb-4 text-gray-600" />
<h2 className="text-xl font-bold mb-2">Login Required</h2>
<p className="text-gray-400 mb-6">Log in to create your video editor profile.</p>
<LoginArea className="justify-center max-w-60 mx-auto" />
</CardContent>
</Card>
</div>
</Layout>
);
}
const hasNip05 = !!author.data?.metadata?.nip05;
return (
<Layout>
<div className="container px-4 sm:px-6 py-6 sm:py-8 max-w-2xl">
<div className="mb-6">
<Link to="/lab" 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 The Lab
</Link>
<h1 className="text-3xl font-black uppercase" style={{ color: '#c084fc', fontFamily: 'Impact, "Arial Black", sans-serif', textShadow: '0 0 20px rgba(192,132,252,0.6)' }}>
Become a Video Editor
</h1>
<p className="text-gray-400 mt-1">Offer your video editing services to the skate community</p>
{!hasNip05 && (
<Card className="mt-4 bg-yellow-950/30 border-2 border-yellow-500/50">
<CardContent className="p-4">
<div className="flex items-start gap-3">
<span className="text-yellow-500">⚠️</span>
<div>
<h3 className="font-bold text-yellow-400 mb-1">NIP-05 Verification Recommended</h3>
<p className="text-sm text-gray-300 mb-2">Verify your identity to build trust with clients.</p>
<Link to="/settings">
<Button variant="outline" size="sm" className="border-yellow-500/50 hover:bg-yellow-500/10 text-yellow-400 font-bold">
Add NIP-05 in Settings
</Button>
</Link>
</div>
</div>
</CardContent>
</Card>
)}
<Card className="mt-4 bg-purple-950/30 border-2 border-purple-500/50">
<CardContent className="p-4">
<div className="flex items-start gap-3">
<Wallet className="h-6 w-6 text-purple-400 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<h3 className="font-bold text-purple-400 mb-2">⚡ Lightning Wallet for Payments</h3>
<p className="text-sm text-gray-300 mb-3">Receive Bitcoin ⚡ payments directly from clients for your editing work.</p>
{hasWallet ? (
<div className="p-3 rounded-lg bg-green-950/30 border border-green-500/50">
<div className="flex items-center gap-2">
<div className="h-2 w-2 rounded-full bg-green-500" />
<p className="text-sm font-bold text-green-400">Wallet Connected ✓</p>
</div>
</div>
) : (
<div className="flex flex-col sm:flex-row gap-2">
<WalletModal>
<Button className="gap-2 bg-purple-600 hover:bg-purple-700 flex-1">
<Wallet className="h-4 w-4" />
Connect Wallet
</Button>
</WalletModal>
<a href="https://planb.academy/en/tutorials/wallet" target="_blank" rel="noopener noreferrer" className="flex-1">
<Button variant="outline" className="gap-2 w-full border-purple-500/50 text-purple-400 hover:bg-purple-500/10">
<ExternalLink className="h-4 w-4" /> Learn How
</Button>
</a>
</div>
)}
</div>
</div>
</CardContent>
</Card>
</div>
<form onSubmit={handleSubmit}>
<Card className="bg-gray-900 border-gray-700">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Scissors className="h-5 w-5 text-purple-400" />
Editor Profile
</CardTitle>
<CardDescription className="text-gray-400">Tell the community about your editing style and skills</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Identity */}
<div className="space-y-2">
<Label>Your Identity</Label>
<div className="p-4 rounded-lg border-2 border-purple-500/30 bg-purple-500/5">
<p className="font-black text-lg">{getDisplayName()}</p>
<p className="text-sm text-gray-400 mt-1">From your Nostr profile</p>
</div>
</div>
{/* Profile image */}
<div className="space-y-2">
<Label>Profile / Showreel Thumbnail</Label>
{image ? (
<div className="relative w-32 h-32">
<img src={image} alt="profile" className="w-32 h-32 object-cover rounded-lg border-2 border-purple-500/40" />
<Button type="button" variant="destructive" size="icon" className="absolute -top-2 -right-2 h-6 w-6" onClick={() => setImage(null)}>
<X className="h-3 w-3" />
</Button>
</div>
) : (
<label className="flex flex-col items-center justify-center w-full h-32 border-2 border-dashed border-gray-700 rounded-lg cursor-pointer hover:border-purple-500/50 transition-colors bg-gray-800/50">
<Upload className="h-8 w-8 text-gray-500 mb-2" />
<span className="text-sm text-gray-500">{isUploading ? 'Uploading…' : 'Upload image'}</span>
<input type="file" className="hidden" accept="image/*" onChange={handleImageUpload} disabled={isUploading} />
</label>
)}
</div>
{/* Bio */}
<div className="space-y-2">
<Label htmlFor="bio">Bio / About Your Work</Label>
<Textarea id="bio" placeholder="Your editing style, background, notable projects…" value={bio} onChange={(e) => setBio(e.target.value)} rows={5} className="bg-gray-800 border-gray-700" />
</div>
{/* Edit Types */}
<div className="space-y-3">
<Label>Edit Types Offered * (select at least one)</Label>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{SERVICE_ENTRIES.map(([service, label]) => (
<button
key={service}
type="button"
onClick={() => toggleService(service)}
className={cn(
'p-3 rounded-lg text-left transition-all border text-sm',
selectedServices.includes(service)
? 'bg-purple-600 text-white border-purple-600'
: 'bg-gray-800 hover:bg-gray-700 border-gray-700'
)}
>
<div className="font-bold">{label}</div>
<div className="text-xs opacity-70 mt-1">{EDITOR_SERVICE_DESCRIPTIONS[service]}</div>
</button>
))}
</div>
</div>
{/* Software / Hardware Skills */}
<div className="space-y-3">
<Label>Software & Hardware Skills</Label>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
{SKILL_ENTRIES.map(([skill, label]) => (
<button
key={skill}
type="button"
onClick={() => toggleSkill(skill)}
className={cn(
'p-2.5 rounded-lg text-left transition-all border text-sm',
selectedSkills.includes(skill)
? 'bg-purple-500 text-white border-purple-500'
: 'bg-gray-800 hover:bg-gray-700 border-gray-700'
)}
>
<div className="font-bold text-xs uppercase">{label}</div>
<div className="text-[10px] opacity-60 mt-0.5 line-clamp-1">{EDITOR_SKILL_DESCRIPTIONS[skill]}</div>
</button>
))}
</div>
</div>
{/* Hardware / Software free-text */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="hardware" className="flex items-center gap-2">
<Monitor className="h-4 w-4 text-gray-400" /> Hardware
</Label>
<Input id="hardware" placeholder="e.g. MacBook Pro M3, Sony FX3" value={hardware} onChange={(e) => setHardware(e.target.value)} className="bg-gray-800 border-gray-700" />
</div>
<div className="space-y-2">
<Label htmlFor="software" className="flex items-center gap-2">
<Scissors className="h-4 w-4 text-gray-400" /> Software
</Label>
<Input id="software" placeholder="e.g. DaVinci Resolve, Premiere" value={software} onChange={(e) => setSoftware(e.target.value)} className="bg-gray-800 border-gray-700" />
</div>
</div>
{/* Experience & Rate */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="experience">Years of Experience</Label>
<Input id="experience" placeholder="e.g. 5 years" value={experience} onChange={(e) => setExperience(e.target.value)} className="bg-gray-800 border-gray-700" />
</div>
<div className="space-y-2">
<Label htmlFor="rate">Rate / Pricing</Label>
<Input id="rate" placeholder="e.g. 50k sats/min, DM for quote" value={rate} onChange={(e) => setRate(e.target.value)} className="bg-gray-800 border-gray-700" />
</div>
</div>
{/* Location */}
<div className="space-y-2">
<Label>Location (optional)</Label>
<Input placeholder="City, Country" value={location} onChange={(e) => setLocation(e.target.value)} className="bg-gray-800 border-gray-700" />
<div className="grid grid-cols-2 gap-2">
<Input placeholder="Latitude" value={latitude} onChange={(e) => setLatitude(e.target.value)} className="bg-gray-800 border-gray-700 text-sm" />
<Input placeholder="Longitude" value={longitude} onChange={(e) => setLongitude(e.target.value)} className="bg-gray-800 border-gray-700 text-sm" />
</div>
<Button type="button" variant="outline" size="sm" onClick={getCurrentLocation} disabled={isGettingLocation} className="gap-2 border-gray-700">
{isGettingLocation ? <Loader2 className="h-4 w-4 animate-spin" /> : <MapPin className="h-4 w-4" />}
Use My Location
</Button>
</div>
{/* Contact & Portfolio */}
<div className="space-y-4">
<Label>Contact & Portfolio</Label>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Input placeholder="Email" value={email} onChange={(e) => setEmail(e.target.value)} className="bg-gray-800 border-gray-700" />
<Input placeholder="Phone / Telegram" value={phone} onChange={(e) => setPhone(e.target.value)} className="bg-gray-800 border-gray-700" />
<Input placeholder="Portfolio (Vimeo, YouTube…)" value={portfolio} onChange={(e) => setPortfolio(e.target.value)} className="bg-gray-800 border-gray-700" />
<Input placeholder="Website" value={website} onChange={(e) => setWebsite(e.target.value)} className="bg-gray-800 border-gray-700" />
<Input placeholder="Instagram / Social handle" value={social} onChange={(e) => setSocial(e.target.value)} className="bg-gray-800 border-gray-700" />
</div>
</div>
</CardContent>
</Card>
<div className="mt-6 flex gap-3">
<Link to="/lab" className="flex-1">
<Button type="button" variant="outline" className="w-full border-gray-700">Cancel</Button>
</Link>
<Button
type="submit"
disabled={isPublishing || selectedServices.length === 0}
className="flex-1 bg-purple-600 hover:bg-purple-700 font-black uppercase gap-2"
>
{isPublishing ? <Loader2 className="h-4 w-4 animate-spin" /> : <Scissors className="h-4 w-4" />}
{isPublishing ? 'Publishing…' : 'Publish Editor Profile'}
</Button>
</div>
</form>
</div>
</Layout>
);
}