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
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">
|
|
← {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;
|