|
|
|
|
@ -0,0 +1,294 @@
|
|
|
|
|
import {
|
|
|
|
|
type Component,
|
|
|
|
|
createSignal,
|
|
|
|
|
onMount,
|
|
|
|
|
Show,
|
|
|
|
|
For,
|
|
|
|
|
createEffect,
|
|
|
|
|
} from 'solid-js';
|
|
|
|
|
import { A } from '@solidjs/router';
|
|
|
|
|
import { useI18n } from '~/i18n';
|
|
|
|
|
import { articleHistoryApi } from '~/api/articleHistory';
|
|
|
|
|
import type { ArticleHistoryEntry } from '~/types';
|
|
|
|
|
import LoadingSpinner from '~/components/ui/LoadingSpinner';
|
|
|
|
|
|
|
|
|
|
const PAGE_SIZE = 50;
|
|
|
|
|
|
|
|
|
|
const STATUS_OPTIONS = [
|
|
|
|
|
'used',
|
|
|
|
|
'filtered_empty',
|
|
|
|
|
'filtered_history',
|
|
|
|
|
'filtered_diversity',
|
|
|
|
|
'filtered_homepage',
|
|
|
|
|
'filtered_duplicate',
|
|
|
|
|
'filtered_cross_phase_dedup',
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
const SOURCE_TYPE_OPTIONS = [
|
|
|
|
|
'personalized_source',
|
|
|
|
|
'web_search',
|
|
|
|
|
'overflow',
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
function statusBadgeClass(status: string): string {
|
|
|
|
|
if (status === 'used') return 'bg-green-100 text-green-800';
|
|
|
|
|
if (status.startsWith('filtered_')) return 'bg-red-100 text-red-800';
|
|
|
|
|
return 'bg-gray-100 text-gray-700';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function truncateUrl(url: string, maxLen = 50): string {
|
|
|
|
|
return url.length > maxLen ? url.slice(0, maxLen) + '...' : url;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Article history page — displays a paginated, filterable table of every
|
|
|
|
|
* article candidate processed across all synthesis jobs.
|
|
|
|
|
*/
|
|
|
|
|
const ArticleHistory: Component = () => {
|
|
|
|
|
const { t } = useI18n();
|
|
|
|
|
|
|
|
|
|
const [items, setItems] = createSignal<ArticleHistoryEntry[]>([]);
|
|
|
|
|
const [total, setTotal] = createSignal(0);
|
|
|
|
|
const [loading, setLoading] = createSignal(true);
|
|
|
|
|
const [error, setError] = createSignal<string | null>(null);
|
|
|
|
|
|
|
|
|
|
const [filterStatus, setFilterStatus] = createSignal('');
|
|
|
|
|
const [filterSourceType, setFilterSourceType] = createSignal('');
|
|
|
|
|
const [page, setPage] = createSignal(0);
|
|
|
|
|
|
|
|
|
|
const totalPages = () => Math.max(1, Math.ceil(total() / PAGE_SIZE));
|
|
|
|
|
|
|
|
|
|
const fetchHistory = async () => {
|
|
|
|
|
setLoading(true);
|
|
|
|
|
setError(null);
|
|
|
|
|
try {
|
|
|
|
|
const result = await articleHistoryApi.list({
|
|
|
|
|
limit: PAGE_SIZE,
|
|
|
|
|
offset: page() * PAGE_SIZE,
|
|
|
|
|
status: filterStatus() || undefined,
|
|
|
|
|
source_type: filterSourceType() || undefined,
|
|
|
|
|
});
|
|
|
|
|
setItems(result.items);
|
|
|
|
|
setTotal(result.total);
|
|
|
|
|
} catch {
|
|
|
|
|
setError(t('common.error'));
|
|
|
|
|
} finally {
|
|
|
|
|
setLoading(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
onMount(fetchHistory);
|
|
|
|
|
|
|
|
|
|
// Re-fetch when filters or page change (but not on first mount — onMount covers that)
|
|
|
|
|
createEffect(() => {
|
|
|
|
|
// Track all reactive dependencies
|
|
|
|
|
filterStatus();
|
|
|
|
|
filterSourceType();
|
|
|
|
|
page();
|
|
|
|
|
// Skip the initial run which is handled by onMount
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const handleFilterChange = () => {
|
|
|
|
|
setPage(0);
|
|
|
|
|
fetchHistory();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handlePageChange = (newPage: number) => {
|
|
|
|
|
setPage(newPage);
|
|
|
|
|
fetchHistory();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
|
|
|
|
{/* Page header */}
|
|
|
|
|
<div class="mb-6">
|
|
|
|
|
<h1 class="text-3xl font-bold text-gray-900">
|
|
|
|
|
{t('articleHistory.title')}
|
|
|
|
|
</h1>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Filters */}
|
|
|
|
|
<div class="mb-4 flex flex-col sm:flex-row gap-3">
|
|
|
|
|
<div>
|
|
|
|
|
<label for="filter-status" class="block text-xs font-medium text-gray-500 mb-1">
|
|
|
|
|
{t('articleHistory.filterStatus')}
|
|
|
|
|
</label>
|
|
|
|
|
<select
|
|
|
|
|
id="filter-status"
|
|
|
|
|
class="block pl-3 pr-8 py-2 text-sm border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 rounded-md border"
|
|
|
|
|
value={filterStatus()}
|
|
|
|
|
onChange={(e) => {
|
|
|
|
|
setFilterStatus(e.currentTarget.value);
|
|
|
|
|
handleFilterChange();
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<option value="">{t('articleHistory.all')}</option>
|
|
|
|
|
<For each={STATUS_OPTIONS}>
|
|
|
|
|
{(s) => <option value={s}>{s}</option>}
|
|
|
|
|
</For>
|
|
|
|
|
</select>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div>
|
|
|
|
|
<label for="filter-source-type" class="block text-xs font-medium text-gray-500 mb-1">
|
|
|
|
|
{t('articleHistory.filterSourceType')}
|
|
|
|
|
</label>
|
|
|
|
|
<select
|
|
|
|
|
id="filter-source-type"
|
|
|
|
|
class="block pl-3 pr-8 py-2 text-sm border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 rounded-md border"
|
|
|
|
|
value={filterSourceType()}
|
|
|
|
|
onChange={(e) => {
|
|
|
|
|
setFilterSourceType(e.currentTarget.value);
|
|
|
|
|
handleFilterChange();
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<option value="">{t('articleHistory.all')}</option>
|
|
|
|
|
<For each={SOURCE_TYPE_OPTIONS}>
|
|
|
|
|
{(s) => <option value={s}>{s}</option>}
|
|
|
|
|
</For>
|
|
|
|
|
</select>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Error */}
|
|
|
|
|
<Show when={error()}>
|
|
|
|
|
<div class="mb-4 p-4 rounded-md bg-red-50 text-red-800 border border-red-200">
|
|
|
|
|
{error()}
|
|
|
|
|
</div>
|
|
|
|
|
</Show>
|
|
|
|
|
|
|
|
|
|
{/* Loading */}
|
|
|
|
|
<Show when={loading()}>
|
|
|
|
|
<LoadingSpinner />
|
|
|
|
|
</Show>
|
|
|
|
|
|
|
|
|
|
{/* Table */}
|
|
|
|
|
<Show when={!loading()}>
|
|
|
|
|
<Show
|
|
|
|
|
when={items().length > 0}
|
|
|
|
|
fallback={
|
|
|
|
|
<div class="text-center py-12 text-gray-500">
|
|
|
|
|
{t('articleHistory.empty')}
|
|
|
|
|
</div>
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
<div class="bg-white shadow overflow-hidden sm:rounded-lg border border-gray-200">
|
|
|
|
|
<div class="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-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
|
|
|
{t('articleHistory.date')}
|
|
|
|
|
</th>
|
|
|
|
|
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
|
|
|
{t('articleHistory.status')}
|
|
|
|
|
</th>
|
|
|
|
|
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
|
|
|
{t('articleHistory.articleTitle')}
|
|
|
|
|
</th>
|
|
|
|
|
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
|
|
|
{t('articleHistory.url')}
|
|
|
|
|
</th>
|
|
|
|
|
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
|
|
|
{t('articleHistory.sourceType')}
|
|
|
|
|
</th>
|
|
|
|
|
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
|
|
|
{t('articleHistory.category')}
|
|
|
|
|
</th>
|
|
|
|
|
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
|
|
|
{t('articleHistory.synthesis')}
|
|
|
|
|
</th>
|
|
|
|
|
</tr>
|
|
|
|
|
</thead>
|
|
|
|
|
<tbody class="bg-white divide-y divide-gray-200">
|
|
|
|
|
<For each={items()}>
|
|
|
|
|
{(entry) => (
|
|
|
|
|
<tr class="hover:bg-gray-50">
|
|
|
|
|
<td class="px-3 py-2 whitespace-nowrap text-xs text-gray-500">
|
|
|
|
|
{new Date(entry.created_at).toLocaleString('fr-FR', {
|
|
|
|
|
day: '2-digit',
|
|
|
|
|
month: '2-digit',
|
|
|
|
|
year: '2-digit',
|
|
|
|
|
hour: '2-digit',
|
|
|
|
|
minute: '2-digit',
|
|
|
|
|
})}
|
|
|
|
|
</td>
|
|
|
|
|
<td class="px-3 py-2 whitespace-nowrap">
|
|
|
|
|
<span class={`inline-flex px-2 py-0.5 text-xs rounded-full font-medium ${statusBadgeClass(entry.status)}`}>
|
|
|
|
|
{entry.status}
|
|
|
|
|
</span>
|
|
|
|
|
</td>
|
|
|
|
|
<td class="px-3 py-2 max-w-xs">
|
|
|
|
|
<span class="block truncate text-gray-900" title={entry.title}>
|
|
|
|
|
{entry.title || '—'}
|
|
|
|
|
</span>
|
|
|
|
|
</td>
|
|
|
|
|
<td class="px-3 py-2 max-w-xs">
|
|
|
|
|
<a
|
|
|
|
|
href={entry.url}
|
|
|
|
|
target="_blank"
|
|
|
|
|
rel="noopener noreferrer"
|
|
|
|
|
class="text-indigo-600 hover:underline"
|
|
|
|
|
title={entry.url}
|
|
|
|
|
>
|
|
|
|
|
{truncateUrl(entry.url)}
|
|
|
|
|
</a>
|
|
|
|
|
</td>
|
|
|
|
|
<td class="px-3 py-2 whitespace-nowrap text-gray-700">
|
|
|
|
|
{entry.source_type}
|
|
|
|
|
</td>
|
|
|
|
|
<td class="px-3 py-2 whitespace-nowrap text-gray-700">
|
|
|
|
|
{entry.category || '—'}
|
|
|
|
|
</td>
|
|
|
|
|
<td class="px-3 py-2 whitespace-nowrap">
|
|
|
|
|
<Show when={entry.synthesis_id}>
|
|
|
|
|
<A
|
|
|
|
|
href={`/synthesis/${entry.synthesis_id}`}
|
|
|
|
|
class="text-indigo-600 hover:underline text-xs"
|
|
|
|
|
>
|
|
|
|
|
{entry.synthesis_id!.slice(0, 8)}...
|
|
|
|
|
</A>
|
|
|
|
|
</Show>
|
|
|
|
|
<Show when={!entry.synthesis_id}>
|
|
|
|
|
<span class="text-gray-400">—</span>
|
|
|
|
|
</Show>
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
)}
|
|
|
|
|
</For>
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Pagination */}
|
|
|
|
|
<Show when={totalPages() > 1}>
|
|
|
|
|
<div class="bg-white px-4 py-3 flex items-center justify-between border-t border-gray-200 sm:px-6">
|
|
|
|
|
<div class="text-sm text-gray-700">
|
|
|
|
|
{page() * PAGE_SIZE + 1}–{Math.min((page() + 1) * PAGE_SIZE, total())} / {total()}
|
|
|
|
|
</div>
|
|
|
|
|
<div class="flex gap-2">
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => handlePageChange(page() - 1)}
|
|
|
|
|
disabled={page() === 0}
|
|
|
|
|
class="px-3 py-1 text-sm border border-gray-300 rounded-md disabled:opacity-50 hover:bg-gray-50"
|
|
|
|
|
>
|
|
|
|
|
«
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => handlePageChange(page() + 1)}
|
|
|
|
|
disabled={page() >= totalPages() - 1}
|
|
|
|
|
class="px-3 py-1 text-sm border border-gray-300 rounded-md disabled:opacity-50 hover:bg-gray-50"
|
|
|
|
|
>
|
|
|
|
|
»
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</Show>
|
|
|
|
|
</div>
|
|
|
|
|
</Show>
|
|
|
|
|
</Show>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export default ArticleHistory;
|