update hired page
This commit is contained in:
191
src/components/forge/ChallengeDirectory.jsx
Normal file
191
src/components/forge/ChallengeDirectory.jsx
Normal file
@@ -0,0 +1,191 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user