|
|
|
@ -1,6 +1,7 @@
|
|
|
|
import {
|
|
|
|
import {
|
|
|
|
type Component,
|
|
|
|
type Component,
|
|
|
|
createSignal,
|
|
|
|
createSignal,
|
|
|
|
|
|
|
|
createMemo,
|
|
|
|
onMount,
|
|
|
|
onMount,
|
|
|
|
onCleanup,
|
|
|
|
onCleanup,
|
|
|
|
Show,
|
|
|
|
Show,
|
|
|
|
@ -32,6 +33,7 @@ const Home: Component = () => {
|
|
|
|
const [error, setError] = createSignal<string | null>(null);
|
|
|
|
const [error, setError] = createSignal<string | null>(null);
|
|
|
|
const [deletingId, setDeletingId] = createSignal<string | null>(null);
|
|
|
|
const [deletingId, setDeletingId] = createSignal<string | null>(null);
|
|
|
|
const [deleteTimers, setDeleteTimers] = createSignal<Record<string, ReturnType<typeof setTimeout>>>({});
|
|
|
|
const [deleteTimers, setDeleteTimers] = createSignal<Record<string, ReturnType<typeof setTimeout>>>({});
|
|
|
|
|
|
|
|
const [sortBy, setSortBy] = createSignal<'date' | 'theme'>('date');
|
|
|
|
|
|
|
|
|
|
|
|
onCleanup(() => {
|
|
|
|
onCleanup(() => {
|
|
|
|
const timers = deleteTimers();
|
|
|
|
const timers = deleteTimers();
|
|
|
|
@ -98,83 +100,58 @@ const Home: Component = () => {
|
|
|
|
const hasInProgress = () =>
|
|
|
|
const hasInProgress = () =>
|
|
|
|
syntheses().some((s) => s.status === 'in_progress');
|
|
|
|
syntheses().some((s) => s.status === 'in_progress');
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
/** Groups syntheses by theme_name, null theme goes last. Within each group: newest first. */
|
|
|
|
<Show when={!loading()} fallback={<LoadingSpinner />}>
|
|
|
|
const groupedByTheme = createMemo(() => {
|
|
|
|
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
|
|
|
const sorted = [...syntheses()].sort(
|
|
|
|
<div class="flex justify-between items-center mb-8">
|
|
|
|
(a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
|
|
|
|
<div>
|
|
|
|
);
|
|
|
|
<h1 class="text-3xl font-bold text-gray-900">{t('home.title')}</h1>
|
|
|
|
|
|
|
|
<p class="mt-2 text-sm text-gray-500">{t('home.subtitle')}</p>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
<A
|
|
|
|
|
|
|
|
href="/generate"
|
|
|
|
|
|
|
|
class="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
|
|
|
|
|
|
|
>
|
|
|
|
|
|
|
|
<Plus class="-ml-1 mr-2 h-5 w-5" aria-hidden="true" />
|
|
|
|
|
|
|
|
{t('home.newSynthesis')}
|
|
|
|
|
|
|
|
</A>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
{/* In-progress banner */}
|
|
|
|
const map = new Map<string | null, SynthesisListItem[]>();
|
|
|
|
<Show when={hasInProgress()}>
|
|
|
|
for (const s of sorted) {
|
|
|
|
<div class="mb-6 bg-indigo-50 border border-indigo-200 rounded-lg p-4 flex items-center justify-between">
|
|
|
|
const key = s.theme_name ?? null;
|
|
|
|
<div class="flex items-center">
|
|
|
|
if (!map.has(key)) map.set(key, []);
|
|
|
|
<div class="animate-spin rounded-full h-5 w-5 border-b-2 border-indigo-600 mr-3" />
|
|
|
|
map.get(key)!.push(s);
|
|
|
|
<span class="text-sm font-medium text-indigo-800">
|
|
|
|
}
|
|
|
|
{t('home.generationInProgress')}
|
|
|
|
|
|
|
|
</span>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
<A
|
|
|
|
|
|
|
|
href="/generate"
|
|
|
|
|
|
|
|
class="text-sm font-medium text-indigo-600 hover:text-indigo-800"
|
|
|
|
|
|
|
|
>
|
|
|
|
|
|
|
|
{t('home.viewProgress')}
|
|
|
|
|
|
|
|
</A>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
</Show>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
{/* Error display */}
|
|
|
|
// Named themes first (sorted alphabetically), null last
|
|
|
|
<Show when={error()}>
|
|
|
|
const named = [...map.keys()]
|
|
|
|
<div class="mb-6 bg-red-50 border border-red-200 rounded-md p-4 text-sm text-red-800">
|
|
|
|
.filter((k): k is string => k !== null)
|
|
|
|
{error()}
|
|
|
|
.sort((a, b) => a.localeCompare(b));
|
|
|
|
</div>
|
|
|
|
|
|
|
|
</Show>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
<Show
|
|
|
|
const groups: { name: string | null; syntheses: SynthesisListItem[] }[] = named.map(
|
|
|
|
when={syntheses().length > 0}
|
|
|
|
(name) => ({ name, syntheses: map.get(name)! }),
|
|
|
|
fallback={
|
|
|
|
);
|
|
|
|
<div class="text-center py-12 bg-white rounded-lg shadow-sm border border-gray-200">
|
|
|
|
|
|
|
|
<FileText class="mx-auto h-12 w-12 text-gray-400" />
|
|
|
|
if (map.has(null)) {
|
|
|
|
<h3 class="mt-2 text-sm font-medium text-gray-900">
|
|
|
|
groups.push({ name: null, syntheses: map.get(null)! });
|
|
|
|
{t('home.empty.title')}
|
|
|
|
|
|
|
|
</h3>
|
|
|
|
|
|
|
|
<p class="mt-1 text-sm text-gray-500">
|
|
|
|
|
|
|
|
{t('home.empty.description')}
|
|
|
|
|
|
|
|
</p>
|
|
|
|
|
|
|
|
<div class="mt-6">
|
|
|
|
|
|
|
|
<A
|
|
|
|
|
|
|
|
href="/generate"
|
|
|
|
|
|
|
|
class="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700"
|
|
|
|
|
|
|
|
>
|
|
|
|
|
|
|
|
<Plus class="-ml-1 mr-2 h-5 w-5" aria-hidden="true" />
|
|
|
|
|
|
|
|
{t('home.empty.action')}
|
|
|
|
|
|
|
|
</A>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
}
|
|
|
|
}
|
|
|
|
>
|
|
|
|
|
|
|
|
<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
|
|
|
return groups;
|
|
|
|
<For each={syntheses()}>
|
|
|
|
});
|
|
|
|
{(synth) => (
|
|
|
|
|
|
|
|
|
|
|
|
/** Renders a single synthesis card (shared between both sort views). */
|
|
|
|
|
|
|
|
const SynthesisCard = (synth: SynthesisListItem) => (
|
|
|
|
<A
|
|
|
|
<A
|
|
|
|
href={`/synthesis/${synth.id}`}
|
|
|
|
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"
|
|
|
|
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="p-6 flex-1">
|
|
|
|
<div class="flex items-center justify-between mb-4">
|
|
|
|
<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">
|
|
|
|
<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) })}
|
|
|
|
{t('home.weekLabel', { week: extractWeekNumber(synth.week) })}
|
|
|
|
</span>
|
|
|
|
</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-right">
|
|
|
|
<div class="text-sm text-gray-500">{formatDate(synth.created_at)}</div>
|
|
|
|
<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 class="text-xs text-gray-400">{formatTime(synth.created_at)}</div>
|
|
|
|
@ -239,9 +216,126 @@ const Home: Component = () => {
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</A>
|
|
|
|
</A>
|
|
|
|
)}
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
|
|
<Show when={!loading()} fallback={<LoadingSpinner />}>
|
|
|
|
|
|
|
|
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
|
|
|
|
|
|
|
<div class="flex justify-between items-center mb-8">
|
|
|
|
|
|
|
|
<div>
|
|
|
|
|
|
|
|
<h1 class="text-3xl font-bold text-gray-900">{t('home.title')}</h1>
|
|
|
|
|
|
|
|
<p class="mt-2 text-sm text-gray-500">{t('home.subtitle')}</p>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
<A
|
|
|
|
|
|
|
|
href="/generate"
|
|
|
|
|
|
|
|
class="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
|
|
|
|
|
|
|
>
|
|
|
|
|
|
|
|
<Plus class="-ml-1 mr-2 h-5 w-5" aria-hidden="true" />
|
|
|
|
|
|
|
|
{t('home.newSynthesis')}
|
|
|
|
|
|
|
|
</A>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
{/* In-progress banner */}
|
|
|
|
|
|
|
|
<Show when={hasInProgress()}>
|
|
|
|
|
|
|
|
<div class="mb-6 bg-indigo-50 border border-indigo-200 rounded-lg p-4 flex items-center justify-between">
|
|
|
|
|
|
|
|
<div class="flex items-center">
|
|
|
|
|
|
|
|
<div class="animate-spin rounded-full h-5 w-5 border-b-2 border-indigo-600 mr-3" />
|
|
|
|
|
|
|
|
<span class="text-sm font-medium text-indigo-800">
|
|
|
|
|
|
|
|
{t('home.generationInProgress')}
|
|
|
|
|
|
|
|
</span>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
<A
|
|
|
|
|
|
|
|
href="/generate"
|
|
|
|
|
|
|
|
class="text-sm font-medium text-indigo-600 hover:text-indigo-800"
|
|
|
|
|
|
|
|
>
|
|
|
|
|
|
|
|
{t('home.viewProgress')}
|
|
|
|
|
|
|
|
</A>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
</Show>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
{/* Error display */}
|
|
|
|
|
|
|
|
<Show when={error()}>
|
|
|
|
|
|
|
|
<div class="mb-6 bg-red-50 border border-red-200 rounded-md p-4 text-sm text-red-800">
|
|
|
|
|
|
|
|
{error()}
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
</Show>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
<Show
|
|
|
|
|
|
|
|
when={syntheses().length > 0}
|
|
|
|
|
|
|
|
fallback={
|
|
|
|
|
|
|
|
<div class="text-center py-12 bg-white rounded-lg shadow-sm border border-gray-200">
|
|
|
|
|
|
|
|
<FileText class="mx-auto h-12 w-12 text-gray-400" />
|
|
|
|
|
|
|
|
<h3 class="mt-2 text-sm font-medium text-gray-900">
|
|
|
|
|
|
|
|
{t('home.empty.title')}
|
|
|
|
|
|
|
|
</h3>
|
|
|
|
|
|
|
|
<p class="mt-1 text-sm text-gray-500">
|
|
|
|
|
|
|
|
{t('home.empty.description')}
|
|
|
|
|
|
|
|
</p>
|
|
|
|
|
|
|
|
<div class="mt-6">
|
|
|
|
|
|
|
|
<A
|
|
|
|
|
|
|
|
href="/generate"
|
|
|
|
|
|
|
|
class="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700"
|
|
|
|
|
|
|
|
>
|
|
|
|
|
|
|
|
<Plus class="-ml-1 mr-2 h-5 w-5" aria-hidden="true" />
|
|
|
|
|
|
|
|
{t('home.empty.action')}
|
|
|
|
|
|
|
|
</A>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
</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>
|
|
|
|
</For>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
</For>
|
|
|
|
|
|
|
|
</Show>
|
|
|
|
</Show>
|
|
|
|
</Show>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</Show>
|
|
|
|
</Show>
|
|
|
|
|