feat(CC-089): add Learning Queue page (books/articles/courses/videos with type filter)

This commit is contained in:
admin-valentin 2026-08-02 17:51:19 +00:00
parent dee20197c8
commit 5ad40653ab

View 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>
);
}