diff --git a/src/AppRouter.tsx b/src/AppRouter.tsx index 9ffc49a..3c04134 100644 --- a/src/AppRouter.tsx +++ b/src/AppRouter.tsx @@ -24,6 +24,7 @@ import PyramidPage from "./pages/PyramidPage"; import LabPage from "./pages/LabPage"; import BecomeCoachPage from "./pages/BecomeCoachPage"; import BecomeFilmerPage from "./pages/BecomeFilmerPage"; +import BecomeEditorPage from "./pages/BecomeEditorPage"; import CoachDetailPage from "./pages/CoachDetailPage"; import TourDetailPage from "./pages/TourDetailPage"; import AboutPage from "./pages/AboutPage"; @@ -37,6 +38,8 @@ import BountyLeaderboardPage from "./pages/BountyLeaderboardPage"; import SignersPage from "./pages/SignersPage"; import StashPage from "./pages/StashPage"; import { NIP19Page } from "./pages/NIP19Page"; +import LicensePage from "./pages/LicensePage"; +import SupportersPage from "./pages/SupportersPage"; import NotFound from "./pages/NotFound"; export function AppRouter() { @@ -52,6 +55,7 @@ export function AppRouter() { } /> } /> } /> + } /> } /> } /> } /> @@ -84,6 +88,9 @@ export function AppRouter() { } /> {/* Stash - Private Inventory */} } /> + {/* Info pages */} + } /> + } /> {/* NIP-19 route for npub1, note1, naddr1, nevent1, nprofile1 */} } /> {/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */} diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index e3d6e96..5542908 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -43,6 +43,7 @@ import { Database, Package, Zap, + Heart, } from 'lucide-react'; import { OfflineIndicator } from '@/components/OfflineIndicator'; import { UserStatusIndicator } from '@/components/UserStatusIndicator'; @@ -424,6 +425,12 @@ export function Layout({ children }: LayoutProps) { {t('nav.about')} + + + + Supporters ⚡ + + @@ -640,6 +647,13 @@ export function Layout({ children }: LayoutProps) { isActive={isActive('/about')} onClick={() => setMobileMenuOpen(false)} /> + setMobileMenuOpen(false)} + />
Made with ❤️ for the skateboarding community -
- Not sponsored by - - OpenSats - OpenSats +
+ + License + + + Supporters + + + Not sponsored by + + OpenSats + OpenSats +
diff --git a/src/hooks/useLabProfiles.ts b/src/hooks/useLabProfiles.ts index f4b8a71..eb61f3a 100644 --- a/src/hooks/useLabProfiles.ts +++ b/src/hooks/useLabProfiles.ts @@ -1,9 +1,9 @@ import { useQuery } from '@tanstack/react-query'; -import { SKATE_COACH_KIND, SKATE_FILMER_KIND, parseSkateCoach, parseSkateFilmer, type SkateCoach, type SkateFilmer } from '@/lib/types'; +import { SKATE_COACH_KIND, SKATE_FILMER_KIND, SKATE_EDITOR_KIND, parseSkateCoach, parseSkateFilmer, parseSkateEditor, type SkateCoach, type SkateFilmer, type SkateEditor } from '@/lib/types'; import { useSkateRelays } from '@/hooks/useSkateRelays'; -export type LabProfile = (SkateCoach | SkateFilmer) & { - profileType: 'coach' | 'filmer'; +export type LabProfile = (SkateCoach | SkateFilmer | SkateEditor) & { + profileType: 'coach' | 'filmer' | 'editor'; }; export function useLabProfiles(limit = 100) { @@ -19,8 +19,8 @@ export function useLabProfiles(limit = 100) { const events = await querySkateContent( [ { - kinds: [SKATE_COACH_KIND, SKATE_FILMER_KIND], - '#t': ['skatecoach', 'skatefilmer'], + kinds: [SKATE_COACH_KIND, SKATE_FILMER_KIND, SKATE_EDITOR_KIND], + '#t': ['skatecoach', 'skatefilmer', 'skateeditor'], limit: limit * 2, }, ], @@ -41,10 +41,15 @@ export function useLabProfiles(limit = 100) { if (filmer) { profiles.push({ ...filmer, profileType: 'filmer' }); } + } else if (event.kind === SKATE_EDITOR_KIND) { + const editor = parseSkateEditor(event); + if (editor) { + profiles.push({ ...editor, profileType: 'editor' }); + } } } - console.log(`[useLabProfiles] Parsed profiles: ${profiles.length} (coaches + filmers)`); + console.log(`[useLabProfiles] Parsed profiles: ${profiles.length} (coaches + filmers + editors)`); return profiles.sort((a, b) => b.createdAt - a.createdAt); }, @@ -67,7 +72,7 @@ export function useLabProfile(identifier: string, pubkey?: string) { authors?: string[]; limit: number; } = { - kinds: [SKATE_COACH_KIND, SKATE_FILMER_KIND], + kinds: [SKATE_COACH_KIND, SKATE_FILMER_KIND, SKATE_EDITOR_KIND], '#d': [identifier], limit: 1, }; @@ -93,6 +98,9 @@ export function useLabProfile(identifier: string, pubkey?: string) { } else if (event.kind === SKATE_FILMER_KIND) { const filmer = parseSkateFilmer(event); return filmer ? { ...filmer, profileType: 'filmer' as const } : null; + } else if (event.kind === SKATE_EDITOR_KIND) { + const editor = parseSkateEditor(event); + return editor ? { ...editor, profileType: 'editor' as const } : null; } return null; diff --git a/src/hooks/useStash.ts b/src/hooks/useStash.ts index 1109b94..7f1a2da 100644 --- a/src/hooks/useStash.ts +++ b/src/hooks/useStash.ts @@ -27,6 +27,8 @@ export interface StashItem { itemType?: 'stash' | 'wtb'; // Rarity override (for special items) rarity?: 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary'; + // Physical storage location + storageLocation?: 'home' | 'warehouse' | 'friends' | 'parents' | 'wardrobe' | 'other'; } export interface Stash { diff --git a/src/lib/types.ts b/src/lib/types.ts index 8d0e1dc..6ed0de8 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -9,6 +9,7 @@ export const SKATE_SHOP_KIND = 30017; // NIP-15 Stall (marketplace) export const CALENDAR_EVENT_KIND = 31923; export const SKATE_COACH_KIND = 35001; export const SKATE_FILMER_KIND = 35002; // Skate filmer/photographer profiles +export const SKATE_EDITOR_KIND = 35003; // Skate video editor profiles export const MARKETPLACE_ITEM_KIND = 30402; // NIP-99 Classified Listings export const SPOT_COLLECTION_KIND = 30003; // NIP-51 Bookmark sets for spot collections export const SKATE_TOUR_KIND = 30823; // Local skate tours - guides offer to show hidden spots @@ -455,6 +456,52 @@ export const FILMING_SKILL_DESCRIPTIONS: Record = { color: 'Color correction and grading', }; +// Video editor service types +export const EDITOR_SERVICE_LABELS: Record = { + parts: 'Video Parts', + montages: 'Montages', + reels: 'Reels / Short Clips', + documentaries: 'Documentaries', + contest: 'Contest Edits', + brand: 'Brand / Promo Videos', + motion: 'Motion Graphics', + colorgrade: 'Color Grading Only', +}; + +export const EDITOR_SERVICE_DESCRIPTIONS: Record = { + parts: 'Full skate video parts with soundtrack', + montages: 'Compilation edits from raw footage', + reels: '15–60 sec social media clips & reels', + documentaries: 'Story-driven skate culture films', + contest: 'Recap edits for contests and events', + brand: 'Promotional content for brands and shops', + motion: 'Titles, transitions, effects, and graphics', + colorgrade: 'Colour correction and LUT-based grading', +}; + +// Hardware / software skills for editors +export const EDITOR_SKILL_LABELS: Record = { + premiere: 'Adobe Premiere', + resolve: 'DaVinci Resolve', + finalcut: 'Final Cut Pro', + aftereffects: 'After Effects', + luts: 'Custom LUTs', + vhs: 'VHS / Retro Aesthetic', + '4k': '4K & RAW Workflows', + sound: 'Sound Design / Mix', +}; + +export const EDITOR_SKILL_DESCRIPTIONS: Record = { + premiere: 'Adobe Premiere Pro editing', + resolve: 'DaVinci Resolve editing & grading', + finalcut: 'Final Cut Pro editing', + aftereffects: 'Motion graphics and compositing', + luts: 'Custom colour look development', + vhs: 'VHS glitch, super-8, retro film looks', + '4k': 'High-res RAW/LOG camera workflows', + sound: 'Music sync, SFX, audio mastering', +}; + export interface SkateCoach { id: string; pubkey: string; @@ -497,6 +544,29 @@ export interface SkateFilmer { event: NostrEvent; } +export interface SkateEditor { + id: string; + pubkey: string; + identifier: string; + title: string; + bio: string; + geohash?: string; + location?: string; + image?: string; + skills: string[]; // Combined editor services + software/hardware skills + experience?: string; // Years of experience + rate?: string; + phone?: string; + email?: string; + website?: string; + portfolio?: string; // Portfolio link (Vimeo, YouTube, etc.) + social?: string; + hardware?: string; // e.g. "MacBook Pro M3, Sony FX3" + software?: string; // e.g. "DaVinci Resolve, Premiere" + createdAt: number; + event: NostrEvent; +} + export interface SkateTour { id: string; pubkey: string; @@ -649,6 +719,52 @@ export function parseSkateFilmer(event: NostrEvent): SkateFilmer | null { }; } +export function parseSkateEditor(event: NostrEvent): SkateEditor | null { + if (event.kind !== SKATE_EDITOR_KIND) return null; + + const d = event.tags.find(([name]) => name === 'd')?.[1]; + const title = event.tags.find(([name]) => name === 'title')?.[1]; + + if (!d || !title) return null; + + const location = event.tags.find(([name]) => name === 'location')?.[1]; + const geohash = event.tags.find(([name]) => name === 'g')?.[1]; + const image = event.tags.find(([name]) => name === 'image')?.[1]; + const skills = event.tags.filter(([name, value]) => name === 't' && value !== 'skateeditor').map(([, value]) => value); + const experience = event.tags.find(([name]) => name === 'experience')?.[1]; + const rate = event.tags.find(([name]) => name === 'rate')?.[1]; + const phone = event.tags.find(([name]) => name === 'phone')?.[1]; + const email = event.tags.find(([name]) => name === 'email')?.[1]; + const website = event.tags.find(([name]) => name === 'website')?.[1]; + const portfolio = event.tags.find(([name]) => name === 'portfolio')?.[1]; + const social = event.tags.find(([name]) => name === 'social')?.[1]; + const hardware = event.tags.find(([name]) => name === 'hardware')?.[1]; + const software = event.tags.find(([name]) => name === 'software')?.[1]; + + return { + id: event.id, + pubkey: event.pubkey, + identifier: d, + title, + bio: event.content, + location, + geohash, + image, + skills, + experience, + rate, + phone, + email, + website, + portfolio, + social, + hardware, + software, + createdAt: event.created_at, + event, + }; +} + export function parseSkateTour(event: NostrEvent): SkateTour | null { if (event.kind !== SKATE_TOUR_KIND) return null; diff --git a/src/pages/BecomeEditorPage.tsx b/src/pages/BecomeEditorPage.tsx new file mode 100644 index 0000000..0e9b645 --- /dev/null +++ b/src/pages/BecomeEditorPage.tsx @@ -0,0 +1,411 @@ +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([]); + const [selectedSkills, setSelectedSkills] = useState([]); + const [image, setImage] = useState(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) => { + 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 ( + +
+ + + +

Login Required

+

Log in to create your video editor profile.

+ +
+
+
+
+ ); + } + + const hasNip05 = !!author.data?.metadata?.nip05; + + return ( + +
+
+ + + Back to The Lab + +

+ Become a Video Editor +

+

Offer your video editing services to the skate community

+ + {!hasNip05 && ( + + +
+ ⚠️ +
+

NIP-05 Verification Recommended

+

Verify your identity to build trust with clients.

+ + + +
+
+
+
+ )} + + + +
+ +
+

⚡ Lightning Wallet for Payments

+

Receive Bitcoin ⚡ payments directly from clients for your editing work.

+ {hasWallet ? ( +
+
+
+

Wallet Connected ✓

+
+
+ ) : ( +
+ + + + + + +
+ )} +
+
+ + +
+ +
+ + + + + Editor Profile + + Tell the community about your editing style and skills + + + {/* Identity */} +
+ +
+

{getDisplayName()}

+

From your Nostr profile

+
+
+ + {/* Profile image */} +
+ + {image ? ( +
+ profile + +
+ ) : ( + + )} +
+ + {/* Bio */} +
+ +