Made with ❤️ for the skateboarding community
-
-
Not sponsored by
-
-
- OpenSats
+
+
+ License
+
+
+ Supporters
+
+
+ Not sponsored by
+
+
+ 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.
+
+
+ Add NIP-05 in Settings
+
+
+
+
+
+
+ )}
+
+
+
+
+
+
+
⚡ Lightning Wallet for Payments
+
Receive Bitcoin ⚡ payments directly from clients for your editing work.
+ {hasWallet ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/pages/LabPage.tsx b/src/pages/LabPage.tsx
index 2f0d0b4..ea2ff27 100644
--- a/src/pages/LabPage.tsx
+++ b/src/pages/LabPage.tsx
@@ -15,12 +15,14 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useBatchTrustScores } from '@/hooks/useBatchTrustScores';
import { useTranslation } from '@/contexts/LanguageContext';
import { genUserName } from '@/lib/genUserName';
-import { COACH_SKILL_LABELS, COACH_SKILL_DESCRIPTIONS, FILMING_SERVICE_LABELS, FILMING_SERVICE_DESCRIPTIONS, FILMING_SKILL_LABELS } from '@/lib/types';
-import { GraduationCap, Search, Plus, MapPin, Filter, Video } from 'lucide-react';
+import { COACH_SKILL_LABELS, COACH_SKILL_DESCRIPTIONS, FILMING_SERVICE_LABELS, FILMING_SERVICE_DESCRIPTIONS, FILMING_SKILL_LABELS, EDITOR_SERVICE_LABELS, EDITOR_SERVICE_DESCRIPTIONS, EDITOR_SKILL_LABELS } from '@/lib/types';
+import { GraduationCap, Search, Plus, MapPin, Filter, Video, Scissors } from 'lucide-react';
import { cn } from '@/lib/utils';
const COACH_SKILL_ENTRIES = Object.entries(COACH_SKILL_LABELS);
const FILMING_SERVICE_ENTRIES = Object.entries(FILMING_SERVICE_LABELS);
+const EDITOR_SERVICE_ENTRIES = Object.entries(EDITOR_SERVICE_LABELS);
+const EDITOR_SKILL_ENTRIES = Object.entries(EDITOR_SKILL_LABELS);
export default function LabPage() {
const { t } = useTranslation();
@@ -34,7 +36,7 @@ export default function LabPage() {
const { data: profiles, isLoading } = useLabProfiles(100);
const [searchQuery, setSearchQuery] = useState('');
const [selectedSkills, setSelectedSkills] = useState([]);
- const [serviceType, setServiceType] = useState<'coaching' | 'filming'>('coaching');
+ const [serviceType, setServiceType] = useState<'coaching' | 'filming' | 'editing'>('coaching');
const [trustFilter, setTrustFilter] = useState('all');
const [sortBy, setSortBy] = useState('recent');
@@ -57,6 +59,7 @@ export default function LabPage() {
// Filter by service type
if (serviceType === 'coaching' && profile.profileType !== 'coach') return false;
if (serviceType === 'filming' && profile.profileType !== 'filmer') return false;
+ if (serviceType === 'editing' && profile.profileType !== 'editor') return false;
if (searchQuery) {
const query = searchQuery.toLowerCase();
@@ -108,7 +111,7 @@ export default function LabPage() {
{t('labPage.subtitle')}
{user ? (
-
-
+
{serviceType === 'coaching' && t('labPage.whatToLearn')}
{serviceType === 'filming' && t('labPage.whatContent')}
+ {serviceType === 'editing' && 'Edit Style & Tools'}
{serviceType === 'coaching' && t('labPage.selectSkillsDesc')}
{serviceType === 'filming' && t('labPage.selectFilmingDesc')}
+ {serviceType === 'editing' && 'Filter by edit type or software/hardware the editor uses.'}
@@ -215,6 +240,41 @@ export default function LabPage() {
))}
+
+ {/* Show editor services when editing */}
+ {serviceType === 'editing' && EDITOR_SERVICE_ENTRIES.map(([service, label]) => (
+
toggleSkill(service)}
+ className={cn(
+ 'p-3 rounded-lg text-left transition-all border text-sm',
+ selectedSkills.includes(service)
+ ? 'bg-purple-600 text-white border-purple-600'
+ : 'bg-gray-800 hover:bg-gray-700 border-gray-700 text-gray-300'
+ )}
+ >
+ {label}
+
+ {EDITOR_SERVICE_DESCRIPTIONS[service]}
+
+
+ ))}
+
+ {/* Show editor tool skills when editing (second row) */}
+ {serviceType === 'editing' && EDITOR_SKILL_ENTRIES.map(([skill, label]) => (
+
toggleSkill(skill)}
+ className={cn(
+ 'p-3 rounded-lg text-left transition-all border text-sm',
+ selectedSkills.includes(skill)
+ ? 'bg-purple-500 text-white border-purple-500'
+ : 'bg-gray-900 hover:bg-gray-800 border-purple-900/50 text-gray-400'
+ )}
+ >
+ {label}
+
+ ))}
diff --git a/src/pages/LicensePage.tsx b/src/pages/LicensePage.tsx
new file mode 100644
index 0000000..bbd0d7e
--- /dev/null
+++ b/src/pages/LicensePage.tsx
@@ -0,0 +1,95 @@
+import { useSeoMeta } from '@unhead/react';
+import { Layout } from '@/components/Layout';
+import { Link } from 'react-router-dom';
+import { Infinity, Scale, Skateboard } from 'lucide-react';
+
+export default function LicensePage() {
+ useSeoMeta({
+ title: 'License - SkateSpots',
+ description: 'SkateSpots license. You are free to skateboard.',
+ });
+
+ return (
+
+
+ {/* Big skate emoji hero */}
+
+
+ 🛹
+
+
+ Skate {' '}
+ Spots
+
+
+ Skate, earn, have fun and spend Sats wherever you are.
+
+
+
+ {/* Copyright */}
+
+
+ Copyright © 2025–
+ ∞
+
+
+ SkateSpots contributors & the community
+
+
+
+ {/* License card */}
+
+ {/* Glow strip */}
+
+
+
+
+
License
+
+
+ YOU ARE FREE
+
+ TO SKATEBOARD
+
+
+
+ This project is open. Share it, fork it, build on it, skate it.
+ The streets belong to everyone. So does this code.
+ Just keep skating and don't be a kook.
+
+
+
+
Built with ❤️ on Nostr · Powered by Bitcoin Lightning ⚡
+
+
+ View source
+
+ {' '}·{' '}
+
+ About SkateSpots
+
+ {' '}·{' '}
+
+ Supporters
+
+
+
+
+
+ {/* MIT note */}
+
+ The source code is also available under the MIT license for those who need it.
+
+
+
+ );
+}
diff --git a/src/pages/MapPage.tsx b/src/pages/MapPage.tsx
index 6f78213..c70383b 100644
--- a/src/pages/MapPage.tsx
+++ b/src/pages/MapPage.tsx
@@ -811,12 +811,12 @@ export default function MapPage() {
Add Spot
-
setMobileFiltersOpen(false)}>
-
-
- Sell Gear
-
-
+
setMobileFiltersOpen(false)}>
+
+
+ Buy/Sell Gear
+
+
setMobileFiltersOpen(false)}>
diff --git a/src/pages/StashPage.tsx b/src/pages/StashPage.tsx
index aa59381..32aca5e 100644
--- a/src/pages/StashPage.tsx
+++ b/src/pages/StashPage.tsx
@@ -224,6 +224,22 @@ function StashItemCard({ item, onEdit, onDelete, onListOnMarketplace, isWTB = fa
+ {/* Storage location badge */}
+ {!isWTB && item.storageLocation && item.storageLocation !== 'other' && (
+
+
+ {{
+ home: '🏠 Home',
+ warehouse: '🏭 Warehouse',
+ friends: "👫 Friend's",
+ parents: "👨👩👦 Parents'",
+ wardrobe: '🚪 Wardrobe',
+ other: '📦 Other',
+ }[item.storageLocation]}
+
+
+ )}
+
{/* Condition stars */}
{!isWTB && (
@@ -452,6 +468,7 @@ function AddItemDialog({ onAdd, mode = 'stash' }: { onAdd: (item: Omit
+ {/* Storage Location */}
+ {!isWTB && (
+
+
+
+ Where is this item?
+
+
+ {([
+ { value: 'home', label: '🏠 Home' },
+ { value: 'warehouse', label: '🏭 Warehouse' },
+ { value: 'friends', label: "👫 Friend's" },
+ { value: 'parents', label: "👨👩👦 Parents'" },
+ { value: 'wardrobe', label: '🚪 Wardrobe' },
+ { value: 'other', label: '📦 Other' },
+ ] as const).map(({ value, label }) => (
+ setFormData({ ...formData, storageLocation: value })}
+ className={cn(
+ 'p-2 rounded-lg text-xs font-bold text-center transition-all border',
+ formData.storageLocation === value
+ ? 'bg-[#ff6600] border-[#ff6600] text-black'
+ : 'bg-black/40 border-white/10 text-gray-400 hover:border-[#ff6600]/50 hover:text-white'
+ )}
+ >
+ {label}
+
+ ))}
+
+
+ )}
+
{/* Visibility */}
Visibility
@@ -1264,6 +1315,7 @@ export default function StashPage() {
const { items, stats, isLoading, addItem, updateItem, removeItem, isEncryptionAvailable } = useStash();
const [searchQuery, setSearchQuery] = useState('');
const [filterCategory, setFilterCategory] = useState('all');
+ const [filterLocation, setFilterLocation] = useState('all');
const [activeTab, setActiveTab] = useState<'stash' | 'wishlist'>('stash');
// Edit dialog state
@@ -1329,6 +1381,9 @@ export default function StashPage() {
if (filterCategory !== 'all' && item.category !== filterCategory) {
return false;
}
+ if (filterLocation !== 'all' && item.storageLocation !== filterLocation) {
+ return false;
+ }
return true;
});
@@ -1502,6 +1557,37 @@ export default function StashPage() {
+ {/* Location filter chips — only show for stash tab */}
+ {activeTab === 'stash' && (
+
+
+ Location:
+
+ {([
+ { value: 'all', label: 'All' },
+ { value: 'home', label: '🏠 Home' },
+ { value: 'warehouse', label: '🏭 Warehouse' },
+ { value: 'friends', label: "👫 Friend's" },
+ { value: 'parents', label: "👨👩👦 Parents'" },
+ { value: 'wardrobe', label: '🚪 Wardrobe' },
+ { value: 'other', label: '📦 Other' },
+ ] as const).map(({ value, label }) => (
+ setFilterLocation(value)}
+ className={cn(
+ 'px-2.5 py-1 rounded-full text-xs font-bold transition-all border',
+ filterLocation === value
+ ? 'bg-[#ff6600] border-[#ff6600] text-black'
+ : 'bg-black/40 border-white/10 text-gray-400 hover:border-[#ff6600]/50 hover:text-white'
+ )}
+ >
+ {label}
+
+ ))}
+
+ )}
+
{/* Items grid */}
{isLoading ? (
diff --git a/src/pages/SupportersPage.tsx b/src/pages/SupportersPage.tsx
new file mode 100644
index 0000000..e612a53
--- /dev/null
+++ b/src/pages/SupportersPage.tsx
@@ -0,0 +1,296 @@
+import { useSeoMeta } from '@unhead/react';
+import { useQuery } from '@tanstack/react-query';
+import { Layout } from '@/components/Layout';
+import { Link } from 'react-router-dom';
+import { ExternalLink, Zap, Heart, Users } from 'lucide-react';
+
+const GEYSER_PROJECT_URL = 'https://geyser.fund/project/skatespots?hero=satsnfacts';
+const GEYSER_LEADERBOARD_URL = 'https://geyser.fund/project/skatespots/leaderboard';
+const GEYSER_API_URL = 'https://api.geyser.fund/graphql';
+
+interface GeyserContributor {
+ user: {
+ id: string;
+ username: string;
+ imageUrl?: string;
+ heroLink?: string;
+ };
+ amount: number; // sats
+ funder?: boolean;
+}
+
+function formatSats(sats: number): string {
+ if (sats >= 1_000_000) return `${(sats / 1_000_000).toFixed(2)}M`;
+ if (sats >= 1_000) return `${Math.round(sats / 1_000).toLocaleString()}k`;
+ return sats.toLocaleString();
+}
+
+function useGeyserLeaderboard() {
+ return useQuery
({
+ queryKey: ['geyser-leaderboard'],
+ queryFn: async () => {
+ try {
+ const res = await fetch(GEYSER_API_URL, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ query: `{
+ projectGet(where: { name: "skatespots" }) {
+ funders(input: { orderBy: { amountFunded: desc }, pagination: { take: 100 } }) {
+ amountFunded
+ user {
+ id
+ username
+ imageUrl
+ heroLink
+ }
+ }
+ }
+ }`,
+ }),
+ });
+ const json = await res.json();
+ const funders = json?.data?.projectGet?.funders ?? [];
+ return funders.map((f: { amountFunded: number; user: GeyserContributor['user'] }) => ({
+ user: f.user,
+ amount: f.amountFunded,
+ }));
+ } catch {
+ return [];
+ }
+ },
+ staleTime: 5 * 60 * 1000, // 5 min cache
+ retry: false,
+ });
+}
+
+function SupporterCard({ contributor }: { contributor: GeyserContributor }) {
+ const isAnon = !contributor.user.username || contributor.user.username.toLowerCase().startsWith('anon');
+ const profileUrl = contributor.user.heroLink || `https://geyser.fund/user/${contributor.user.id}`;
+
+ const card = (
+
+
+ {contributor.user.imageUrl ? (
+
+ ) : (
+
+ 🛹
+
+ )}
+
+
+
+
+
+ {isAnon ? 'Anon 🛹' : contributor.user.username}
+
+
+ {formatSats(contributor.amount)} sats
+
+
+ );
+
+ if (isAnon) return card;
+
+ return (
+
+ {card}
+
+ );
+}
+
+export default function SupportersPage() {
+ useSeoMeta({
+ title: 'Supporters - SkateSpots',
+ description: 'The community keeping SkateSpots alive. Powered by Geyser and Bitcoin Lightning.',
+ });
+
+ const { data: contributors = [], isLoading } = useGeyserLeaderboard();
+
+ return (
+
+
+
+ {/* Hero */}
+
+
⚡
+
+ OUR {' '}
+ SUPPORTERS
+
+
+ SkateSpots is community-powered. These skaters and Bitcoiners help keep the project free, decentralized, and built for the streets.
+
+
+
+ Become a Supporter
+
+
+
+
+ {/* Individuals section */}
+
+
+
+ Every sat counts — ordered by cumulative contributions.
+
+
+ {isLoading ? (
+
+ {Array.from({ length: 12 }).map((_, i) => (
+
+ ))}
+
+ ) : contributors.length > 0 ? (
+
+ {contributors.map((c, i) => (
+
+ ))}
+
+ ) : (
+
+
🛹
+
Be the first supporter!
+
+ The leaderboard will update once contributions come in.
+
+
+ )}
+
+ {/* Geyser leaderboard link */}
+
+
+ {/* Powered by Geyser */}
+
+
+
+ {/* Mission section */}
+
+
+
+
+
+
+
+
+
+ Why We Need Support
+
+
+ SkateSpots is 100% free, open-source, and built on Nostr — a decentralized protocol with no central server to shut it down.
+ We don't run ads, sell your data, or answer to investors. Keeping the lights on and building new features relies entirely on the skate community.
+ Every sat you contribute goes directly toward development, relay hosting, and keeping this tool free for every skater on earth.
+
+
+
+ {[
+ { emoji: '🛹', title: 'Nostr-native', desc: 'No central server. Your data lives on relays you choose.' },
+ { emoji: '⚡', title: 'Bitcoin powered', desc: 'Earn sats for mapping spots. Spend them at local shops.' },
+ { emoji: '🌍', title: 'Free forever', desc: 'Open source, no paywalls, no tracking, no VC funding.' },
+ ].map(({ emoji, title, desc }) => (
+
+
{emoji}
+
{title}
+
{desc}
+
+ ))}
+
+
+
+
+ {/* Payment options */}
+
+
+
+ Send Sats
+
+
+
+
+ Lightning
+
+
+ skatespots@goskatespots.nostr1.com
+
+
Any Lightning wallet supporting LNURL
+
+
+
Via Geyser
+
+ Contribute via the Geyser campaign — your name goes on the leaderboard above.
+
+
+ Open campaign
+
+
+
+
+
+ {/* Footer links */}
+
+
Thank you. Every sat keeps the spots on the map. 🛹⚡
+
+ License
+ {' '}·{' '}
+ About
+ {' '}·{' '}
+ FAQ
+
+
+
+
+ );
+}