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>"],
|
||||
["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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Reference in a new issue