Rename "Action shot" to "Profile Pic" and add custom Level option for skaters
- Become-Skater form: photo section relabeled "Profile Pic" - Level picker now includes a "Custom" toggle with a free-text input (≤40 chars) for labels like "OG", "Weekend Warrior", "Retired" - Store custom level as a `level_custom` tag (mutually exclusive with preset `level`) - Render custom level everywhere presets show: profile (NIP19Page), SkatersPage cards, SkaterDirectoryCard - Document `level_custom` tag in NIP.md Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
parent
3ef11eda61
commit
4c9839eb45
6 changed files with 97 additions and 25 deletions
5
NIP.md
5
NIP.md
|
|
@ -1508,6 +1508,7 @@ exists purely for community discovery.
|
||||||
["alt", "SkateSpots skater: <display name>"],
|
["alt", "SkateSpots skater: <display name>"],
|
||||||
["stance", "regular | goofy"],
|
["stance", "regular | goofy"],
|
||||||
["level", "grom | intermediate | advanced | ripper"],
|
["level", "grom | intermediate | advanced | ripper"],
|
||||||
|
["level_custom", "<free-text level, ≤40 chars, mutually exclusive with `level`>"],
|
||||||
["t", "<style>"],
|
["t", "<style>"],
|
||||||
["years", "<years skating>"],
|
["years", "<years skating>"],
|
||||||
["home_spot", "<freeform home spot>"],
|
["home_spot", "<freeform home spot>"],
|
||||||
|
|
@ -1525,6 +1526,10 @@ Recognized `style` values (repeated `t` tags): `street`, `park`, `vert`,
|
||||||
`diy`. A valid profile requires the `d` tag, a `title`, and the `skateskater`
|
`diy`. A valid profile requires the `d` tag, a `title`, and the `skateskater`
|
||||||
marker tag.
|
marker tag.
|
||||||
|
|
||||||
|
Level is either one of the four presets via the `level` tag, **or** a
|
||||||
|
free-text label via `level_custom` (max 40 chars) — clients should treat these
|
||||||
|
as mutually exclusive and prefer `level` when both are present.
|
||||||
|
|
||||||
## The Lab — Service Profiles
|
## The Lab — Service Profiles
|
||||||
|
|
||||||
The Lab connects skaters with service providers. Each provider type is an
|
The Lab connects skaters with service providers. Each provider type is an
|
||||||
|
|
|
||||||
|
|
@ -54,14 +54,18 @@ export function SkaterDirectoryCard() {
|
||||||
You're in the Skater Directory
|
You're in the Skater Directory
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-wrap items-center gap-1.5 mt-1.5">
|
<div className="flex flex-wrap items-center gap-1.5 mt-1.5">
|
||||||
{level && (
|
{level ? (
|
||||||
<span
|
<span
|
||||||
className="text-[10px] font-black uppercase tracking-wide px-2 py-0.5 rounded-full text-black"
|
className="text-[10px] font-black uppercase tracking-wide px-2 py-0.5 rounded-full text-black"
|
||||||
style={{ backgroundColor: level.color }}
|
style={{ backgroundColor: level.color }}
|
||||||
>
|
>
|
||||||
{level.emoji} {level.label}
|
{level.emoji} {level.label}
|
||||||
</span>
|
</span>
|
||||||
)}
|
) : skater.customLevel ? (
|
||||||
|
<span className="text-[10px] font-black uppercase tracking-wide px-2 py-0.5 rounded-full bg-[#ccff00] text-black">
|
||||||
|
✏️ {skater.customLevel}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
{skater.styles.slice(0, 3).map((s) => (
|
{skater.styles.slice(0, 3).map((s) => (
|
||||||
<span
|
<span
|
||||||
key={s}
|
key={s}
|
||||||
|
|
|
||||||
|
|
@ -339,6 +339,7 @@ export interface Skater {
|
||||||
stance?: SkaterStance;
|
stance?: SkaterStance;
|
||||||
styles: SkaterStyle[]; // preferred terrain/disciplines
|
styles: SkaterStyle[]; // preferred terrain/disciplines
|
||||||
level?: SkaterLevel;
|
level?: SkaterLevel;
|
||||||
|
customLevel?: string; // free-text level when none of the presets fit
|
||||||
yearsSkating?: string;
|
yearsSkating?: string;
|
||||||
homeSpot?: string; // freeform, or a spot ref
|
homeSpot?: string; // freeform, or a spot ref
|
||||||
instagram?: string;
|
instagram?: string;
|
||||||
|
|
@ -377,6 +378,7 @@ export function parseSkater(event: NostrEvent): Skater | null {
|
||||||
stance: stance && SKATER_STANCES[stance] ? stance : undefined,
|
stance: stance && SKATER_STANCES[stance] ? stance : undefined,
|
||||||
styles,
|
styles,
|
||||||
level: level && SKATER_LEVELS[level] ? level : undefined,
|
level: level && SKATER_LEVELS[level] ? level : undefined,
|
||||||
|
customLevel: tag('level_custom')?.slice(0, 40),
|
||||||
yearsSkating: tag('years'),
|
yearsSkating: tag('years'),
|
||||||
homeSpot: tag('home_spot'),
|
homeSpot: tag('home_spot'),
|
||||||
instagram: tag('instagram'),
|
instagram: tag('instagram'),
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,9 @@ export default function BecomeSkaterPage() {
|
||||||
const [stance, setStance] = useState<SkaterStance | undefined>();
|
const [stance, setStance] = useState<SkaterStance | undefined>();
|
||||||
const [styles, setStyles] = useState<SkaterStyle[]>([]);
|
const [styles, setStyles] = useState<SkaterStyle[]>([]);
|
||||||
const [level, setLevel] = useState<SkaterLevel | undefined>();
|
const [level, setLevel] = useState<SkaterLevel | undefined>();
|
||||||
|
// When set, the skater is using a free-text level instead of a preset.
|
||||||
|
const [customLevel, setCustomLevel] = useState('');
|
||||||
|
const [levelMode, setLevelMode] = useState<'preset' | 'custom'>('preset');
|
||||||
const [years, setYears] = useState('');
|
const [years, setYears] = useState('');
|
||||||
const [homeSpot, setHomeSpot] = useState('');
|
const [homeSpot, setHomeSpot] = useState('');
|
||||||
const [instagram, setInstagram] = useState('');
|
const [instagram, setInstagram] = useState('');
|
||||||
|
|
@ -80,6 +83,10 @@ export default function BecomeSkaterPage() {
|
||||||
setStance(existing.stance);
|
setStance(existing.stance);
|
||||||
setStyles(existing.styles || []);
|
setStyles(existing.styles || []);
|
||||||
setLevel(existing.level);
|
setLevel(existing.level);
|
||||||
|
if (existing.customLevel && !existing.level) {
|
||||||
|
setCustomLevel(existing.customLevel);
|
||||||
|
setLevelMode('custom');
|
||||||
|
}
|
||||||
setYears(existing.yearsSkating || '');
|
setYears(existing.yearsSkating || '');
|
||||||
setHomeSpot(existing.homeSpot || '');
|
setHomeSpot(existing.homeSpot || '');
|
||||||
setInstagram(existing.instagram || '');
|
setInstagram(existing.instagram || '');
|
||||||
|
|
@ -158,7 +165,11 @@ export default function BecomeSkaterPage() {
|
||||||
if (!isNaN(lat) && !isNaN(lng)) tags.push(['g', ngeohash.encode(lat, lng, 7)]);
|
if (!isNaN(lat) && !isNaN(lng)) tags.push(['g', ngeohash.encode(lat, lng, 7)]);
|
||||||
}
|
}
|
||||||
if (stance) tags.push(['stance', stance]);
|
if (stance) tags.push(['stance', stance]);
|
||||||
if (level) tags.push(['level', level]);
|
if (levelMode === 'custom' && customLevel.trim()) {
|
||||||
|
tags.push(['level_custom', customLevel.trim().slice(0, 40)]);
|
||||||
|
} else if (levelMode === 'preset' && level) {
|
||||||
|
tags.push(['level', level]);
|
||||||
|
}
|
||||||
for (const s of styles) tags.push(['t', s]);
|
for (const s of styles) tags.push(['t', s]);
|
||||||
if (years.trim()) tags.push(['years', years.trim()]);
|
if (years.trim()) tags.push(['years', years.trim()]);
|
||||||
if (homeSpot.trim()) tags.push(['home_spot', homeSpot.trim()]);
|
if (homeSpot.trim()) tags.push(['home_spot', homeSpot.trim()]);
|
||||||
|
|
@ -319,7 +330,7 @@ export default function BecomeSkaterPage() {
|
||||||
|
|
||||||
{/* Photo */}
|
{/* Photo */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-sm font-bold uppercase tracking-wide text-gray-400">Action shot</Label>
|
<Label className="text-sm font-bold uppercase tracking-wide text-gray-400">Profile Pic</Label>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
{image ? (
|
{image ? (
|
||||||
<div className="relative w-28 h-28 rounded-2xl overflow-hidden bg-gray-800 flex-shrink-0">
|
<div className="relative w-28 h-28 rounded-2xl overflow-hidden bg-gray-800 flex-shrink-0">
|
||||||
|
|
@ -417,23 +428,65 @@ export default function BecomeSkaterPage() {
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Label className="text-sm font-bold uppercase tracking-wide text-gray-400">Level</Label>
|
<Label className="text-sm font-bold uppercase tracking-wide text-gray-400">Level</Label>
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
|
<div className="grid grid-cols-2 sm:grid-cols-5 gap-2">
|
||||||
{levelEntries.map(([key, { label, emoji, color }]) => (
|
{levelEntries.map(([key, { label, emoji, color }]) => {
|
||||||
<button
|
const active = levelMode === 'preset' && level === key;
|
||||||
key={key}
|
return (
|
||||||
type="button"
|
<button
|
||||||
onClick={() => setLevel(level === key ? undefined : key)}
|
key={key}
|
||||||
className={cn(
|
type="button"
|
||||||
'px-3 py-2.5 rounded-xl text-sm font-black border transition-all text-center',
|
onClick={() => {
|
||||||
level === key ? 'text-black' : 'bg-white/5 border-white/10 text-gray-300 hover:border-white/30',
|
setLevelMode('preset');
|
||||||
)}
|
setLevel(active ? undefined : key);
|
||||||
style={level === key ? { backgroundColor: color, borderColor: color } : undefined}
|
}}
|
||||||
>
|
className={cn(
|
||||||
<div className="text-lg leading-none mb-0.5">{emoji}</div>
|
'px-3 py-2.5 rounded-xl text-sm font-black border transition-all text-center',
|
||||||
{label}
|
active ? 'text-black' : 'bg-white/5 border-white/10 text-gray-300 hover:border-white/30',
|
||||||
</button>
|
)}
|
||||||
))}
|
style={active ? { backgroundColor: color, borderColor: color } : undefined}
|
||||||
|
>
|
||||||
|
<div className="text-lg leading-none mb-0.5">{emoji}</div>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{/* Custom level toggle */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (levelMode === 'custom') {
|
||||||
|
setLevelMode('preset');
|
||||||
|
} else {
|
||||||
|
setLevelMode('custom');
|
||||||
|
setLevel(undefined);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'px-3 py-2.5 rounded-xl text-sm font-black border transition-all text-center',
|
||||||
|
levelMode === 'custom'
|
||||||
|
? 'bg-[#ccff00] border-[#ccff00] text-black'
|
||||||
|
: 'bg-white/5 border-white/10 text-gray-300 hover:border-[#ccff00]/40',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="text-lg leading-none mb-0.5">✏️</div>
|
||||||
|
Custom
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{levelMode === 'custom' && (
|
||||||
|
<div className="pt-1">
|
||||||
|
<Input
|
||||||
|
id="custom-level"
|
||||||
|
placeholder="e.g. OG, Weekend Warrior, Retired…"
|
||||||
|
value={customLevel}
|
||||||
|
maxLength={40}
|
||||||
|
onChange={(e) => setCustomLevel(e.target.value)}
|
||||||
|
className="border-[#ccff00]/30 focus-visible:ring-[#ccff00]/40"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1.5">
|
||||||
|
Your own label — shown on your skater card instead of a preset.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
|
|
||||||
|
|
@ -88,7 +88,7 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
|
||||||
const levelMeta = skater?.level ? SKATER_LEVELS[skater.level] : undefined;
|
const levelMeta = skater?.level ? SKATER_LEVELS[skater.level] : undefined;
|
||||||
const skaterWebsite = skater?.website || website;
|
const skaterWebsite = skater?.website || website;
|
||||||
const hasSkateLayer = !!skater && (
|
const hasSkateLayer = !!skater && (
|
||||||
!!stanceMeta || !!levelMeta || skater.styles.length > 0 ||
|
!!stanceMeta || !!levelMeta || !!skater.customLevel || skater.styles.length > 0 ||
|
||||||
!!skater.yearsSkating || !!skater.homeSpot
|
!!skater.yearsSkating || !!skater.homeSpot
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -247,14 +247,18 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
|
||||||
Skater
|
Skater
|
||||||
</h2>
|
</h2>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{levelMeta && (
|
{levelMeta ? (
|
||||||
<span
|
<span
|
||||||
className="text-[10px] font-black uppercase tracking-wide px-2.5 py-1 rounded-full text-black"
|
className="text-[10px] font-black uppercase tracking-wide px-2.5 py-1 rounded-full text-black"
|
||||||
style={{ backgroundColor: levelMeta.color }}
|
style={{ backgroundColor: levelMeta.color }}
|
||||||
>
|
>
|
||||||
{levelMeta.emoji} {levelMeta.label}
|
{levelMeta.emoji} {levelMeta.label}
|
||||||
</span>
|
</span>
|
||||||
)}
|
) : skater?.customLevel ? (
|
||||||
|
<span className="text-[10px] font-black uppercase tracking-wide px-2.5 py-1 rounded-full bg-[#ccff00] text-black">
|
||||||
|
✏️ {skater.customLevel}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
{isOwnProfile && (
|
{isOwnProfile && (
|
||||||
<Link
|
<Link
|
||||||
to="/become-skater"
|
to="/become-skater"
|
||||||
|
|
|
||||||
|
|
@ -49,14 +49,18 @@ function SkaterCard({ skater }: { skater: Skater }) {
|
||||||
{name.slice(0, 2).toUpperCase()}
|
{name.slice(0, 2).toUpperCase()}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
{level && (
|
{level ? (
|
||||||
<span
|
<span
|
||||||
className="absolute -bottom-1.5 left-1/2 -translate-x-1/2 text-[9px] font-black uppercase tracking-wide px-1.5 py-0.5 rounded-full text-black whitespace-nowrap"
|
className="absolute -bottom-1.5 left-1/2 -translate-x-1/2 text-[9px] font-black uppercase tracking-wide px-1.5 py-0.5 rounded-full text-black whitespace-nowrap"
|
||||||
style={{ backgroundColor: level.color }}
|
style={{ backgroundColor: level.color }}
|
||||||
>
|
>
|
||||||
{level.emoji} {level.label}
|
{level.emoji} {level.label}
|
||||||
</span>
|
</span>
|
||||||
)}
|
) : skater.customLevel ? (
|
||||||
|
<span className="absolute -bottom-1.5 left-1/2 -translate-x-1/2 text-[9px] font-black uppercase tracking-wide px-1.5 py-0.5 rounded-full text-black whitespace-nowrap bg-[#ccff00] max-w-[90px] truncate">
|
||||||
|
✏️ {skater.customLevel}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="min-w-0 flex-1 pt-0.5">
|
<div className="min-w-0 flex-1 pt-0.5">
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue