feat(CC-089): add Learning Queue page (books/articles/courses/videos with type filter)
This commit is contained in:
parent
dee20197c8
commit
5ad40653ab
1 changed files with 172 additions and 0 deletions
172
src/app/dashboard/learning-queue/page.tsx
Normal file
172
src/app/dashboard/learning-queue/page.tsx
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiFetch } from '../../../lib/api';
|
||||
import { useSession } from '../../../components/session-provider';
|
||||
|
||||
interface Task { id: string; title: string; description: string | null; status: string; tags: string[]; priority: string | null; createdAt: string; }
|
||||
|
||||
const LEARN_TAGS = ['learning', 'to-read', 'to-watch', 'to-study', 'curs', 'carte', 'articol', 'video', 'podcast', 'tutorial'];
|
||||
const TYPE_MAP: Record<string, { label: string; icon: string }> = {
|
||||
'to-read': { label: 'De citit', icon: '📖' },
|
||||
carte: { label: 'Carte', icon: '📚' },
|
||||
articol: { label: 'Articol', icon: '📰' },
|
||||
'to-watch': { label: 'De urmărit', icon: '🎬' },
|
||||
video: { label: 'Video', icon: '🎥' },
|
||||
podcast: { label: 'Podcast', icon: '🎙️' },
|
||||
curs: { label: 'Curs', icon: '🎓' },
|
||||
tutorial: { label: 'Tutorial', icon: '💻' },
|
||||
'to-study': { label: 'Studiu', icon: '🔬' },
|
||||
learning: { label: 'Altele', icon: '💡' },
|
||||
};
|
||||
|
||||
export default function LearningQueuePage() {
|
||||
const { activeTenant } = useSession();
|
||||
const tenantId = activeTenant?.tenantId ?? '';
|
||||
const qc = useQueryClient();
|
||||
const [typeFilter, setTypeFilter] = useState('all');
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [form, setForm] = useState({ title: '', url: '', type: 'to-read', priority: 'normal' });
|
||||
|
||||
const { data: tasks = [], isLoading } = useQuery({
|
||||
queryKey: ['learning', tenantId],
|
||||
queryFn: () => apiFetch<Task[]>('/v1/tasks?limit=500', { tenantId }),
|
||||
enabled: Boolean(tenantId), staleTime: 60_000,
|
||||
});
|
||||
|
||||
const queue = useMemo(() =>
|
||||
tasks
|
||||
.filter((t) => t.tags.some((tag) => LEARN_TAGS.includes(tag.toLowerCase())))
|
||||
.sort((a, b) => {
|
||||
const pOrder: Record<string, number> = { urgent: 0, high: 1, normal: 2, low: 3 };
|
||||
return (pOrder[a.priority ?? 'normal'] ?? 2) - (pOrder[b.priority ?? 'normal'] ?? 2);
|
||||
}),
|
||||
[tasks]);
|
||||
|
||||
const typeFilter2 = typeFilter === 'all' ? queue : queue.filter((t) => t.tags.some((tag) => tag === typeFilter));
|
||||
|
||||
const typeCounts = useMemo(() => {
|
||||
const counts: Record<string, number> = {};
|
||||
for (const t of queue) {
|
||||
for (const tag of t.tags) {
|
||||
if (LEARN_TAGS.includes(tag)) { counts[tag] = (counts[tag] ?? 0) + 1; }
|
||||
}
|
||||
}
|
||||
return counts;
|
||||
}, [queue]);
|
||||
|
||||
const addMut = useMutation({
|
||||
mutationFn: () => apiFetch('/v1/tasks', { tenantId, method: 'POST', body: {
|
||||
title: form.title + (form.url ? ` — ${form.url}` : ''),
|
||||
tags: ['learning', form.type], priority: form.priority, status: 'todo',
|
||||
}}),
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['learning', tenantId] }); setShowAdd(false); setForm({ title: '', url: '', type: 'to-read', priority: 'normal' }); },
|
||||
});
|
||||
|
||||
const doneMut = useMutation({
|
||||
mutationFn: (id: string) => apiFetch(`/v1/tasks/${id}`, { tenantId, method: 'PATCH', body: { status: 'completed' } }),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['learning', tenantId] }),
|
||||
});
|
||||
|
||||
const pending = queue.filter((t) => t.status !== 'completed' && t.status !== 'cancelled');
|
||||
const done = queue.filter((t) => t.status === 'completed');
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl space-y-6 p-6">
|
||||
<div className="flex items-start justify-between flex-wrap gap-3">
|
||||
<div>
|
||||
<h1 className="font-display text-2xl font-semibold text-ink">Learning Queue</h1>
|
||||
<p className="text-sm text-ink-faint mt-1">
|
||||
{pending.length} de parcurs · {done.length} finalizate
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={() => setShowAdd(true)}
|
||||
className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white hover:bg-primary/90">
|
||||
+ Adaugă
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showAdd && (
|
||||
<div className="card p-5 space-y-3">
|
||||
<p className="text-sm font-semibold text-ink">Adaugă în coadă</p>
|
||||
<input placeholder="Titlu (ex: Deep Work — Cal Newport)" value={form.title}
|
||||
onChange={(e) => setForm((p) => ({ ...p, title: e.target.value }))}
|
||||
className="w-full rounded-lg border bg-background px-3 py-2 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-ring" />
|
||||
<input placeholder="URL (opțional)" value={form.url}
|
||||
onChange={(e) => setForm((p) => ({ ...p, url: e.target.value }))}
|
||||
className="w-full rounded-lg border bg-background px-3 py-2 text-sm text-ink font-mono text-xs focus:outline-none focus:ring-2 focus:ring-ring" />
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<select value={form.type} onChange={(e) => setForm((p) => ({ ...p, type: e.target.value }))}
|
||||
className="rounded-lg border bg-background px-3 py-2 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-ring">
|
||||
{Object.entries(TYPE_MAP).map(([k, v]) => <option key={k} value={k}>{v.icon} {v.label}</option>)}
|
||||
</select>
|
||||
<select value={form.priority} onChange={(e) => setForm((p) => ({ ...p, priority: e.target.value }))}
|
||||
className="rounded-lg border bg-background px-3 py-2 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-ring">
|
||||
<option value="low">Prioritate scăzută</option>
|
||||
<option value="normal">Normală</option>
|
||||
<option value="high">Înaltă</option>
|
||||
<option value="urgent">Urgent</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => addMut.mutate()} disabled={!form.title || addMut.isPending}
|
||||
className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white disabled:opacity-50">
|
||||
{addMut.isPending ? '…' : 'Adaugă'}
|
||||
</button>
|
||||
<button onClick={() => setShowAdd(false)} className="rounded-lg border px-4 py-2 text-sm text-ink">Anulează</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Type filters */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button onClick={() => setTypeFilter('all')}
|
||||
className={`rounded-full px-3 py-1 text-xs font-medium border transition-colors ${typeFilter === 'all' ? 'bg-primary text-white border-primary' : 'bg-background text-ink-faint border-border hover:border-primary/40'}`}>
|
||||
Toate ({queue.length})
|
||||
</button>
|
||||
{Object.entries(typeCounts).map(([tag, count]) => (
|
||||
<button key={tag} onClick={() => setTypeFilter(typeFilter === tag ? 'all' : tag)}
|
||||
className={`rounded-full px-3 py-1 text-xs font-medium border transition-colors ${typeFilter === tag ? 'bg-primary text-white border-primary' : 'bg-background text-ink-faint border-border hover:border-primary/40'}`}>
|
||||
{TYPE_MAP[tag]?.icon ?? '📌'} {TYPE_MAP[tag]?.label ?? tag} ({count})
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center text-sm text-ink-faint py-8">Se încarcă…</div>
|
||||
) : typeFilter2.length === 0 ? (
|
||||
<div className="card p-8 text-center space-y-2">
|
||||
<p className="text-3xl">📚</p>
|
||||
<p className="text-sm text-ink-faint">Coada de learning e goală. Adaugă cărți, cursuri, articole.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="card divide-y divide-border/50">
|
||||
{typeFilter2.filter((t) => t.status !== 'completed' && t.status !== 'cancelled').map((t) => {
|
||||
const typeTag = t.tags.find((tag) => TYPE_MAP[tag]) ?? 'learning';
|
||||
const tm = TYPE_MAP[typeTag] ?? { icon: '📌', label: typeTag };
|
||||
const pCls: Record<string, string> = { urgent: 'text-signal-danger', high: 'text-warn', normal: '', low: 'text-ink-faint' };
|
||||
return (
|
||||
<div key={t.id} className="flex items-center gap-4 p-4">
|
||||
<span className="text-xl shrink-0">{tm.icon}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-ink truncate">{t.title}</p>
|
||||
<p className={`text-[10px] ${pCls[t.priority ?? 'normal'] ?? 'text-ink-faint'}`}>{tm.label}</p>
|
||||
</div>
|
||||
<button onClick={() => doneMut.mutate(t.id)}
|
||||
className="rounded-lg border px-3 py-1 text-[10px] text-signal-ok border-signal-ok/30 hover:bg-signal-ok/10 shrink-0">
|
||||
✓ Done
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{done.length > 0 && (
|
||||
<div className="p-3 text-center">
|
||||
<p className="text-[10px] text-signal-ok">✓ {done.length} finalizate</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue