feat(frontend): marketplace page with subscribe + fork
This commit is contained in:
97
packages/frontend/src/pages/Marketplace.tsx
Normal file
97
packages/frontend/src/pages/Marketplace.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { motion } from 'framer-motion';
|
||||
import { marketplaceApi, type MarketplaceListResponse } from '../api/marketplace.js';
|
||||
import { lessonsApi } from '../api/lessons.js';
|
||||
import { ApiClientError } from '../api/client.js';
|
||||
|
||||
export function MarketplacePage() {
|
||||
const [data, setData] = useState<MarketplaceListResponse>({ rows: [], total: 0 });
|
||||
const [q, setQ] = useState('');
|
||||
const [curatedOnly, setCuratedOnly] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
async function refresh() {
|
||||
setBusy(true); setError(null);
|
||||
try {
|
||||
const r = await marketplaceApi.list({ q: q.trim() || undefined, curated: curatedOnly ? true : undefined });
|
||||
setData(r);
|
||||
} catch (e) {
|
||||
setError(e instanceof ApiClientError ? e.message : 'Kon marketplace niet laden.');
|
||||
} finally { setBusy(false); }
|
||||
}
|
||||
|
||||
useEffect(() => { refresh(); }, [curatedOnly]);
|
||||
|
||||
async function subscribe(id: number) {
|
||||
try { await lessonsApi.subscribe(id); await refresh(); }
|
||||
catch (e) { alert(e instanceof ApiClientError ? e.message : 'Abonneren mislukt'); }
|
||||
}
|
||||
|
||||
async function fork(id: number) {
|
||||
try {
|
||||
const f = await lessonsApi.fork(id);
|
||||
navigate(`/admin/lessons/${f.id}`);
|
||||
}
|
||||
catch (e) { alert(e instanceof ApiClientError ? e.message : 'Forken mislukt'); }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header>
|
||||
<h1 className="font-display text-3xl font-bold">Marketplace 🛍️</h1>
|
||||
<p className="text-sm text-slate-500">Vind trainingen van andere gebruikers en officiële beheerderscontent.</p>
|
||||
</header>
|
||||
|
||||
<div className="surface flex flex-col gap-2 p-4 sm:flex-row">
|
||||
<input
|
||||
className="input-field flex-1"
|
||||
placeholder="Zoek op naam of beschrijving…"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && refresh()}
|
||||
/>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" className="h-4 w-4 rounded accent-brand-600" checked={curatedOnly} onChange={(e) => setCuratedOnly(e.target.checked)} />
|
||||
⭐ Alleen officieel
|
||||
</label>
|
||||
<button className="btn-primary shrink-0" onClick={refresh} disabled={busy}>Zoek</button>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-danger-700">{error}</p>}
|
||||
|
||||
{data.rows.length === 0 && !busy ? (
|
||||
<div className="surface p-12 text-center text-slate-500">Geen lessen gevonden.</div>
|
||||
) : (
|
||||
<ul className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{data.rows.map((l, i) => (
|
||||
<motion.li
|
||||
key={l.id}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.02 }}
|
||||
className="surface flex flex-col p-5"
|
||||
>
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
{l.isCurated && <span className="rounded-full bg-yellow-100 px-2 py-0.5 text-xs font-semibold text-yellow-800">⭐ Curated</span>}
|
||||
{l.isFork && <span className="rounded-full bg-slate-100 px-2 py-0.5 text-xs text-slate-600 dark:bg-slate-800 dark:text-slate-300">🍴 Fork</span>}
|
||||
</div>
|
||||
<h2 className="font-display text-lg font-bold">{l.name}</h2>
|
||||
<p className="mt-1 line-clamp-2 text-sm text-slate-500">{l.description ?? <span className="italic">geen beschrijving</span>}</p>
|
||||
<div className="mt-3 flex items-center justify-between text-xs text-slate-500">
|
||||
<span>door {l.ownerDisplayName}</span>
|
||||
<span>{l.totalCards} kaarten · {l.subscribersCount} abonnees</span>
|
||||
</div>
|
||||
<div className="mt-4 flex gap-2">
|
||||
<button className="btn-primary flex-1" onClick={() => subscribe(l.id)}>Abonneer</button>
|
||||
<button className="btn-ghost flex-1" onClick={() => fork(l.id)}>🍴 Fork</button>
|
||||
</div>
|
||||
</motion.li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user