All checks were successful
Build Docker Image / build (push) Successful in 1m14s
- Added a new entry for "fuzzy_talisman" in the journal. - Updated import-json script to handle character deletion and mark absent characters as deleted in the scrape validation. - Modified schema to include an `isDeleted` field in the characterScrapeValidation table. - Renamed function `upsertCharacterFromScrapeValidation` to `applyCharacterChangeFromScrapeValidation` for clarity. - Enhanced character change loading to include deleted characters and updated UI to display them. - Improved character change handling in the Svelte component to reflect new, modified, and deleted states.
252 lines
9.0 KiB
Svelte
252 lines
9.0 KiB
Svelte
<script lang="ts">
|
|
type CharacterLike = {
|
|
name: string;
|
|
pictureUrl?: string | null;
|
|
url?: string | null;
|
|
status?: string | null;
|
|
gender?: string | null;
|
|
age?: number | null;
|
|
bounty?: number | null;
|
|
[key: string]: unknown;
|
|
};
|
|
|
|
type CharacterChange = {
|
|
type: 'new' | 'modified' | 'deleted';
|
|
id: string;
|
|
scraped: CharacterLike;
|
|
current?: CharacterLike;
|
|
differences?: Record<string, { current: unknown; scraped: unknown }>;
|
|
};
|
|
|
|
let { data, form } = $props();
|
|
|
|
const newCharacters = $derived((data.changes as CharacterChange[]).filter((c) => c.type === 'new'));
|
|
const modifiedCharacters = $derived((data.changes as CharacterChange[]).filter((c) => c.type === 'modified'));
|
|
const deletedCharacters = $derived((data.changes as CharacterChange[]).filter((c) => c.type === 'deleted'));
|
|
|
|
function formatValue(value: unknown): string {
|
|
if (value === null || value === undefined) {
|
|
return '—';
|
|
}
|
|
if (Array.isArray(value)) {
|
|
return value.join(', ');
|
|
}
|
|
if (typeof value === 'boolean') {
|
|
return value ? '✓' : '✗';
|
|
}
|
|
return String(value);
|
|
}
|
|
</script>
|
|
|
|
<svelte:head>
|
|
<title>Admin - Character Changes</title>
|
|
</svelte:head>
|
|
|
|
<div class="space-y-8">
|
|
<div>
|
|
<h1 class="text-3xl font-black uppercase tracking-[0.25em] text-amber-50 mb-2">Character Changes</h1>
|
|
<p class="text-gray-400">Total changes: {newCharacters.length} new, {modifiedCharacters.length} modified, {deletedCharacters.length} deleted</p>
|
|
<form method="POST" action="?/runScrapeImport" class="mt-4">
|
|
<button
|
|
type="submit"
|
|
class="rounded-full border border-sky-300/40 bg-sky-500/20 px-4 py-2 text-sm font-semibold text-sky-100 transition hover:bg-sky-500/30"
|
|
>
|
|
🔄 Lancer scrape + import
|
|
</button>
|
|
</form>
|
|
{#if form?.message}
|
|
<p class={`mt-3 text-sm ${form.success ? 'text-emerald-300' : 'text-rose-300'}`}>
|
|
{form.message}
|
|
</p>
|
|
{/if}
|
|
{#if form?.logs}
|
|
<pre class="mt-3 max-h-72 overflow-auto rounded-lg border border-white/10 bg-slate-900/70 p-3 text-xs text-slate-200 whitespace-pre-wrap">{form.logs}</pre>
|
|
{/if}
|
|
{#if newCharacters.length + modifiedCharacters.length + deletedCharacters.length > 0}
|
|
<form method="POST" action="?/acceptAll" class="mt-4">
|
|
<button
|
|
type="submit"
|
|
class="rounded-full border border-emerald-300/40 bg-emerald-500/20 px-4 py-2 text-sm font-semibold text-emerald-100 transition hover:bg-emerald-500/30"
|
|
>
|
|
✅ Accepter tous les changements
|
|
</button>
|
|
</form>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- New Characters Section -->
|
|
{#if newCharacters.length > 0}
|
|
<section class="space-y-4">
|
|
<h2 class="text-xl font-bold text-emerald-400 uppercase tracking-[0.15em]">
|
|
🆕 New Characters ({newCharacters.length})
|
|
</h2>
|
|
<div class="grid gap-4">
|
|
{#each newCharacters as change (change.id)}
|
|
<div class="rounded-lg border border-emerald-500/30 bg-emerald-500/5 p-4 space-y-3">
|
|
<div class="flex items-center justify-between gap-3">
|
|
<div class="flex items-center gap-3">
|
|
{#if change.scraped.pictureUrl}
|
|
<a href="https://onepiece.fandom.com/fr/wiki/{change.scraped.url}" target="_blank" rel="noopener noreferrer">
|
|
<img
|
|
src={change.scraped.pictureUrl ?? undefined}
|
|
alt={change.scraped.name}
|
|
class="w-12 h-12 rounded object-cover hover:opacity-80 transition"
|
|
/>
|
|
</a>
|
|
{/if}
|
|
<div>
|
|
<h3 class="font-bold text-emerald-300">{change.scraped.name}</h3>
|
|
<p class="text-sm text-gray-500">{change.id}</p>
|
|
</div>
|
|
</div>
|
|
<form method="POST" action="?/acceptOne">
|
|
<input type="hidden" name="characterId" value={change.id} />
|
|
<button
|
|
type="submit"
|
|
class="rounded-full border border-emerald-300/40 bg-emerald-500/20 px-3 py-1 text-xs font-semibold text-emerald-100 transition hover:bg-emerald-500/30"
|
|
>
|
|
Accepter
|
|
</button>
|
|
</form>
|
|
</div>
|
|
<div class="grid grid-cols-2 gap-2 text-sm">
|
|
<div>
|
|
<span class="text-gray-400">Status:</span>
|
|
<span class="ml-2">{formatValue(change.scraped.status)}</span>
|
|
</div>
|
|
<div>
|
|
<span class="text-gray-400">Gender:</span>
|
|
<span class="ml-2">{formatValue(change.scraped.gender)}</span>
|
|
</div>
|
|
<div>
|
|
<span class="text-gray-400">Age:</span>
|
|
<span class="ml-2">{formatValue(change.scraped.age)}</span>
|
|
</div>
|
|
<div>
|
|
<span class="text-gray-400">Bounty:</span>
|
|
<span class="ml-2">{formatValue(change.scraped.bounty)}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
</section>
|
|
{/if}
|
|
|
|
<!-- Modified Characters Section -->
|
|
{#if modifiedCharacters.length > 0}
|
|
<section class="space-y-4">
|
|
<h2 class="text-xl font-bold text-amber-400 uppercase tracking-[0.15em]">
|
|
✏️ Modified Characters ({modifiedCharacters.length})
|
|
</h2>
|
|
<div class="grid gap-6">
|
|
{#each modifiedCharacters as change (change.id)}
|
|
<div class="rounded-lg border border-amber-500/30 bg-amber-500/5 p-4 space-y-4">
|
|
<div class="flex items-center justify-between gap-3 pb-4 border-b border-amber-500/20">
|
|
<div class="flex items-center gap-3">
|
|
{#if change.current?.pictureUrl}
|
|
<a href="https://onepiece.fandom.com/fr/wiki/{change.current?.url ?? change.scraped.url}" target="_blank" rel="noopener noreferrer">
|
|
<img
|
|
src={change.current?.pictureUrl ?? undefined}
|
|
alt={change.current?.name ?? change.scraped.name}
|
|
class="w-12 h-12 rounded object-cover hover:opacity-80 transition"
|
|
/>
|
|
</a>
|
|
{/if}
|
|
<div>
|
|
<h3 class="font-bold text-amber-300">{change.current?.name ?? change.scraped.name}</h3>
|
|
<p class="text-sm text-gray-500">{change.id}</p>
|
|
</div>
|
|
</div>
|
|
<form method="POST" action="?/acceptOne">
|
|
<input type="hidden" name="characterId" value={change.id} />
|
|
<button
|
|
type="submit"
|
|
class="rounded-full border border-amber-300/40 bg-amber-500/20 px-3 py-1 text-xs font-semibold text-amber-100 transition hover:bg-amber-500/30"
|
|
>
|
|
Accepter
|
|
</button>
|
|
</form>
|
|
</div>
|
|
|
|
{#if change.differences}
|
|
<div class="space-y-3">
|
|
{#each Object.entries(change.differences) as [field, diff] (field)}
|
|
<div class="bg-slate-900/50 rounded p-3 space-y-1">
|
|
<h4 class="text-sm font-semibold text-amber-100 uppercase tracking-widest">{field}</h4>
|
|
<div class="grid grid-cols-2 gap-4 text-sm">
|
|
<div class="space-y-1">
|
|
<p class="text-gray-500">Current:</p>
|
|
<p class="font-mono text-gray-300">{formatValue(diff.current)}</p>
|
|
</div>
|
|
<div class="space-y-1">
|
|
<p class="text-gray-500">Scraped:</p>
|
|
<p class="font-mono text-emerald-300 font-semibold">{formatValue(diff.scraped)}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
</section>
|
|
{/if}
|
|
|
|
<!-- Deleted Characters Section -->
|
|
{#if deletedCharacters.length > 0}
|
|
<section class="space-y-4">
|
|
<h2 class="text-xl font-bold text-rose-400 uppercase tracking-[0.15em]">
|
|
🗑️ Deleted Characters ({deletedCharacters.length})
|
|
</h2>
|
|
<div class="grid gap-4">
|
|
{#each deletedCharacters as change (change.id)}
|
|
<div class="rounded-lg border border-rose-500/30 bg-rose-500/5 p-4 space-y-3">
|
|
<div class="flex items-center justify-between gap-3">
|
|
<div class="flex items-center gap-3">
|
|
{#if change.current?.pictureUrl}
|
|
<a href="https://onepiece.fandom.com/fr/wiki/{change.current?.url ?? change.scraped.url}" target="_blank" rel="noopener noreferrer">
|
|
<img
|
|
src={change.current?.pictureUrl ?? undefined}
|
|
alt={change.current?.name ?? change.scraped.name}
|
|
class="w-12 h-12 rounded object-cover hover:opacity-80 transition"
|
|
/>
|
|
</a>
|
|
{/if}
|
|
<div>
|
|
<h3 class="font-bold text-rose-300">{change.current?.name ?? change.scraped.name}</h3>
|
|
<p class="text-sm text-gray-500">{change.id}</p>
|
|
</div>
|
|
</div>
|
|
<form method="POST" action="?/acceptOne">
|
|
<input type="hidden" name="characterId" value={change.id} />
|
|
<button
|
|
type="submit"
|
|
class="rounded-full border border-rose-300/40 bg-rose-500/20 px-3 py-1 text-xs font-semibold text-rose-100 transition hover:bg-rose-500/30"
|
|
>
|
|
Supprimer
|
|
</button>
|
|
</form>
|
|
</div>
|
|
<p class="text-sm text-rose-200/80">This character is no longer present in the latest scrape and will be removed if accepted.</p>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
</section>
|
|
{/if}
|
|
|
|
{#if newCharacters.length === 0 && modifiedCharacters.length === 0 && deletedCharacters.length === 0}
|
|
<div class="rounded-lg border border-white/10 bg-white/5 p-8 text-center">
|
|
<p class="text-gray-400">Aucun changement détecté. Les tables character et characterScrapeValidation sont synchronisées.</p>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<style>
|
|
:global(body) {
|
|
background-color: rgb(15, 23, 42);
|
|
color: rgb(203, 213, 225);
|
|
}
|
|
</style>
|