|
|
|
|
@ -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')} →
|
|
|
|
|
</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')} →
|
|
|
|
|
</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>
|
|
|
|
|
|