You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

477 lines
19 KiB
TypeScript

import {
type Component,
createSignal,
onMount,
Show,
For,
} from 'solid-js';
import { useParams, useNavigate, A } from '@solidjs/router';
import { ArrowLeft, ExternalLink, Trash2, AlertTriangle, Mail, Send, FileDown } from 'lucide-solid';
import { useI18n } from '~/i18n';
import { useAuth } from '~/contexts/AuthContext';
import { synthesesApi } from '~/api/syntheses';
import { articleHistoryApi } from '~/api/articleHistory';
import { isApiError } from '~/types';
import type { Synthesis, NewsItem as NewsItemType, ArticleHistoryEntry } from '~/types';
import { extractWeekNumber, formatDateLong } from '~/utils/dates';
import LoadingSpinner from '~/components/ui/LoadingSpinner';
/** Renders a single news article with its title (linked) and summary. */
const NewsItemCard: Component<{ item: NewsItemType }> = (props) => {
return (
<div class="bg-white rounded-lg shadow-sm border border-gray-100 p-6 hover:shadow-md transition-shadow">
<h3 class="text-lg font-semibold text-indigo-700 mb-2">
<a
href={props.item.url}
target="_blank"
rel="noopener noreferrer"
class="hover:underline flex items-center gap-2"
>
{props.item.title}
<ExternalLink class="h-4 w-4 text-gray-400 flex-shrink-0" />
</a>
</h3>
<p class="text-gray-700 leading-relaxed text-sm">
{props.item.summary}
</p>
</div>
);
};
/** Renders a titled category section containing a list of {@link NewsItemCard}s. */
const Section: Component<{ title: string; items: NewsItemType[] }> = (props) => {
return (
<Show when={props.items && props.items.length > 0}>
<div class="mb-10">
<h2 class="text-2xl font-bold text-gray-900 mb-6 border-b pb-2">
{props.title}
</h2>
<div class="space-y-6">
<For each={props.items}>
{(item) => <NewsItemCard item={item} />}
</For>
</div>
</div>
</Show>
);
};
/**
* Detail view for a single synthesis, with email send and export capabilities.
*
* - **Email send flow**: The email input is pre-filled from the authenticated
* user's address. On send, a POST is made to the backend; a success banner
* auto-dismisses after 5 seconds.
* - **Export download**: Markdown and PDF exports call {@link fetchFile} to
* obtain a binary Response, then {@link triggerDownload} to create a
* temporary `<a>` element that triggers the browser download dialog.
*/
const SynthesisDetail: Component = () => {
const { t } = useI18n();
const { user } = useAuth();
const params = useParams<{ id: string }>();
const navigate = useNavigate();
const [synthesis, setSynthesis] = createSignal<Synthesis | null>(null);
const [loading, setLoading] = createSignal(true);
const [error, setError] = createSignal<string | null>(null);
const [showDeleteConfirm, setShowDeleteConfirm] = createSignal(false);
const [isDeleting, setIsDeleting] = createSignal(false);
// Email state
const [email, setEmail] = createSignal('');
const [sendingEmail, setSendingEmail] = createSignal(false);
const [emailSuccess, setEmailSuccess] = createSignal(false);
const [emailError, setEmailError] = createSignal<string | null>(null);
// Export state
const [exportingMarkdown, setExportingMarkdown] = createSignal(false);
const [exportingPdf, setExportingPdf] = createSignal(false);
const [exportError, setExportError] = createSignal<string | null>(null);
// Provenance state
const [provenance, setProvenance] = createSignal<ArticleHistoryEntry[]>([]);
const [showProvenance, setShowProvenance] = createSignal(false);
const [provenanceLoading, setProvenanceLoading] = createSignal(false);
onMount(async () => {
// Pre-fill email from authenticated user
const currentUser = user();
if (currentUser?.email) {
setEmail(currentUser.email);
}
try {
const data = await synthesesApi.get(params.id);
setSynthesis(data);
} catch (err) {
if (isApiError(err) && err.status === 404) {
setError(t('synthesis.notFound'));
} else if (isApiError(err)) {
setError(err.message);
} else {
setError(t('synthesis.loadError'));
}
} finally {
setLoading(false);
}
});
const handleDelete = async () => {
const synth = synthesis();
if (!synth) return;
setIsDeleting(true);
try {
await synthesesApi.remove(synth.id);
navigate('/');
} catch (err) {
if (isApiError(err)) {
setError(err.message);
} else {
setError(t('synthesis.deleteError'));
}
setIsDeleting(false);
setShowDeleteConfirm(false);
}
};
const handleSendEmail = async () => {
const synth = synthesis();
if (!synth || !email()) return;
setSendingEmail(true);
setEmailError(null);
setEmailSuccess(false);
try {
await synthesesApi.sendEmail(synth.id, email());
setEmailSuccess(true);
setTimeout(() => setEmailSuccess(false), 5000);
} catch (err) {
if (isApiError(err)) {
setEmailError(err.message);
} else {
setEmailError(t('synthesis.email.error'));
}
} finally {
setSendingEmail(false);
}
};
const handleExportMarkdown = async () => {
const synth = synthesis();
if (!synth) return;
setExportingMarkdown(true);
setExportError(null);
try {
await synthesesApi.exportMarkdown(synth.id);
} catch (err) {
if (isApiError(err)) {
setExportError(err.message);
} else {
setExportError(t('synthesis.export.error'));
}
} finally {
setExportingMarkdown(false);
}
};
const handleExportPdf = async () => {
const synth = synthesis();
if (!synth) return;
setExportingPdf(true);
setExportError(null);
try {
await synthesesApi.exportPdf(synth.id);
} catch (err) {
if (isApiError(err)) {
setExportError(err.message);
} else {
setExportError(t('synthesis.export.error'));
}
} finally {
setExportingPdf(false);
}
};
const loadProvenance = async () => {
if (provenance().length > 0) return; // Already loaded
setProvenanceLoading(true);
try {
const data = await articleHistoryApi.getProvenance(params.id);
setProvenance(data);
} catch {
// Provenance may not be available for old syntheses
} finally {
setProvenanceLoading(false);
}
};
const toggleProvenance = () => {
const newState = !showProvenance();
setShowProvenance(newState);
if (newState) loadProvenance();
};
return (
<Show when={!loading()} fallback={<LoadingSpinner />}>
<Show
when={!error() && synthesis()}
fallback={
<div class="max-w-4xl mx-auto px-4 py-12 text-center">
<p class="text-red-600 mb-4">{error()}</p>
<A href="/" class="text-indigo-600 hover:text-indigo-800 font-medium">
&larr; {t('synthesis.backToHome')}
</A>
</div>
}
>
{(synth) => (
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="mb-8">
{/* Top bar: back link + delete */}
<div class="flex justify-between items-center mb-6">
<A
href="/"
class="inline-flex items-center text-sm font-medium text-indigo-600 hover:text-indigo-800"
>
<ArrowLeft class="mr-2 h-4 w-4" />
{t('synthesis.backLink')}
</A>
<button
onClick={() => setShowDeleteConfirm(true)}
class="inline-flex items-center text-sm font-medium text-red-600 hover:text-red-800"
>
<Trash2 class="mr-1 h-4 w-4" />
{t('synthesis.delete')}
</button>
</div>
{/* Delete confirmation banner */}
<Show when={showDeleteConfirm()}>
<div class="mb-6 bg-red-50 border border-red-200 rounded-lg p-4 flex flex-col sm:flex-row items-center justify-between gap-4">
<div class="flex items-center text-red-800">
<AlertTriangle class="h-5 w-5 mr-2 flex-shrink-0" />
<p class="text-sm">{t('synthesis.deleteConfirmMessage')}</p>
</div>
<div class="flex gap-3">
<button
onClick={() => setShowDeleteConfirm(false)}
disabled={isDeleting()}
class="px-3 py-1.5 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50"
>
{t('synthesis.deleteCancel')}
</button>
<button
onClick={handleDelete}
disabled={isDeleting()}
class="inline-flex items-center px-3 py-1.5 text-sm font-medium text-white bg-red-600 border border-transparent rounded-md hover:bg-red-700 disabled:opacity-50"
>
<Show when={isDeleting()}>
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2" />
</Show>
{t('synthesis.deleteConfirm')}
</button>
</div>
</div>
</Show>
{/* Title + date badge */}
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<h1 class="text-3xl font-extrabold text-gray-900 tracking-tight">
{t('synthesis.title', { week: extractWeekNumber(synth().week) })}
</h1>
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-gray-100 text-gray-800">
{t('synthesis.generatedAt', { date: formatDateLong(synth().created_at) })}
</span>
</div>
{/* Email section */}
<div class="mt-6 bg-white p-4 rounded-lg shadow-sm border border-gray-200">
<div class="flex flex-col sm:flex-row items-center gap-4">
<div class="flex-1 w-full">
<label for="email-input" class="sr-only">
{t('synthesis.email.title')}
</label>
<div class="relative rounded-md shadow-sm">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Mail class="h-5 w-5 text-gray-400" />
</div>
<input
type="email"
id="email-input"
class="focus:ring-indigo-500 focus:border-indigo-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md py-2 border"
placeholder={t('synthesis.email.placeholder')}
value={email()}
onInput={(e) => setEmail(e.currentTarget.value)}
/>
</div>
</div>
<button
onClick={handleSendEmail}
disabled={sendingEmail() || !email()}
class="w-full sm:w-auto inline-flex items-center justify-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
<Show
when={!sendingEmail()}
fallback={
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2" />
}
>
<Send class="h-4 w-4 mr-2" />
</Show>
{sendingEmail() ? t('synthesis.email.sending') : t('synthesis.email.send')}
</button>
</div>
{/* Email success message */}
<Show when={emailSuccess()}>
<div class="mt-3 text-sm font-medium text-green-600 bg-green-50 p-3 rounded-md border border-green-200">
{t('synthesis.email.success')}
</div>
</Show>
{/* Email error message */}
<Show when={emailError()}>
<div class="mt-3 text-sm font-medium text-red-600 bg-red-50 p-3 rounded-md border border-red-200">
{emailError()}
</div>
</Show>
</div>
{/* Export section */}
<div class="mt-4 bg-white p-4 rounded-lg shadow-sm border border-gray-200">
<h3 class="text-sm font-medium text-gray-700 mb-3">
{t('synthesis.export.title')}
</h3>
<div class="flex flex-col sm:flex-row gap-3">
<button
onClick={handleExportMarkdown}
disabled={exportingMarkdown()}
class="inline-flex items-center justify-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
<Show
when={!exportingMarkdown()}
fallback={
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-gray-500 mr-2" />
}
>
<FileDown class="h-4 w-4 mr-2" />
</Show>
{exportingMarkdown() ? t('synthesis.export.downloading') : t('synthesis.export.markdown')}
</button>
<button
onClick={handleExportPdf}
disabled={exportingPdf()}
class="inline-flex items-center justify-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
<Show
when={!exportingPdf()}
fallback={
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-gray-500 mr-2" />
}
>
<FileDown class="h-4 w-4 mr-2" />
</Show>
{exportingPdf() ? t('synthesis.export.downloading') : t('synthesis.export.pdf')}
</button>
</div>
{/* Export error message */}
<Show when={exportError()}>
<div class="mt-3 text-sm font-medium text-red-600 bg-red-50 p-3 rounded-md border border-red-200">
{exportError()}
</div>
</Show>
</div>
</div>
{/* Sections */}
<div class="space-y-12">
<Show
when={synth().sections && synth().sections.length > 0}
fallback={
<p class="text-center text-gray-500 italic py-12">
{t('synthesis.noSections')}
</p>
}
>
<For each={synth().sections}>
{(section) => <Section title={section.title} items={section.items} />}
</For>
</Show>
</div>
{/* Provenance */}
<div class="mt-8 border-t pt-6">
<button
onClick={toggleProvenance}
class="flex items-center text-sm font-medium text-gray-500 hover:text-gray-700"
>
<span class="mr-2">{showProvenance() ? '▼' : '▶'}</span>
{t('articleHistory.provenance')}
<Show when={provenance().length > 0}>
<span class="ml-2 text-xs text-gray-400">({provenance().length})</span>
</Show>
</button>
<Show when={showProvenance()}>
<Show when={provenanceLoading()}>
<LoadingSpinner />
</Show>
<Show when={!provenanceLoading() && provenance().length === 0}>
<p class="mt-4 text-sm text-gray-500">{t('articleHistory.provenanceEmpty')}</p>
</Show>
<Show when={!provenanceLoading() && provenance().length > 0}>
<div class="mt-4 overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 text-sm">
<thead class="bg-gray-50">
<tr>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500">{t('articleHistory.status')}</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500">{t('articleHistory.articleTitle')}</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500">{t('articleHistory.url')}</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500">{t('articleHistory.sourceType')}</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500">{t('articleHistory.category')}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
<For each={provenance()}>
{(entry) => (
<tr>
<td class="px-3 py-2">
<span class={`inline-flex px-2 py-0.5 text-xs rounded-full ${
entry.status === 'used' ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
}`}>
{entry.status}
</span>
</td>
<td class="px-3 py-2 max-w-xs truncate">{entry.title || '—'}</td>
<td class="px-3 py-2 max-w-xs truncate">
<a href={entry.url} target="_blank" rel="noopener noreferrer" class="text-indigo-600 hover:underline">
{entry.url.length > 50 ? entry.url.slice(0, 50) + '...' : entry.url}
</a>
</td>
<td class="px-3 py-2">{entry.source_type}</td>
<td class="px-3 py-2">{entry.category || '—'}</td>
</tr>
)}
</For>
</tbody>
</table>
</div>
</Show>
</Show>
</div>
</div>
)}
</Show>
</Show>
);
};
export default SynthesisDetail;