feat: Phase 4 — theme badge on synthesis cards + sort by date/theme

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
master
oabrivard 3 months ago
parent ed29e74b1b
commit d2f98dc66f

@ -63,6 +63,10 @@ const fr = {
'home.deleteError': 'Erreur lors de la suppression de la synthese.',
'home.generationInProgress': 'Une generation est en cours...',
'home.viewProgress': 'Voir la progression',
'home.sortBy': 'Trier par',
'home.sortDate': 'Date',
'home.sortTheme': 'Theme',
'home.deletedTheme': 'Theme supprime',
// Generate
'generate.title': 'Generer la Synthese Hebdomadaire',

@ -1,6 +1,7 @@
import {
type Component,
createSignal,
createMemo,
onMount,
onCleanup,
Show,
@ -32,6 +33,7 @@ const Home: Component = () => {
const [error, setError] = createSignal<string | null>(null);
const [deletingId, setDeletingId] = createSignal<string | null>(null);
const [deleteTimers, setDeleteTimers] = createSignal<Record<string, ReturnType<typeof setTimeout>>>({});
const [sortBy, setSortBy] = createSignal<'date' | 'theme'>('date');
onCleanup(() => {
const timers = deleteTimers();
@ -98,6 +100,124 @@ const Home: Component = () => {
const hasInProgress = () =>
syntheses().some((s) => s.status === 'in_progress');
/** Groups syntheses by theme_name, null theme goes last. Within each group: newest first. */
const groupedByTheme = createMemo(() => {
const sorted = [...syntheses()].sort(
(a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
);
const map = new Map<string | null, SynthesisListItem[]>();
for (const s of sorted) {
const key = s.theme_name ?? null;
if (!map.has(key)) map.set(key, []);
map.get(key)!.push(s);
}
// Named themes first (sorted alphabetically), null last
const named = [...map.keys()]
.filter((k): k is string => k !== null)
.sort((a, b) => a.localeCompare(b));
const groups: { name: string | null; syntheses: SynthesisListItem[] }[] = named.map(
(name) => ({ name, syntheses: map.get(name)! }),
);
if (map.has(null)) {
groups.push({ name: null, syntheses: map.get(null)! });
}
return groups;
});
/** Renders a single synthesis card (shared between both sort views). */
const SynthesisCard = (synth: SynthesisListItem) => (
<A
href={`/synthesis/${synth.id}`}
class="flex flex-col bg-white rounded-xl shadow-sm border border-gray-200 hover:shadow-md hover:border-indigo-300 transition-all duration-200 overflow-hidden"
>
<div class="p-6 flex-1">
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-2 flex-wrap">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-indigo-100 text-indigo-800">
{t('home.weekLabel', { week: extractWeekNumber(synth.week) })}
</span>
<Show when={synth.theme_name}>
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800">
{synth.theme_name}
</span>
</Show>
<Show when={!synth.theme_name && synth.theme_id === null}>
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-500">
{t('home.deletedTheme')}
</span>
</Show>
</div>
<div class="text-right">
<div class="text-sm text-gray-500">{formatDate(synth.created_at)}</div>
<div class="text-xs text-gray-400">{formatTime(synth.created_at)}</div>
</div>
</div>
<h3 class="text-lg font-semibold text-gray-900 mb-3">
{t('home.cardTitle')}
</h3>
<div class="space-y-1">
<Show
when={synth.sections_summary && synth.sections_summary.length > 0}
fallback={<p class="text-sm text-gray-400">{t('home.noPreview')}</p>}
>
<For each={synth.sections_summary}>
{(section) => (
<div class="flex items-center justify-between text-sm">
<span class="text-gray-700 truncate mr-2">{section.title}</span>
<span class="text-gray-400 text-xs whitespace-nowrap">({section.count})</span>
</div>
)}
</For>
</Show>
</div>
</div>
<div class="bg-gray-50 px-6 py-3 border-t border-gray-100 flex justify-between items-center">
<span class="text-sm font-medium text-indigo-600 hover:text-indigo-500">
{t('home.readLink')} &rarr;
</span>
<div class="flex items-center gap-1">
{synth.job_id && (
<A
href={`/llm-logs/${synth.job_id}`}
title={t('llmLogs.viewLogs')}
class="p-1 text-gray-400 hover:text-indigo-600"
onClick={(e: MouseEvent) => e.stopPropagation()}
>
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</A>
)}
<button
onClick={(e) => handleDelete(e, synth.id)}
class={`inline-flex items-center p-1.5 rounded-md transition-colors ${
deletingId() === synth.id
? 'bg-red-100 text-red-700 hover:bg-red-200'
: 'text-gray-400 hover:text-red-600 hover:bg-red-50'
}`}
title={
deletingId() === synth.id
? t('home.deleteConfirmTooltip')
: t('home.deleteTooltip')
}
>
<Show when={deletingId() === synth.id}>
<span class="text-xs font-medium mr-1">
{t('home.deleteConfirm')}
</span>
</Show>
<Trash2 class="h-4 w-4" />
</button>
</div>
</div>
</A>
);
return (
<Show when={!loading()} fallback={<LoadingSpinner />}>
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
@ -163,85 +283,59 @@ const Home: Component = () => {
</div>
}
>
<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
<For each={syntheses()}>
{(synth) => (
<A
href={`/synthesis/${synth.id}`}
class="flex flex-col bg-white rounded-xl shadow-sm border border-gray-200 hover:shadow-md hover:border-indigo-300 transition-all duration-200 overflow-hidden"
>
<div class="p-6 flex-1">
<div class="flex items-center justify-between mb-4">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-indigo-100 text-indigo-800">
{t('home.weekLabel', { week: extractWeekNumber(synth.week) })}
</span>
<div class="text-right">
<div class="text-sm text-gray-500">{formatDate(synth.created_at)}</div>
<div class="text-xs text-gray-400">{formatTime(synth.created_at)}</div>
</div>
</div>
<h3 class="text-lg font-semibold text-gray-900 mb-3">
{t('home.cardTitle')}
</h3>
<div class="space-y-1">
<Show
when={synth.sections_summary && synth.sections_summary.length > 0}
fallback={<p class="text-sm text-gray-400">{t('home.noPreview')}</p>}
>
<For each={synth.sections_summary}>
{(section) => (
<div class="flex items-center justify-between text-sm">
<span class="text-gray-700 truncate mr-2">{section.title}</span>
<span class="text-gray-400 text-xs whitespace-nowrap">({section.count})</span>
</div>
)}
</For>
</Show>
</div>
</div>
<div class="bg-gray-50 px-6 py-3 border-t border-gray-100 flex justify-between items-center">
<span class="text-sm font-medium text-indigo-600 hover:text-indigo-500">
{t('home.readLink')} &rarr;
</span>
<div class="flex items-center gap-1">
{synth.job_id && (
<A
href={`/llm-logs/${synth.job_id}`}
title={t('llmLogs.viewLogs')}
class="p-1 text-gray-400 hover:text-indigo-600"
onClick={(e: MouseEvent) => e.stopPropagation()}
>
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</A>
)}
<button
onClick={(e) => handleDelete(e, synth.id)}
class={`inline-flex items-center p-1.5 rounded-md transition-colors ${
deletingId() === synth.id
? 'bg-red-100 text-red-700 hover:bg-red-200'
: 'text-gray-400 hover:text-red-600 hover:bg-red-50'
}`}
title={
deletingId() === synth.id
? t('home.deleteConfirmTooltip')
: t('home.deleteTooltip')
}
>
<Show when={deletingId() === synth.id}>
<span class="text-xs font-medium mr-1">
{t('home.deleteConfirm')}
</span>
</Show>
<Trash2 class="h-4 w-4" />
</button>
</div>
{/* Sort toggle */}
<div class="flex items-center gap-3 mb-4">
<span class="text-sm text-gray-500">{t('home.sortBy')} :</span>
<button
class={`px-3 py-1 rounded-full text-sm font-medium transition-colors ${
sortBy() === 'date'
? 'bg-indigo-100 text-indigo-800'
: 'text-gray-500 hover:text-gray-700 hover:bg-gray-100'
}`}
onClick={() => setSortBy('date')}
>
{t('home.sortDate')}
</button>
<button
class={`px-3 py-1 rounded-full text-sm font-medium transition-colors ${
sortBy() === 'theme'
? 'bg-indigo-100 text-indigo-800'
: 'text-gray-500 hover:text-gray-700 hover:bg-gray-100'
}`}
onClick={() => setSortBy('theme')}
>
{t('home.sortTheme')}
</button>
</div>
{/* Date sort: flat grid, newest first */}
<Show when={sortBy() === 'date'}>
<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
<For each={[...syntheses()].sort(
(a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
)}>
{(synth) => SynthesisCard(synth)}
</For>
</div>
</Show>
{/* Theme sort: one section per theme */}
<Show when={sortBy() === 'theme'}>
<For each={groupedByTheme()}>
{(group) => (
<div class="mb-8">
<h2 class="text-lg font-semibold text-gray-700 mb-4">
{group.name ?? t('home.deletedTheme')}
</h2>
<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
<For each={group.syntheses}>
{(synth) => SynthesisCard(synth)}
</For>
</div>
</A>
</div>
)}
</For>
</div>
</Show>
</Show>
</div>
</Show>

Loading…
Cancel
Save