diff --git a/NIP.md b/NIP.md index d27fe08..71307c9 100644 --- a/NIP.md +++ b/NIP.md @@ -1402,3 +1402,111 @@ The value of a badge is proportional to the reputation of the witness: "sig": "..." } ``` + +## The Lab — Service Profiles + +The Lab connects skaters with service providers. Each provider type is an +addressable event (kind 35001–35004) identified by `pubkey + kind + d-tag`. +They share a common base schema and are distinguished by kind and a self-tag. + +| Kind | Provider | Self-tag | +|------|----------|----------| +| 35001 | Coach | `skatecoach` | +| 35002 | Filmer (video) | `skatefilmer` | +| 35003 | Video Editor | `skateeditor` | +| 35004 | Photographer | `skatephotographer` | + +### Common Base Schema + +```json +{ + "kind": 35004, + "content": "", + "tags": [ + ["d", ""], + ["title", ""], + ["t", ""], + ["t", ""], + ["location", ""], + ["g", ""], + ["image", ""], + ["experience", ""], + ["rate", ""], + ["phone", ""], + ["email", ""], + ["website", ""], + ["a", "30818::"], + ["alt", ""] + ] +} +``` + +#### Required Tags +| Tag | Description | +|-----|-------------| +| `d` | Unique profile identifier (e.g. `photography-profile`) | +| `title` | Display name | +| `t` | Must include the provider self-tag (see table above) | + +#### Shared Optional Tags +`location`, `g` (geohash), `image`, `experience`, `rate`, `phone`, `email`, +`website`, `a` (spot references to kind 30818), `alt`. + +### Photographer (kind 35004) + +Skate photography is a distinct craft from filming — it is shot-, light-, and +print-oriented. A photographer profile records services and gear as queryable +`t` tags, plus a freeform `gear` tag and a `portfolio`/`social` link. + +Categories encoded as `t` tags: + +- **Services**: `action`, `sequence`, `portraits`, `editorial`, `product`, `events`, `lifestyle`, `prints` +- **Camera bodies**: `dslr`, `mirrorless`, `medium`, `film35`, `filmmedium`, `point`, `phone` +- **Lenses**: `fisheye`, `wide`, `standard`, `telephoto`, `macro`, `zoom` +- **Lighting**: `strobes`, `speedlights`, `profoto`, `elinchrom`, `godox`, `triggers`, `continuous`, `reflectors`, `naturallight` +- **Support**: `tripod`, `monopod`, `lightstands`, `gimbal`, `bag`, `laptop` +- **Style**: `flashfisheye`, `natural`, `bw`, `film`, `longlens`, `studio`, `street` + +Photographer-specific optional tags: + +| Tag | Description | +|-----|-------------| +| `gear` | Freeform "what's in the bag" (exact bodies, lenses, lights) | +| `portfolio` | Portfolio link (Instagram, personal site, etc.) | +| `social` | Additional social handle or link | + +#### Example: Photographer + +```json +{ + "kind": 35004, + "content": "Shooting street and park sessions for 8 years. Love a lit flash fisheye at dusk.", + "tags": [ + ["d", "photography-profile"], + ["title", "Ana Flash"], + ["t", "skatephotographer"], + ["t", "action"], ["t", "sequence"], ["t", "editorial"], + ["t", "mirrorless"], ["t", "fisheye"], ["t", "telephoto"], + ["t", "godox"], ["t", "triggers"], + ["t", "tripod"], ["t", "lightstands"], + ["t", "flashfisheye"], ["t", "bw"], + ["location", "Barcelona, ES"], + ["g", "sp3e9"], + ["experience", "8"], + ["rate", "25000 sats/shoot"], + ["gear", "Sony A7 IV, 15mm fisheye, 70-200mm f/2.8, 2× Godox AD200 Pro, remote triggers, Manfrotto tripod"], + ["portfolio", "https://instagram.com/anaflash"], + ["alt", "Skate photographer: Ana Flash"] + ] +} +``` + +### Querying Lab Profiles + +```json +{ + "kinds": [35001, 35002, 35003, 35004], + "#t": ["skatecoach", "skatefilmer", "skateeditor", "skatephotographer"], + "limit": 100 +} +``` diff --git a/src/AppRouter.tsx b/src/AppRouter.tsx index d48134d..8842b9e 100644 --- a/src/AppRouter.tsx +++ b/src/AppRouter.tsx @@ -29,6 +29,7 @@ const PyramidPage = lazy(() => import("./pages/PyramidPage")); const BecomeCoachPage = lazy(() => import("./pages/BecomeCoachPage")); const BecomeFilmerPage = lazy(() => import("./pages/BecomeFilmerPage")); const BecomeEditorPage = lazy(() => import("./pages/BecomeEditorPage")); +const BecomePhotographerPage = lazy(() => import("./pages/BecomePhotographerPage")); const CoachDetailPage = lazy(() => import("./pages/CoachDetailPage")); const TourDetailPage = lazy(() => import("./pages/TourDetailPage")); const AboutPage = lazy(() => import("./pages/AboutPage")); @@ -80,6 +81,7 @@ export function AppRouter() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/hooks/useLabProfiles.ts b/src/hooks/useLabProfiles.ts index b65eb2f..fbd4b63 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, SKATE_EDITOR_KIND, parseSkateCoach, parseSkateFilmer, parseSkateEditor, type SkateCoach, type SkateFilmer, type SkateEditor } from '@/lib/types'; +import { SKATE_COACH_KIND, SKATE_FILMER_KIND, SKATE_EDITOR_KIND, SKATE_PHOTOGRAPHER_KIND, parseSkateCoach, parseSkateFilmer, parseSkateEditor, parseSkatePhotographer, type SkateCoach, type SkateFilmer, type SkateEditor, type SkatePhotographer } from '@/lib/types'; import { useSkateRelays } from '@/hooks/useSkateRelays'; -export type LabProfile = (SkateCoach | SkateFilmer | SkateEditor) & { - profileType: 'coach' | 'filmer' | 'editor'; +export type LabProfile = (SkateCoach | SkateFilmer | SkateEditor | SkatePhotographer) & { + profileType: 'coach' | 'filmer' | 'editor' | 'photographer'; }; 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, SKATE_EDITOR_KIND], - '#t': ['skatecoach', 'skatefilmer', 'skateeditor'], + kinds: [SKATE_COACH_KIND, SKATE_FILMER_KIND, SKATE_EDITOR_KIND, SKATE_PHOTOGRAPHER_KIND], + '#t': ['skatecoach', 'skatefilmer', 'skateeditor', 'skatephotographer'], limit: limit * 2, }, ], @@ -46,10 +46,15 @@ export function useLabProfiles(limit = 100) { if (editor) { profiles.push({ ...editor, profileType: 'editor' }); } + } else if (event.kind === SKATE_PHOTOGRAPHER_KIND) { + const photographer = parseSkatePhotographer(event); + if (photographer) { + profiles.push({ ...photographer, profileType: 'photographer' }); + } } } - console.log(`[useLabProfiles] Parsed profiles: ${profiles.length} (coaches + filmers + editors)`); + console.log(`[useLabProfiles] Parsed profiles: ${profiles.length} (coaches + filmers + editors + photographers)`); return profiles.sort((a, b) => b.createdAt - a.createdAt); }, @@ -72,7 +77,7 @@ export function useLabProfile(identifier: string, pubkey?: string) { authors?: string[]; limit: number; } = { - kinds: [SKATE_COACH_KIND, SKATE_FILMER_KIND, SKATE_EDITOR_KIND], + kinds: [SKATE_COACH_KIND, SKATE_FILMER_KIND, SKATE_EDITOR_KIND, SKATE_PHOTOGRAPHER_KIND], '#d': [identifier], limit: 1, }; @@ -101,6 +106,9 @@ export function useLabProfile(identifier: string, pubkey?: string) { } else if (event.kind === SKATE_EDITOR_KIND) { const editor = parseSkateEditor(event); return editor ? { ...editor, profileType: 'editor' as const } : null; + } else if (event.kind === SKATE_PHOTOGRAPHER_KIND) { + const photographer = parseSkatePhotographer(event); + return photographer ? { ...photographer, profileType: 'photographer' as const } : null; } return null; diff --git a/src/lib/types.ts b/src/lib/types.ts index 46c69a1..f52ffb0 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -46,8 +46,9 @@ export const SKATE_SPOT_KIND = 30818; // Addressable event - editable per pubkey 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_FILMER_KIND = 35002; // Skate filmer (video) profiles export const SKATE_EDITOR_KIND = 35003; // Skate video editor profiles +export const SKATE_PHOTOGRAPHER_KIND = 35004; // Skate photographer 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 @@ -473,7 +474,8 @@ export const FILMING_SERVICE_LABELS: Record = { documentaries: 'Documentaries', contests: 'Filming for Contests', raw: 'Raw Footage', - photos: 'Action Photography', + followlines: 'Follow / Line Filming', + lines: 'Line & B-Roll Filming', }; export const FILMING_SERVICE_DESCRIPTIONS: Record = { @@ -483,28 +485,39 @@ export const FILMING_SERVICE_DESCRIPTIONS: Record = { documentaries: 'Skate culture and story-driven content', contests: 'Professional contest and competition coverage', raw: 'Unedited multi-angle footage', - photos: 'Sequence shots and peak moments', + followlines: 'Fisheye follow-cam for lines', + lines: 'Long-lens lines and cinematic b-roll', }; -// Filming skills/equipment +// Filming skills/equipment (video-focused) export const FILMING_SKILL_LABELS: Record = { vx1000: 'VX1000', - dslr: 'DSLR/Mirrorless', + hvx: 'HVX / HPX', + cinema: 'Cinema Camera', + dslr: 'DSLR / Mirrorless', + actioncam: 'GoPro / Action Cam', phone: 'Phone Filming', - drone: 'Drone/Aerial', + drone: 'Drone / Aerial', + gimbal: 'Gimbal / Stabilizer', fisheye: 'Fisheye Lens', longlens: 'Long Lens', + '4k': '4K / High Frame Rate', editing: 'Video Editing', color: 'Color Grading', }; export const FILMING_SKILL_DESCRIPTIONS: Record = { vx1000: 'Classic VX1000 skateboarding aesthetic', + hvx: 'Panasonic HVX/HPX standard-def / HD look', + cinema: 'Cinema camera (FX3, C-series, RED, BMPCC)', dslr: 'High quality DSLR or mirrorless camera', + actioncam: 'GoPro / action cam POV and mounts', phone: 'Mobile filming with modern smartphones', drone: 'Aerial and drone footage', + gimbal: 'Stabilized gimbal movement', fisheye: 'Wide-angle fisheye lens shots', longlens: 'Long lens for detailed shots', + '4k': '4K resolution and high frame rate slow-mo', editing: 'Professional video editing and post-production', color: 'Color correction and grading', }; @@ -555,6 +568,88 @@ export const EDITOR_SKILL_DESCRIPTIONS: Record = { sound: 'Music sync, SFX, audio mastering', }; +// ─── Photographer taxonomy ────────────────────────────────────────────────── +// Skate photography is a distinct craft from filming — it's shot-, light-, +// and print-oriented. These maps power The Lab's Photographers category. + +// What a photographer offers (deliverables / shoot types) +export const PHOTO_SERVICE_LABELS: Record = { + action: 'Action / Trick Shots', + sequence: 'Sequence Shots', + portraits: 'Portraits', + editorial: 'Editorial / Magazine', + product: 'Product / Shop Photography', + events: 'Event Coverage', + lifestyle: 'Lifestyle / Documentary', + prints: 'Prints & Zines', +}; + +export const PHOTO_SERVICE_DESCRIPTIONS: Record = { + action: 'Peak-action stills of tricks and lines', + sequence: 'Multi-frame trick sequences', + portraits: 'Rider portraits and team shots', + editorial: 'Magazine-ready editorial spreads', + product: 'Deck, gear and shop product photography', + events: 'Comp, jam and premiere coverage', + lifestyle: 'Behind-the-scenes and culture documentary', + prints: 'Physical prints, zines and photo books', +}; + +// Camera bodies / formats +export const PHOTO_CAMERA_LABELS: Record = { + dslr: 'DSLR', + mirrorless: 'Mirrorless', + medium: 'Medium Format', + film35: '35mm Film', + filmmedium: 'Medium Format Film', + point: 'Point & Shoot', + phone: 'Phone', +}; + +// Lenses +export const PHOTO_LENS_LABELS: Record = { + fisheye: 'Fisheye', + wide: 'Wide Angle', + standard: 'Standard / Prime', + telephoto: 'Telephoto / Long Lens', + macro: 'Macro', + zoom: 'Zoom', +}; + +// Lighting gear +export const PHOTO_LIGHTING_LABELS: Record = { + strobes: 'Studio Strobes', + speedlights: 'Speedlights / Flashes', + profoto: 'Profoto', + elinchrom: 'Elinchrom', + godox: 'Godox', + triggers: 'Remote Triggers', + continuous: 'Continuous / LED', + reflectors: 'Reflectors / Diffusers', + naturallight: 'Natural Light Only', +}; + +// Support / accessories +export const PHOTO_SUPPORT_LABELS: Record = { + tripod: 'Tripod', + monopod: 'Monopod', + lightstands: 'Light Stands', + gimbal: 'Gimbal', + bag: 'Mobile Rig / Bag', + laptop: 'On-site Tethering', +}; + +// Shooting style +export const PHOTO_STYLE_LABELS: Record = { + flashfisheye: 'Flash Fisheye', + natural: 'Natural Light', + bw: 'Black & White', + film: 'Film Aesthetic', + longlens: 'Long Lens', + studio: 'Studio / Lit', + street: 'Street / Documentary', +}; + export interface SkateCoach { id: string; pubkey: string; @@ -591,7 +686,9 @@ export interface SkateFilmer { phone?: string; email?: string; website?: string; + portfolio?: string; // Reel / portfolio link (Vimeo, YouTube, IG) social?: string; // Social media handle or link + gear?: string; // Freeform "what's in the bag" (cameras, lenses, rigs) spotRefs?: string[]; // Array of spot references: "30818:pubkey:d-tag" createdAt: number; event: NostrEvent; @@ -620,6 +717,29 @@ export interface SkateEditor { event: NostrEvent; } +export interface SkatePhotographer { + id: string; + pubkey: string; + identifier: string; + title: string; + bio: string; + geohash?: string; + location?: string; + image?: string; + skills: string[]; // Combined services + camera/lens/lighting/support/style tags + experience?: string; // Years of experience + rate?: string; + phone?: string; + email?: string; + website?: string; + portfolio?: string; // Portfolio link (Instagram, personal site, etc.) + social?: string; + gear?: string; // Freeform "what's in the bag" (bodies, lenses, lights) + spotRefs?: string[]; // Array of spot references: "30818:pubkey:d-tag" + createdAt: number; + event: NostrEvent; +} + export interface SkateTour { id: string; pubkey: string; @@ -746,7 +866,9 @@ export function parseSkateFilmer(event: NostrEvent): SkateFilmer | null { 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 gear = event.tags.find(([name]) => name === 'gear')?.[1]; // Extract spot references (a tags pointing to kind 30818 spots) const spotRefs = event.tags .filter(([name, value]) => name === 'a' && value?.startsWith('30818:')) @@ -767,7 +889,9 @@ export function parseSkateFilmer(event: NostrEvent): SkateFilmer | null { phone, email, website, + portfolio, social, + gear, spotRefs: spotRefs.length > 0 ? spotRefs : undefined, createdAt: event.created_at, event, @@ -821,6 +945,55 @@ export function parseSkateEditor(event: NostrEvent): SkateEditor | null { }; } +export function parseSkatePhotographer(event: NostrEvent): SkatePhotographer | null { + if (event.kind !== SKATE_PHOTOGRAPHER_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; + if (title.trim().length < MIN_TITLE_LEN || title.length > MAX_TITLE_LEN) return null; + + const location = clampStr(event.tags.find(([name]) => name === 'location')?.[1], MAX_LOCATION_LEN); + 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 !== 'skatephotographer').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 gear = event.tags.find(([name]) => name === 'gear')?.[1]; + const spotRefs = event.tags + .filter(([name, value]) => name === 'a' && value?.startsWith('30818:')) + .map(([, value]) => value); + + return { + id: event.id, + pubkey: event.pubkey, + identifier: d, + title: title.slice(0, MAX_TITLE_LEN), + bio: event.content.slice(0, MAX_DESCRIPTION_LEN), + location, + geohash, + image, + skills, + experience, + rate, + phone, + email, + website, + portfolio, + social, + gear, + spotRefs: spotRefs.length > 0 ? spotRefs : undefined, + 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/BecomeFilmerPage.tsx b/src/pages/BecomeFilmerPage.tsx index df2f69c..0d41c63 100644 --- a/src/pages/BecomeFilmerPage.tsx +++ b/src/pages/BecomeFilmerPage.tsx @@ -59,7 +59,9 @@ export default function BecomeFilmerPage() { const [phone, setPhone] = useState(''); const [email, setEmail] = useState(''); const [website, setWebsite] = useState(''); + const [portfolio, setPortfolio] = useState(''); const [social, setSocial] = useState(''); + const [gear, setGear] = useState(''); const [selectedSpots, setSelectedSpots] = useState([]); const [isGettingLocation, setIsGettingLocation] = useState(false); @@ -162,7 +164,9 @@ export default function BecomeFilmerPage() { 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 (gear.trim()) tags.push(['gear', gear.trim()]); // Add spot references for (const spotId of selectedSpots) { @@ -213,7 +217,7 @@ export default function BecomeFilmerPage() {

Become a Filmer

-

Offer your filming & editing services

+

Offer your skate video filming — clips, lines, full parts & contest coverage

{!hasNip05 && ( @@ -342,7 +346,7 @@ export default function BecomeFilmerPage() {
- +
{SKILL_ENTRIES.map(([skill, label]) => (
+ {/* Freeform gear list */} +
+ +