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:
shakespeare.diy 2026-09-02 21:34:59 +07:00
parent 3ef11eda61
commit 4c9839eb45
6 changed files with 97 additions and 25 deletions

5
NIP.md
View file

@ -1508,6 +1508,7 @@ exists purely for community discovery.
["alt", "SkateSpots skater: <display name>"],
["stance", "regular | goofy"],
["level", "grom | intermediate | advanced | ripper"],
["level_custom", "<free-text level, ≤40 chars, mutually exclusive with `level`>"],
["t", "<style>"],
["years", "<years skating>"],
["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`
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 connects skaters with service providers. Each provider type is an

View file

@ -54,14 +54,18 @@ export function SkaterDirectoryCard() {
You're in the Skater Directory
</p>
<div className="flex flex-wrap items-center gap-1.5 mt-1.5">
{level && (
{level ? (
<span
className="text-[10px] font-black uppercase tracking-wide px-2 py-0.5 rounded-full text-black"
style={{ backgroundColor: level.color }}
>
{level.emoji} {level.label}
</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) => (
<span
key={s}

View file

@ -339,6 +339,7 @@ export interface Skater {
stance?: SkaterStance;
styles: SkaterStyle[]; // preferred terrain/disciplines
level?: SkaterLevel;
customLevel?: string; // free-text level when none of the presets fit
yearsSkating?: string;
homeSpot?: string; // freeform, or a spot ref
instagram?: string;
@ -377,6 +378,7 @@ export function parseSkater(event: NostrEvent): Skater | null {
stance: stance && SKATER_STANCES[stance] ? stance : undefined,
styles,
level: level && SKATER_LEVELS[level] ? level : undefined,
customLevel: tag('level_custom')?.slice(0, 40),
yearsSkating: tag('years'),
homeSpot: tag('home_spot'),
instagram: tag('instagram'),

View file

@ -60,6 +60,9 @@ export default function BecomeSkaterPage() {
const [stance, setStance] = useState<SkaterStance | undefined>();
const [styles, setStyles] = useState<SkaterStyle[]>([]);
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 [homeSpot, setHomeSpot] = useState('');
const [instagram, setInstagram] = useState('');
@ -80,6 +83,10 @@ export default function BecomeSkaterPage() {
setStance(existing.stance);
setStyles(existing.styles || []);
setLevel(existing.level);
if (existing.customLevel && !existing.level) {
setCustomLevel(existing.customLevel);
setLevelMode('custom');
}
setYears(existing.yearsSkating || '');
setHomeSpot(existing.homeSpot || '');
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 (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]);
if (years.trim()) tags.push(['years', years.trim()]);
if (homeSpot.trim()) tags.push(['home_spot', homeSpot.trim()]);
@ -319,7 +330,7 @@ export default function BecomeSkaterPage() {
{/* Photo */}
<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">
{image ? (
<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">
<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">
{levelEntries.map(([key, { label, emoji, color }]) => (
<button
key={key}
type="button"
onClick={() => setLevel(level === key ? undefined : key)}
className={cn(
'px-3 py-2.5 rounded-xl text-sm font-black border transition-all text-center',
level === key ? 'text-black' : 'bg-white/5 border-white/10 text-gray-300 hover:border-white/30',
)}
style={level === key ? { backgroundColor: color, borderColor: color } : undefined}
>
<div className="text-lg leading-none mb-0.5">{emoji}</div>
{label}
</button>
))}
<div className="grid grid-cols-2 sm:grid-cols-5 gap-2">
{levelEntries.map(([key, { label, emoji, color }]) => {
const active = levelMode === 'preset' && level === key;
return (
<button
key={key}
type="button"
onClick={() => {
setLevelMode('preset');
setLevel(active ? undefined : key);
}}
className={cn(
'px-3 py-2.5 rounded-xl text-sm font-black border transition-all text-center',
active ? 'text-black' : 'bg-white/5 border-white/10 text-gray-300 hover:border-white/30',
)}
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>
{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 className="grid grid-cols-2 gap-4">

View file

@ -88,7 +88,7 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
const levelMeta = skater?.level ? SKATER_LEVELS[skater.level] : undefined;
const skaterWebsite = skater?.website || website;
const hasSkateLayer = !!skater && (
!!stanceMeta || !!levelMeta || skater.styles.length > 0 ||
!!stanceMeta || !!levelMeta || !!skater.customLevel || skater.styles.length > 0 ||
!!skater.yearsSkating || !!skater.homeSpot
);
@ -247,14 +247,18 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
Skater
</h2>
<div className="flex items-center gap-3">
{levelMeta && (
{levelMeta ? (
<span
className="text-[10px] font-black uppercase tracking-wide px-2.5 py-1 rounded-full text-black"
style={{ backgroundColor: levelMeta.color }}
>
{levelMeta.emoji} {levelMeta.label}
</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 && (
<Link
to="/become-skater"

View file

@ -49,14 +49,18 @@ function SkaterCard({ skater }: { skater: Skater }) {
{name.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
{level && (
{level ? (
<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"
style={{ backgroundColor: level.color }}
>
{level.emoji} {level.label}
</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 className="min-w-0 flex-1 pt-0.5">