Files
krow_talent_app/src/components/forge/ChallengeDirectory.jsx
2026-08-11 19:17:40 +05:30

192 lines
7.9 KiB
JavaScript

import React from 'react';
import { Link } from 'react-router-dom';
import { ArrowRight, Lock, Search, ShieldCheck } from 'lucide-react';
import { cn } from '@/lib/utils';
import { isUnlocked } from '@/lib/provingGround';
import { DIFF_TONE, TYPE_ICON, TYPE_LABEL, typeOf } from './challengeMeta';
/**
* Every other challenge, as a directory.
*
* Rows on hairlines rather than a wall of cards: these are secondary to the
* featured skill above, and a grid of bordered tiles would compete with it. The
* controls sit on the heading line so the section needs no toolbar surface of
* its own, and the row still carries everything a card did — skill, title,
* description, difficulty, proof type, duration, XP, reward, state and reason.
*/
export function ChallengeDirectory({
courses,
results,
search,
onSearch,
difficulty,
onDifficulty,
difficulties,
loading,
filtered,
onClear,
completedIds,
profile,
}) {
return (
<section aria-labelledby="forge-directory" className="min-w-0">
<div className="flex flex-col gap-3 border-b border-border pb-3 lg:flex-row lg:items-end lg:justify-between">
<div className="min-w-0">
<h2 id="forge-directory" className="text-overline uppercase text-ink-4">
Other challenges
</h2>
<p className="mt-1 text-body-sm text-ink-3">
Practical challenges that turn a claim into verified evidence.
</p>
</div>
<div className="flex flex-col gap-x-4 gap-y-2 sm:flex-row sm:flex-wrap sm:items-center">
<div className="relative min-w-0 sm:w-52">
<Search
className="pointer-events-none absolute left-0 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-ink-4"
aria-hidden="true"
/>
<input
type="search"
value={search}
onChange={(e) => onSearch(e.target.value)}
placeholder="Search challenges"
aria-label="Search challenges"
className="h-8 w-full border-0 border-b border-border bg-transparent pl-6 pr-2 text-body-sm text-ink-1
placeholder:text-ink-4 focus:border-krow-blue focus:outline-none"
/>
</div>
<div className="flex flex-wrap items-center gap-3" role="radiogroup" aria-label="Challenge difficulty">
{difficulties.map((d) => {
const active = d.v === difficulty;
return (
<button
key={d.v}
type="button"
role="radio"
aria-checked={active}
onClick={() => onDifficulty(d.v)}
className={cn(
'rounded text-body-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50',
active
? 'font-semibold text-krow-blue underline decoration-krow-blue decoration-2 underline-offset-[6px]'
: 'text-ink-3 hover:text-ink-1'
)}
>
{d.label}
</button>
);
})}
</div>
</div>
</div>
<div className="flex items-center justify-between gap-3 py-2.5">
<p className="text-caption tabular-nums text-ink-4">
{results.length} of {courses.length} challenges{filtered ? ' · filtered' : ''}
</p>
{filtered && (
<button
type="button"
onClick={onClear}
className="rounded text-caption font-semibold text-krow-blue hover:underline
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
>
Clear filters
</button>
)}
</div>
{loading ? (
<div className="divide-y divide-border border-t border-border">
{[...Array(3)].map((_, i) => (
<div key={i} className="py-5">
<div className="h-10 w-full animate-pulse rounded bg-surface-sunken" />
</div>
))}
</div>
) : results.length === 0 ? (
<div className="border-t border-border py-10 text-center">
<p className="text-body-sm text-ink-3">No challenges match your filters.</p>
<button
type="button"
onClick={onClear}
className="mt-2 rounded text-body-sm font-semibold text-krow-blue hover:underline
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
>
Clear filters
</button>
</div>
) : (
<ul className="divide-y divide-border border-t border-border">
{results.map((course) => {
const lock = isUnlocked(course, profile);
const completed = completedIds.has(course.id);
const locked = !lock.unlocked && !completed;
const type = typeOf(course);
const Icon = TYPE_ICON[type] || TYPE_ICON.roleplay;
return (
<li key={course.id}>
<Link
to={`/university/${course.id}`}
className="group grid gap-x-6 gap-y-2 py-4 transition-colors hover:bg-surface-subtle
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-krow-blue/50
lg:grid-cols-[minmax(0,20rem)_minmax(0,1fr)_auto] lg:items-baseline lg:px-2"
>
<span className="min-w-0">
<span className="flex items-center gap-2">
{completed ? (
<ShieldCheck className="h-3.5 w-3.5 shrink-0 text-success" aria-hidden="true" />
) : locked ? (
<Lock className="h-3.5 w-3.5 shrink-0 text-warning" aria-hidden="true" />
) : (
<Icon className="h-3.5 w-3.5 shrink-0 text-ink-4" aria-hidden="true" />
)}
<span className="truncate text-overline uppercase text-ink-4">
{course.proof_skill || course.category}
</span>
</span>
<span className="mt-1 block text-body-lg font-semibold leading-snug text-ink-1 group-hover:text-krow-blue">
{course.title}
</span>
</span>
<span className="min-w-0">
<span className="block line-clamp-2 text-body-sm leading-relaxed text-ink-3 lg:line-clamp-1">
{course.description}
</span>
<span className="mt-1 block text-caption tabular-nums text-ink-4">
<span className={cn('font-semibold capitalize', DIFF_TONE[course.difficulty] || 'text-ink-3')}>
{course.difficulty}
</span>
{' · '}{TYPE_LABEL[type]}
{' · '}{course.estimated_minutes} min
{' · '}{course.xp} XP
{course.badge_reward && ` · Earns ${course.badge_reward}`}
</span>
{locked && lock.reasons?.length > 0 && (
<span className="mt-1 block truncate text-caption text-warning">
Requires {lock.reasons[0]}
</span>
)}
</span>
<span className="inline-flex shrink-0 items-center gap-1 text-body-sm font-semibold text-krow-blue">
{completed ? 'Review' : locked ? 'Details' : 'Start'}
<ArrowRight
className="h-3.5 w-3.5 transition-transform duration-200 group-hover:translate-x-0.5"
aria-hidden="true"
/>
</span>
</Link>
</li>
);
})}
</ul>
)}
</section>
);
}