feat: implement character changes page with new and modified character listings
All checks were successful
Build Docker Image / build (push) Successful in 1m23s
All checks were successful
Build Docker Image / build (push) Successful in 1m23s
This commit is contained in:
@@ -327,8 +327,8 @@ async function importFromJson(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Check for changes and update scrapeValidation table
|
// Update scrapeValidation table
|
||||||
console.log('Characters table not empty, checking for changes...\n');
|
console.log('Characters table not empty, updating scrapeValidation table for changes...\n');
|
||||||
|
|
||||||
for (let i = 0; i < characters.length; i++) {
|
for (let i = 0; i < characters.length; i++) {
|
||||||
const item = characters[i];
|
const item = characters[i];
|
||||||
@@ -340,33 +340,19 @@ async function importFromJson(): Promise<void> {
|
|||||||
.where(eq(character.id, item.id));
|
.where(eq(character.id, item.id));
|
||||||
|
|
||||||
lastSql = selectQuery.toSQL();
|
lastSql = selectQuery.toSQL();
|
||||||
const [dbCharacter] = await selectQuery;
|
|
||||||
|
|
||||||
const jsonData = transformCharacterData(item);
|
const jsonData = transformCharacterData(item);
|
||||||
const changed = hasChanged(jsonData, dbCharacter);
|
|
||||||
|
|
||||||
if (changed) {
|
const upsertQuery = db
|
||||||
// Update scrapeValidation table with changes
|
.insert(characterScrapeValidation)
|
||||||
const upsertQuery = db
|
.values(jsonData)
|
||||||
.insert(characterScrapeValidation)
|
.onConflictDoUpdate({
|
||||||
.values(jsonData)
|
target: characterScrapeValidation.id,
|
||||||
.onConflictDoUpdate({
|
set: jsonData
|
||||||
target: characterScrapeValidation.id,
|
});
|
||||||
set: jsonData
|
|
||||||
});
|
|
||||||
|
|
||||||
lastSql = upsertQuery.toSQL();
|
|
||||||
await upsertQuery;
|
|
||||||
} else {
|
|
||||||
// No changes, delete from scrapeValidation if it exists
|
|
||||||
const deleteQuery = db
|
|
||||||
.delete(characterScrapeValidation)
|
|
||||||
.where(eq(characterScrapeValidation.id, item.id));
|
|
||||||
|
|
||||||
lastSql = deleteQuery.toSQL();
|
|
||||||
await deleteQuery;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
lastSql = upsertQuery.toSQL();
|
||||||
|
await upsertQuery;
|
||||||
successCount++;
|
successCount++;
|
||||||
process.stdout.write(`\rProcessed: ${successCount}/${characters.length}`);
|
process.stdout.write(`\rProcessed: ${successCount}/${characters.length}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
const navItems = [
|
const navItems = [
|
||||||
{ href: '/admin', label: 'Dashboard', icon: '📊' },
|
{ href: '/admin', label: 'Dashboard', icon: '📊' },
|
||||||
{ href: '/admin/characters', label: 'Characters', icon: '🗣️' },
|
{ href: '/admin/characters', label: 'Characters', icon: '🗣️' },
|
||||||
|
{ href: '/admin/character-changes', label: 'Changes', icon: '🔄' },
|
||||||
{ href: '/admin/devil-fruits', label: 'Devil Fruits', icon: '🍎' },
|
{ href: '/admin/devil-fruits', label: 'Devil Fruits', icon: '🍎' },
|
||||||
{ href: '/admin/arcs', label: 'Arcs', icon: '📚' },
|
{ href: '/admin/arcs', label: 'Arcs', icon: '📚' },
|
||||||
{ href: '/admin/users', label: 'Users', icon: '👥' },
|
{ href: '/admin/users', label: 'Users', icon: '👥' },
|
||||||
|
|||||||
88
src/routes/(admin)/admin/character-changes/+page.server.ts
Normal file
88
src/routes/(admin)/admin/character-changes/+page.server.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { character, characterScrapeValidation } from '$lib/server/db/schema';
|
||||||
|
|
||||||
|
export async function load() {
|
||||||
|
// Get all characters from both tables
|
||||||
|
const currentCharacters = await db.select().from(character);
|
||||||
|
const scrapedCharacters = await db.select().from(characterScrapeValidation);
|
||||||
|
|
||||||
|
// Create a map for quick lookup
|
||||||
|
const currentCharMap = new Map(currentCharacters.map(c => [c.id, c]));
|
||||||
|
|
||||||
|
// Compare and categorize changes
|
||||||
|
const changes: {
|
||||||
|
type: 'new' | 'modified';
|
||||||
|
id: string;
|
||||||
|
scraped: (typeof scrapedCharacters)[0];
|
||||||
|
current?: (typeof currentCharacters)[0];
|
||||||
|
differences?: Record<string, { current: any; scraped: any }>;
|
||||||
|
}[] = [];
|
||||||
|
|
||||||
|
for (const scraped of scrapedCharacters) {
|
||||||
|
const current = currentCharMap.get(scraped.id);
|
||||||
|
|
||||||
|
if (!current) {
|
||||||
|
// New character
|
||||||
|
changes.push({
|
||||||
|
type: 'new',
|
||||||
|
id: scraped.id,
|
||||||
|
scraped
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Check if different
|
||||||
|
const differences: Record<string, { current: any; scraped: any }> = {};
|
||||||
|
const fieldsToCompare = [
|
||||||
|
'name',
|
||||||
|
'gender',
|
||||||
|
'age',
|
||||||
|
'affiliations',
|
||||||
|
'devilFruitId',
|
||||||
|
'hakiObservation',
|
||||||
|
'hakiArmament',
|
||||||
|
'hakiConqueror',
|
||||||
|
'bounty',
|
||||||
|
'height',
|
||||||
|
'origin',
|
||||||
|
'firstAppearance',
|
||||||
|
'pictureUrl',
|
||||||
|
'epithets',
|
||||||
|
'status',
|
||||||
|
'arcId',
|
||||||
|
'url'
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const field of fieldsToCompare) {
|
||||||
|
const currentValue = current[field as keyof typeof current];
|
||||||
|
const scrapedValue = scraped[field as keyof typeof scraped];
|
||||||
|
|
||||||
|
// Deep comparison for JSON fields
|
||||||
|
if (JSON.stringify(currentValue) !== JSON.stringify(scrapedValue)) {
|
||||||
|
differences[field] = {
|
||||||
|
current: currentValue,
|
||||||
|
scraped: scrapedValue
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(differences).length > 0) {
|
||||||
|
changes.push({
|
||||||
|
type: 'modified',
|
||||||
|
id: scraped.id,
|
||||||
|
scraped,
|
||||||
|
current,
|
||||||
|
differences
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
changes: changes.sort((a, b) => {
|
||||||
|
// Show 'new' first, then 'modified'
|
||||||
|
if (a.type !== b.type) {
|
||||||
|
return a.type === 'new' ? -1 : 1;
|
||||||
|
}
|
||||||
|
return a.id.localeCompare(b.id);
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}
|
||||||
146
src/routes/(admin)/admin/character-changes/+page.svelte
Normal file
146
src/routes/(admin)/admin/character-changes/+page.svelte
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
|
||||||
|
let { data } = $props();
|
||||||
|
|
||||||
|
const newCharacters = $derived(data.changes.filter((c: any) => c.type === 'new'));
|
||||||
|
const modifiedCharacters = $derived(data.changes.filter((c: any) => c.type === 'modified'));
|
||||||
|
|
||||||
|
function formatValue(value: any): string {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.join(', ');
|
||||||
|
}
|
||||||
|
if (typeof value === 'boolean') {
|
||||||
|
return value ? '✓' : '✗';
|
||||||
|
}
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDifferenceColor(current: any, scraped: any): string {
|
||||||
|
if (JSON.stringify(current) === JSON.stringify(scraped)) {
|
||||||
|
return 'text-gray-400';
|
||||||
|
}
|
||||||
|
return 'text-amber-300';
|
||||||
|
}
|
||||||
|
</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</p>
|
||||||
|
</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 gap-3">
|
||||||
|
{#if change.scraped.pictureUrl}
|
||||||
|
<img
|
||||||
|
src={change.scraped.pictureUrl}
|
||||||
|
alt={change.scraped.name}
|
||||||
|
class="w-12 h-12 rounded object-cover"
|
||||||
|
/>
|
||||||
|
{/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>
|
||||||
|
<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 gap-3 pb-4 border-b border-amber-500/20">
|
||||||
|
{#if change.current?.pictureUrl}
|
||||||
|
<img
|
||||||
|
src={change.current.pictureUrl}
|
||||||
|
alt={change.current.name}
|
||||||
|
class="w-12 h-12 rounded object-cover"
|
||||||
|
/>
|
||||||
|
{/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>
|
||||||
|
|
||||||
|
{#if change.differences}
|
||||||
|
<div class="space-y-3">
|
||||||
|
{#each Object.entries(change.differences) as [field, diff]}
|
||||||
|
<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}
|
||||||
|
|
||||||
|
{#if newCharacters.length === 0 && modifiedCharacters.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>
|
||||||
Reference in New Issue
Block a user