192 lines
7.9 KiB
JavaScript
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>
|
|
);
|
|
}
|