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