Compare commits
17 Commits
997b2f1781
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ef6bf9862e | |||
| d75c74ac3c | |||
| fa14156d82 | |||
| 29297d3773 | |||
| 28bb8f526b | |||
| 288271fb04 | |||
| fb64c84a17 | |||
| 81e205dd4e | |||
| ded1c8313d | |||
| 4426b5d28a | |||
| 5ad0428420 | |||
| 7760570365 | |||
| 5fde54a2a7 | |||
| 2a3c82f777 | |||
| 835163f5bb | |||
| 5020393b22 | |||
| 94393851c8 |
1
drizzle/0001_fuzzy_talisman.sql
Normal file
1
drizzle/0001_fuzzy_talisman.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE `character_scrape_validation` ADD `is_deleted` integer DEFAULT false;
|
||||
1
drizzle/0002_old_earthquake.sql
Normal file
1
drizzle/0002_old_earthquake.sql
Normal file
@@ -0,0 +1 @@
|
||||
DROP TABLE `character_override`;
|
||||
8
drizzle/0003_mixed_ben_grimm.sql
Normal file
8
drizzle/0003_mixed_ben_grimm.sql
Normal file
@@ -0,0 +1,8 @@
|
||||
ALTER TABLE `character` ADD `affiliation` text;--> statement-breakpoint
|
||||
ALTER TABLE `character` ADD `fr_affiliation` text;--> statement-breakpoint
|
||||
ALTER TABLE `character` DROP COLUMN `affiliations`;--> statement-breakpoint
|
||||
ALTER TABLE `character` DROP COLUMN `fr_affiliations`;--> statement-breakpoint
|
||||
ALTER TABLE `character_scrape_validation` ADD `affiliation` text;--> statement-breakpoint
|
||||
ALTER TABLE `character_scrape_validation` ADD `fr_affiliation` text;--> statement-breakpoint
|
||||
ALTER TABLE `character_scrape_validation` DROP COLUMN `affiliations`;--> statement-breakpoint
|
||||
ALTER TABLE `character_scrape_validation` DROP COLUMN `fr_affiliations`;
|
||||
1396
drizzle/meta/0001_snapshot.json
Normal file
1396
drizzle/meta/0001_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1185
drizzle/meta/0002_snapshot.json
Normal file
1185
drizzle/meta/0002_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1185
drizzle/meta/0003_snapshot.json
Normal file
1185
drizzle/meta/0003_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,27 @@
|
||||
"when": 1773602933375,
|
||||
"tag": "0000_huge_doctor_octopus",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "6",
|
||||
"when": 1773697753818,
|
||||
"tag": "0001_fuzzy_talisman",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "6",
|
||||
"when": 1775950314114,
|
||||
"tag": "0002_old_earthquake",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "6",
|
||||
"when": 1776195681488,
|
||||
"tag": "0003_mixed_ben_grimm",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createClient } from '@libsql/client';
|
||||
import { drizzle } from 'drizzle-orm/libsql';
|
||||
import { sql, eq } from 'drizzle-orm';
|
||||
import { sql, eq, inArray } from 'drizzle-orm';
|
||||
import fs from 'fs';
|
||||
import { arc, character, devilFruit, characterScrapeValidation, type DevilFruitType } from '../src/lib/server/db/schema';
|
||||
|
||||
@@ -28,8 +28,8 @@ type CharacterRecord = {
|
||||
frName?: string | null;
|
||||
gender?: string | null;
|
||||
age?: number | null;
|
||||
affiliations?: string[] | string | null;
|
||||
frAffiliations?: string[] | string | null;
|
||||
affiliation?: string | null;
|
||||
frAffiliation?: string | null;
|
||||
devilFruitId?: string | null;
|
||||
hakiObservation?: boolean;
|
||||
hakiArmament?: boolean;
|
||||
@@ -123,8 +123,8 @@ function transformCharacterData(item: CharacterRecord) {
|
||||
frName: toNullable(item.frName),
|
||||
gender: toNullable(item.gender),
|
||||
age: toNullable(item.age),
|
||||
affiliations: toJsonArray(item.affiliations),
|
||||
frAffiliations: toJsonArray(item.frAffiliations),
|
||||
affiliation: toNullable(item.affiliation),
|
||||
frAffiliation: toNullable(item.frAffiliation),
|
||||
devilFruitId: toNullable(item.devilFruitId),
|
||||
hakiObservation: !!item.hakiObservation,
|
||||
hakiArmament: !!item.hakiArmament,
|
||||
@@ -140,7 +140,8 @@ function transformCharacterData(item: CharacterRecord) {
|
||||
status: toNullable(item.status),
|
||||
arcId: toNullable(item.arcId),
|
||||
url: toNullable(item.url),
|
||||
frUrl: toNullable(item.frUrl)
|
||||
frUrl: toNullable(item.frUrl),
|
||||
isDeleted: false
|
||||
};
|
||||
}
|
||||
|
||||
@@ -307,6 +308,7 @@ async function importFromJson(): Promise<void> {
|
||||
} else {
|
||||
// Update scrapeValidation table
|
||||
console.log('Characters table not empty, updating scrapeValidation table for changes...\n');
|
||||
const scrapedCharacterIds: string[] = [];
|
||||
|
||||
for (let i = 0; i < characters.length; i++) {
|
||||
const item = characters[i];
|
||||
@@ -319,6 +321,7 @@ async function importFromJson(): Promise<void> {
|
||||
|
||||
lastSql = selectQuery.toSQL();
|
||||
|
||||
scrapedCharacterIds.push(item.id);
|
||||
const jsonData = transformCharacterData(item);
|
||||
|
||||
const upsertQuery = db
|
||||
@@ -341,6 +344,57 @@ async function importFromJson(): Promise<void> {
|
||||
logSqlOnError(lastSql);
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch all characters from the character table and mark those absent from the
|
||||
// scrape as deleted in scrape validation.
|
||||
const allCharacters = await db.select({ id: character.id }).from(character);
|
||||
const scrapedSet = new Set(scrapedCharacterIds);
|
||||
const idsToMarkDeleted = allCharacters
|
||||
.map((c) => c.id)
|
||||
.filter((id) => !scrapedSet.has(id));
|
||||
|
||||
if (idsToMarkDeleted.length > 0) {
|
||||
console.log(`\n⚠️ Marking ${idsToMarkDeleted.length} character(s) as deleted in scrape validation...`);
|
||||
const deletedCharacterRows = await db
|
||||
.select()
|
||||
.from(character)
|
||||
.where(inArray(character.id, idsToMarkDeleted));
|
||||
|
||||
for (const row of deletedCharacterRows) {
|
||||
await db
|
||||
.insert(characterScrapeValidation)
|
||||
.values({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
frName: row.frName,
|
||||
gender: row.gender,
|
||||
age: row.age,
|
||||
affiliation: row.affiliation,
|
||||
frAffiliation: row.frAffiliation,
|
||||
devilFruitId: row.devilFruitId,
|
||||
hakiObservation: row.hakiObservation,
|
||||
hakiArmament: row.hakiArmament,
|
||||
hakiConqueror: row.hakiConqueror,
|
||||
bounty: row.bounty,
|
||||
height: row.height,
|
||||
origin: row.origin,
|
||||
frOrigin: row.frOrigin,
|
||||
firstAppearance: row.firstAppearance,
|
||||
pictureUrl: row.pictureUrl,
|
||||
epithets: row.epithets,
|
||||
frEpithets: row.frEpithets,
|
||||
status: row.status,
|
||||
arcId: row.arcId,
|
||||
url: row.url,
|
||||
frUrl: row.frUrl,
|
||||
isDeleted: true
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: characterScrapeValidation.id,
|
||||
set: { isDeleted: true }
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n\n✓ Characters imported!`);
|
||||
|
||||
@@ -23,7 +23,8 @@ const columns = [
|
||||
'origin',
|
||||
'devilFruitType',
|
||||
'arc',
|
||||
'status'
|
||||
'status',
|
||||
'age'
|
||||
] as const;
|
||||
|
||||
async function initColumnConfig(): Promise<void> {
|
||||
|
||||
@@ -23,8 +23,8 @@ interface Character {
|
||||
frOrigin: string | null;
|
||||
devilFruitId: string | null;
|
||||
devilFruitUrl: string | null;
|
||||
affiliations: string[];
|
||||
frAffiliations: string[] | null;
|
||||
affiliation: string | null;
|
||||
frAffiliation: string | null;
|
||||
bounty: number | null;
|
||||
hakiObservation: boolean;
|
||||
hakiArmament: boolean;
|
||||
@@ -307,6 +307,10 @@ async function fetchAllCharacters(arcsList: Arc[]): Promise<Character[]> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (charName.toLowerCase().includes('family') || charName === 'Four Emperors') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (charUrl) {
|
||||
charUrl = charUrl.replace('/wiki/', '');
|
||||
characterList.push({
|
||||
@@ -366,7 +370,7 @@ async function fetchAllCharacters(arcsList: Arc[]): Promise<Character[]> {
|
||||
Age: data.age,
|
||||
Status: data.status,
|
||||
Epithets: data.epithets.join(', '),
|
||||
Affiliations: data.affiliations.join(', '),
|
||||
Affiliation: data.affiliation,
|
||||
DevilFruitId: data.devilFruitId,
|
||||
DevilFruitUrl: data.devilFruitUrl,
|
||||
HakiObservation: data.hakiObservation ? 'Yes' : 'No',
|
||||
@@ -437,10 +441,10 @@ async function fetchCharacter(
|
||||
let gender: string | null = null;
|
||||
for (const cat of categories) {
|
||||
const catName = cat['*'] || '';
|
||||
if (catName === 'Male_Characters') {
|
||||
if (catName === 'Male_Characters' || catName === 'Kings' || catName === 'Princes' || catName === 'Former_Kings' || catName === 'Former_Princes') {
|
||||
gender = 'Male';
|
||||
break;
|
||||
} else if (catName === 'Female_Characters') {
|
||||
} else if (catName === 'Female_Characters' || catName === 'Queens' || catName === 'Princesses' || catName === 'Former_Queens' || catName === 'Former_Princesses' || catName === 'Queens_Regnant' || catName === 'Gorgon_Sisters' || catName === 'Kuja') {
|
||||
gender = 'Female';
|
||||
break;
|
||||
}
|
||||
@@ -449,8 +453,8 @@ async function fetchCharacter(
|
||||
// Extract age
|
||||
const age = extractAge($);
|
||||
|
||||
// Extract affiliations
|
||||
const affiliations = await extractAffiliations($, 'en');
|
||||
// Extract affiliation
|
||||
const affiliation = await extractAffiliations($, 'en');
|
||||
|
||||
// Extract epithets
|
||||
const epithets = extractEpithets($);
|
||||
@@ -509,7 +513,7 @@ async function fetchCharacter(
|
||||
|
||||
let frName = frjsonData?.parse?.title || null;
|
||||
|
||||
const frAffiliations = frjsonData
|
||||
const frAffiliation = frjsonData
|
||||
? await extractAffiliations(cheerio.load(frjsonData.parse?.text?.['*'] || ''), 'fr')
|
||||
: null;
|
||||
|
||||
@@ -538,8 +542,8 @@ async function fetchCharacter(
|
||||
frOrigin,
|
||||
devilFruitId,
|
||||
devilFruitUrl,
|
||||
affiliations,
|
||||
frAffiliations,
|
||||
affiliation,
|
||||
frAffiliation,
|
||||
bounty,
|
||||
hakiObservation,
|
||||
hakiArmament,
|
||||
@@ -587,15 +591,15 @@ function extractAge($: cheerio.CheerioAPI): number | null {
|
||||
/**
|
||||
* Extract affiliations from infobox
|
||||
*/
|
||||
async function extractAffiliations($: cheerio.CheerioAPI, lang: string): Promise<string[]> {
|
||||
async function extractAffiliations($: cheerio.CheerioAPI, lang: string): Promise<string | null> {
|
||||
const div = $('[data-source="affiliation"] .pi-data-value');
|
||||
if (div.length === 0) return [];
|
||||
if (div.length === 0) return null;
|
||||
|
||||
const cleanedDiv = div.clone();
|
||||
cleanedDiv.find('sup').remove();
|
||||
|
||||
const text = cleanedDiv.html();
|
||||
if (!text) return [];
|
||||
if (!text) return null;
|
||||
|
||||
// Resolve affiliations from linked page titles.
|
||||
const links = cleanedDiv.find('a').toArray();
|
||||
@@ -620,14 +624,14 @@ async function extractAffiliations($: cheerio.CheerioAPI, lang: string): Promise
|
||||
|
||||
const uniqueLinks = Array.from(new Set(linkValues.filter(Boolean)));
|
||||
if (uniqueLinks.length > 0) {
|
||||
return uniqueLinks;
|
||||
return uniqueLinks[0];
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to parsing text
|
||||
const cleanText = text.replace(/<[^>]*>/g, '').trim();
|
||||
const parts = cleanText.split(/\s*\n\s*|\s*;\s*|\s*,\s*/).filter(Boolean);
|
||||
return parts.length > 0 ? parts : [];
|
||||
return parts.length > 0 ? parts[0] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -635,7 +639,9 @@ async function extractAffiliations($: cheerio.CheerioAPI, lang: string): Promise
|
||||
* Handles both quoted and unquoted epithets, keeping only the main/latest readable values.
|
||||
*/
|
||||
function extractEpithets($: cheerio.CheerioAPI): string[] {
|
||||
const div = $('[data-source="epithet"] .pi-data-value');
|
||||
const div = $(
|
||||
'[data-source="epithet"] .pi-data-value, [data-source="épithète"] .pi-data-value'
|
||||
).first();
|
||||
if (div.length === 0) return [];
|
||||
|
||||
const cleanedDiv = div.clone();
|
||||
@@ -802,11 +808,11 @@ function extractStatus($: cheerio.CheerioAPI): string | null {
|
||||
|
||||
const statusText = div.text().trim().toLowerCase();
|
||||
|
||||
if (statusText.includes('Alive')) {
|
||||
if (statusText.includes('alive')) {
|
||||
return 'Alive';
|
||||
} else if (statusText.includes('Dead')) {
|
||||
} else if (statusText.includes('deceased')) {
|
||||
return 'Dead';
|
||||
} else if (statusText.includes('Unknown')) {
|
||||
} else if (statusText.includes('unknown')) {
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
@@ -863,9 +869,8 @@ async function saveToCSV(characters: Character[]): Promise<void> {
|
||||
status: c.status || '',
|
||||
epithets: Array.isArray(c.epithets) ? c.epithets.join(', ') : c.epithets || '',
|
||||
devilFruitId: c.devilFruitId || '',
|
||||
affiliations: Array.isArray(c.affiliations)
|
||||
? c.affiliations.join(', ')
|
||||
: c.affiliations || '',
|
||||
affiliation: c.affiliation || '',
|
||||
frAffiliation: c.frAffiliation || '',
|
||||
bounty: c.bounty ?? 0,
|
||||
hakiObservation: c.hakiObservation ? 1 : 0,
|
||||
hakiArmament: c.hakiArmament ? 1 : 0,
|
||||
|
||||
73
src/lib/components/FriendsTodaySection.svelte
Normal file
73
src/lib/components/FriendsTodaySection.svelte
Normal file
@@ -0,0 +1,73 @@
|
||||
<script lang="ts">
|
||||
import { t } from '$lib/i18n';
|
||||
|
||||
type TriedCharacter = {
|
||||
id: string;
|
||||
name: string;
|
||||
pictureUrl: string | null;
|
||||
};
|
||||
|
||||
type FriendTodayResult = {
|
||||
userId: string;
|
||||
name: string;
|
||||
image: string | null;
|
||||
tryCount: number;
|
||||
triedCharacters: TriedCharacter[];
|
||||
};
|
||||
|
||||
export let friendsTodayResults: FriendTodayResult[] = [];
|
||||
</script>
|
||||
|
||||
{#if friendsTodayResults.length > 0}
|
||||
<section class="mt-6 rounded-3xl border border-white/10 bg-white/5 p-6 shadow-[0_24px_60px_rgba(0,0,0,0.45)] backdrop-blur">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.28em] text-amber-100 text-center">{$t.game.daily.friendsToday}</p>
|
||||
<div class="mt-4 space-y-2">
|
||||
{#each friendsTodayResults as friendResult (friendResult.userId)}
|
||||
<div class="rounded-lg border border-white/10 bg-slate-950/50 px-4 py-3">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-3">
|
||||
{#if friendResult.image}
|
||||
<img
|
||||
src={friendResult.image}
|
||||
alt={friendResult.name}
|
||||
class="h-8 w-8 rounded-full border border-white/20 object-cover"
|
||||
/>
|
||||
{:else}
|
||||
<div class="flex h-8 w-8 items-center justify-center rounded-full bg-amber-300/20 text-xs font-semibold text-amber-100">
|
||||
{friendResult.name?.charAt(0).toUpperCase() || 'U'}
|
||||
</div>
|
||||
{/if}
|
||||
<p class="text-sm font-semibold text-slate-100">{friendResult.name}</p>
|
||||
</div>
|
||||
<p class="text-sm text-amber-300">
|
||||
{friendResult.tryCount} {friendResult.tryCount > 1 ? $t.game.daily.friendTryPlural : $t.game.daily.friendTrySingular}
|
||||
</p>
|
||||
</div>
|
||||
<div class="mt-3 border-t border-white/10 pt-2">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-[0.2em] text-amber-100">
|
||||
{$t.game.daily.friendsTriedCharacters}
|
||||
</p>
|
||||
{#if friendResult.triedCharacters && friendResult.triedCharacters.length > 0}
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
{#each friendResult.triedCharacters as triedCharacter (triedCharacter.id)}
|
||||
<span class="inline-flex items-center gap-2 rounded-full border border-white/15 bg-white/5 px-2.5 py-1 text-xs text-slate-200">
|
||||
{#if triedCharacter.pictureUrl}
|
||||
<img
|
||||
src={triedCharacter.pictureUrl}
|
||||
alt={triedCharacter.name}
|
||||
class="h-4 w-4 rounded-full object-cover"
|
||||
/>
|
||||
{/if}
|
||||
{triedCharacter.name}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="mt-1 text-xs text-slate-500">{$t.game.daily.friendsNoTriedCharacters}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
@@ -1,6 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { formatBounty } from '$lib';
|
||||
import { resolve } from '$app/paths';
|
||||
import type { CharacterWithRelations } from '$lib/server/daily-character';
|
||||
import { language, t } from '$lib/i18n';
|
||||
|
||||
@@ -9,61 +8,16 @@
|
||||
export let columnVisibility: {
|
||||
status?: boolean;
|
||||
gender?: boolean;
|
||||
affiliations?: boolean;
|
||||
affiliation?: boolean;
|
||||
devilFruitType?: boolean;
|
||||
haki?: boolean;
|
||||
bounty?: boolean;
|
||||
height?: boolean;
|
||||
age?: boolean;
|
||||
origin?: boolean;
|
||||
arc?: boolean;
|
||||
};
|
||||
|
||||
function normalizeAffiliations(value: unknown): string[] {
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.map((entry) => (typeof entry === 'string' ? entry.trim() : ''))
|
||||
.filter((entry) => entry.length > 0);
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (trimmed.startsWith('[')) {
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed
|
||||
.map((entry) => (typeof entry === 'string' ? entry.trim() : ''))
|
||||
.filter((entry) => entry.length > 0);
|
||||
}
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
return trimmed
|
||||
.split(',')
|
||||
.map((entry) => entry.trim())
|
||||
.filter((entry) => entry.length > 0);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function firstAffiliation(value: unknown): string | null {
|
||||
const affiliations = normalizeAffiliations(value);
|
||||
return affiliations.length > 0 ? affiliations[0] : null;
|
||||
}
|
||||
|
||||
function hasMatchingPrimaryAffiliation(characterAffiliations: unknown, dailyAffiliations: unknown): boolean {
|
||||
const characterPrimary = firstAffiliation(characterAffiliations);
|
||||
const dailyPrimary = firstAffiliation(dailyAffiliations);
|
||||
return characterPrimary === dailyPrimary;
|
||||
}
|
||||
|
||||
$: isFrench = $language === 'fr';
|
||||
|
||||
function getDisplayName(character: CharacterWithRelations): string {
|
||||
@@ -106,6 +60,14 @@
|
||||
return character.arcName;
|
||||
}
|
||||
|
||||
function getDislayAffiliation(character: CharacterWithRelations): string | null {
|
||||
if (isFrench && typeof character.frAffiliation === 'string' && character.frAffiliation.length > 0) {
|
||||
return character.frAffiliation;
|
||||
}
|
||||
|
||||
return character.affiliation;
|
||||
}
|
||||
|
||||
function hasMatchingArc(characterEntry: CharacterWithRelations, dailyEntry: CharacterWithRelations): boolean {
|
||||
return getDisplayArcName(characterEntry) === getDisplayArcName(dailyEntry);
|
||||
}
|
||||
@@ -156,7 +118,7 @@
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
{#if columnVisibility.affiliations !== false}
|
||||
{#if columnVisibility.affiliation !== false}
|
||||
<div
|
||||
class="flex w-16 shrink-0 items-center justify-center rounded-lg border border-amber-200/30 bg-amber-900/30 p-1 text-center sm:w-20 sm:p-2 md:w-24"
|
||||
>
|
||||
@@ -211,6 +173,17 @@
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
{#if columnVisibility.age !== false}
|
||||
<div
|
||||
class="flex w-16 shrink-0 items-center justify-center rounded-lg border border-amber-200/30 bg-amber-900/30 p-1 text-center sm:w-20 sm:p-2 md:w-24"
|
||||
>
|
||||
<p
|
||||
class="text-[9px] font-semibold tracking-wider text-amber-100 uppercase sm:text-xs"
|
||||
>
|
||||
{$t.game.components.guessHistory.age}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
{#if columnVisibility.origin !== false}
|
||||
<div
|
||||
class="flex w-16 shrink-0 items-center justify-center rounded-lg border border-amber-200/30 bg-amber-900/30 p-1 text-center sm:w-20 sm:p-2 md:w-24"
|
||||
@@ -308,23 +281,15 @@
|
||||
{/if}
|
||||
|
||||
<!-- Affiliations -->
|
||||
{#if columnVisibility.affiliations !== false}
|
||||
{#if columnVisibility.affiliation !== false}
|
||||
<div
|
||||
class="h-16 w-16 shrink-0 rounded-lg border border-white/10 sm:h-20 sm:w-20 md:h-24 md:w-24 {hasMatchingPrimaryAffiliation(character.affiliations, dailyCharacter.affiliations)
|
||||
class="h-16 w-16 shrink-0 rounded-lg border border-white/10 sm:h-20 sm:w-20 md:h-24 md:w-24 {getDislayAffiliation(character) === getDislayAffiliation(dailyCharacter)
|
||||
? 'bg-emerald-600/90'
|
||||
: 'bg-red-900/60'} flex items-center justify-center overflow-hidden p-1 sm:p-2"
|
||||
: 'bg-red-900/60'} flex items-center justify-center p-1 sm:p-2"
|
||||
>
|
||||
{#if firstAffiliation(character.affiliations)}
|
||||
<p
|
||||
class="w-full text-center text-[10px] leading-tight font-bold wrap-break-word whitespace-normal text-white sm:text-xs md:text-sm"
|
||||
>
|
||||
{firstAffiliation(character.affiliations)}
|
||||
</p>
|
||||
{:else}
|
||||
<p class="text-center text-xs font-bold text-slate-400 sm:text-sm md:text-base">
|
||||
-
|
||||
</p>
|
||||
{/if}
|
||||
<p class="text-center text-[10px] font-bold text-white sm:text-xs md:text-sm">
|
||||
{getDislayAffiliation(character) || $t.game.components.guessHistory.unknown}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -451,6 +416,41 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Age -->
|
||||
{#if columnVisibility.age !== false}
|
||||
<div
|
||||
class="h-16 w-16 shrink-0 rounded-lg border border-white/10 sm:h-20 sm:w-20 md:h-24 md:w-24 {character.age ===
|
||||
dailyCharacter.age
|
||||
? 'bg-emerald-600/90'
|
||||
: 'bg-red-900/60'} relative flex items-center justify-center overflow-hidden p-1 sm:p-2"
|
||||
>
|
||||
{#if character.age != null && dailyCharacter.age != null && character.age !== dailyCharacter.age}
|
||||
<div
|
||||
class="pointer-events-none absolute h-full w-full opacity-30"
|
||||
style="
|
||||
background-color: rgb(203, 213, 225);
|
||||
clip-path: {character.age > dailyCharacter.age
|
||||
? 'polygon(97% 60%,80% 60%,80% 5%,20% 5%,20% 60%,3% 60%,50% 95%)'
|
||||
: 'polygon(97% 40%,80% 40%,80% 95%,20% 95%,20% 40%,3% 40%,50% 5%)'};
|
||||
"
|
||||
></div>
|
||||
{/if}
|
||||
{#if character.age != null}
|
||||
<p
|
||||
class="relative z-10 text-center text-[10px] font-bold text-white sm:text-xs md:text-sm"
|
||||
>
|
||||
{character.age}
|
||||
</p>
|
||||
{:else}
|
||||
<p
|
||||
class="relative z-10 text-center text-[10px] font-bold text-white sm:text-xs md:text-sm"
|
||||
>
|
||||
{$t.game.components.guessHistory.unknown}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Origine -->
|
||||
{#if columnVisibility.origin !== false}
|
||||
<div
|
||||
|
||||
@@ -86,10 +86,7 @@
|
||||
>
|
||||
<p class="text-sm font-medium text-amber-100">{$t.game.components.hints.affiliation}</p>
|
||||
{#if showHintAffiliation}
|
||||
{@const affiliations = typeof dailyCharacter.affiliations === 'string'
|
||||
? ((dailyCharacter.affiliations as string).includes('[') ? JSON.parse(dailyCharacter.affiliations) : (dailyCharacter.affiliations as string).split(',').map((a: string) => a.trim()))
|
||||
: dailyCharacter.affiliations}
|
||||
<p class="mt-2 text-xs text-white font-semibold">{Array.isArray(affiliations) ? affiliations[0] : affiliations || $t.game.components.hints.unknown}</p>
|
||||
<p class="mt-2 text-xs text-white font-semibold">{isFrench && dailyCharacter.frAffiliation ? dailyCharacter.frAffiliation : dailyCharacter.affiliation || $t.game.components.hints.unknown}</p>
|
||||
{:else if Math.max(0, 15 - selectedCharacters.length) > 0}
|
||||
<p class="mt-2 text-xs text-slate-400">{Math.max(0, 15 - selectedCharacters.length)} {$t.game.components.hints.beforeUnlock}</p>
|
||||
{:else}
|
||||
|
||||
@@ -88,6 +88,8 @@
|
||||
"changePassword": "Change password",
|
||||
"dailyHistoryTitle": "Daily history",
|
||||
"noDailyHistory": "No history available",
|
||||
"triedCharactersTitle": "Tried characters",
|
||||
"noTriedCharacters": "No characters recorded",
|
||||
"noImage": "N/A",
|
||||
"trySingular": "try",
|
||||
"tryPlural": "tries",
|
||||
@@ -111,6 +113,8 @@
|
||||
"reset": "Play again",
|
||||
"description": "Guess the character. Each hint unlocks after a certain number of guesses. Good luck!",
|
||||
"friendsToday": "Your friends today",
|
||||
"friendsTriedCharacters": "Tried characters",
|
||||
"friendsNoTriedCharacters": "No characters recorded",
|
||||
"friendTrySingular": "try",
|
||||
"friendTryPlural": "tries"
|
||||
},
|
||||
@@ -140,6 +144,7 @@
|
||||
"withFruit": "With Fruit",
|
||||
"withoutFruit": "Without Fruit",
|
||||
"heightDefined": "Height defined",
|
||||
"ageDefined": "Age defined",
|
||||
"originDefined": "Origin defined",
|
||||
"availableCharactersSingular": "character available",
|
||||
"availableCharactersPlural": "characters available",
|
||||
@@ -171,6 +176,7 @@
|
||||
"haki": "Haki",
|
||||
"bounty": "Bounty",
|
||||
"height": "Height",
|
||||
"age": "Age",
|
||||
"origin": "Origin",
|
||||
"arc": "Arc",
|
||||
"alive": "Alive",
|
||||
|
||||
@@ -88,6 +88,8 @@
|
||||
"changePassword": "Changer le mot de passe",
|
||||
"dailyHistoryTitle": "Historique des Daily",
|
||||
"noDailyHistory": "Aucun historique disponible",
|
||||
"triedCharactersTitle": "Personnages essayes",
|
||||
"noTriedCharacters": "Aucun personnage enregistre",
|
||||
"noImage": "N/A",
|
||||
"trySingular": "tentative",
|
||||
"tryPlural": "tentatives",
|
||||
@@ -111,6 +113,8 @@
|
||||
"reset": "Recommencer",
|
||||
"description": "Devine le personnage. Chaque indice se debloque apres un certain nombre de tentatives. Bonne chance !",
|
||||
"friendsToday": "Tes amis aujourd'hui",
|
||||
"friendsTriedCharacters": "Personnages essayes",
|
||||
"friendsNoTriedCharacters": "Aucun personnage enregistre",
|
||||
"friendTrySingular": "coup",
|
||||
"friendTryPlural": "coups"
|
||||
},
|
||||
@@ -140,6 +144,7 @@
|
||||
"withFruit": "Avec Fruit",
|
||||
"withoutFruit": "Sans Fruit",
|
||||
"heightDefined": "Taille definie",
|
||||
"ageDefined": "Age defini",
|
||||
"originDefined": "Origine definie",
|
||||
"availableCharactersSingular": "personnage disponible",
|
||||
"availableCharactersPlural": "personnages disponibles",
|
||||
@@ -171,6 +176,7 @@
|
||||
"haki": "Haki",
|
||||
"bounty": "Prime",
|
||||
"height": "Taille",
|
||||
"age": "Age",
|
||||
"origin": "Origine",
|
||||
"arc": "Arc",
|
||||
"alive": "Vivant",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { db } from '$lib/server/db';
|
||||
import { arc, character, characterHistory, characterOverride, devilFruit, type Character, type CharacterOverride } from '$lib/server/db/schema';
|
||||
import { desc, eq, inArray, and } from 'drizzle-orm';
|
||||
import { arc, character, characterHistory, devilFruit, type Character } from '$lib/server/db/schema';
|
||||
import { desc, eq, and } from 'drizzle-orm';
|
||||
|
||||
// Generate or get random seed for daily character selection
|
||||
const RANDOM_SEED = Math.random();
|
||||
@@ -11,8 +11,8 @@ const characterWithRelationsSelect = {
|
||||
frName: character.frName,
|
||||
gender: character.gender,
|
||||
age: character.age,
|
||||
affiliations: character.affiliations,
|
||||
frAffiliations: character.frAffiliations,
|
||||
affiliation: character.affiliation,
|
||||
frAffiliation: character.frAffiliation,
|
||||
devilFruitId: character.devilFruitId,
|
||||
devilFruitName: devilFruit.name,
|
||||
devilFruitType: devilFruit.type,
|
||||
@@ -51,104 +51,6 @@ function isNotNullish<T>(value: T | null | undefined): value is T {
|
||||
return value !== null && value !== undefined;
|
||||
}
|
||||
|
||||
function mergeCharacterWithOverride(
|
||||
baseCharacter: CharacterWithRelations,
|
||||
overrideRow?: CharacterOverride,
|
||||
relationMaps?: RelationMaps
|
||||
): CharacterWithRelations {
|
||||
if (!overrideRow) {
|
||||
return baseCharacter;
|
||||
}
|
||||
|
||||
const mergedCharacter = { ...baseCharacter } as CharacterWithRelations;
|
||||
|
||||
for (const [key, value] of Object.entries(overrideRow)) {
|
||||
if (key === 'characterId' || key === 'notes') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isNotNullish(value)) {
|
||||
(mergedCharacter as Record<string, unknown>)[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
if (relationMaps) {
|
||||
if (mergedCharacter.arcId) {
|
||||
mergedCharacter.arcName = relationMaps.arcNameById.get(mergedCharacter.arcId) ?? null;
|
||||
mergedCharacter.frArcName = relationMaps.arcNameById.get(mergedCharacter.arcId) ?? null;
|
||||
} else {
|
||||
mergedCharacter.arcName = null;
|
||||
mergedCharacter.frArcName = null;
|
||||
}
|
||||
|
||||
if (mergedCharacter.devilFruitId) {
|
||||
const devilFruitData = relationMaps.devilFruitById.get(mergedCharacter.devilFruitId);
|
||||
mergedCharacter.devilFruitName = devilFruitData?.name ?? null;
|
||||
mergedCharacter.devilFruitType = devilFruitData?.type ?? null;
|
||||
} else {
|
||||
mergedCharacter.devilFruitName = null;
|
||||
mergedCharacter.devilFruitType = null;
|
||||
}
|
||||
}
|
||||
|
||||
return mergedCharacter;
|
||||
}
|
||||
|
||||
async function applyCharacterOverrides(
|
||||
characters: CharacterWithRelations[]
|
||||
): Promise<CharacterWithRelations[]> {
|
||||
if (characters.length === 0) {
|
||||
return characters;
|
||||
}
|
||||
|
||||
const characterIds = characters.map((currentCharacter) => currentCharacter.id);
|
||||
const overrideRows = await db
|
||||
.select()
|
||||
.from(characterOverride)
|
||||
.where(inArray(characterOverride.characterId, characterIds));
|
||||
|
||||
if (overrideRows.length === 0) {
|
||||
return characters;
|
||||
}
|
||||
|
||||
const overrideByCharacterId = new Map<string, CharacterOverride>(
|
||||
overrideRows.map((overrideRow) => [overrideRow.characterId, overrideRow])
|
||||
);
|
||||
|
||||
const shouldRefreshRelations = overrideRows.some(
|
||||
(overrideRow) => isNotNullish(overrideRow.arcId) || isNotNullish(overrideRow.devilFruitId)
|
||||
);
|
||||
|
||||
let relationMaps: RelationMaps | undefined;
|
||||
|
||||
if (shouldRefreshRelations) {
|
||||
const [allArcs, allDevilFruits] = await Promise.all([
|
||||
db.select({ id: arc.id, name: arc.name }).from(arc),
|
||||
db
|
||||
.select({ id: devilFruit.id, name: devilFruit.name, type: devilFruit.type })
|
||||
.from(devilFruit)
|
||||
]);
|
||||
|
||||
relationMaps = {
|
||||
arcNameById: new Map(allArcs.map((currentArc) => [currentArc.id, currentArc.name])),
|
||||
devilFruitById: new Map(
|
||||
allDevilFruits.map((currentDevilFruit) => [
|
||||
currentDevilFruit.id,
|
||||
{ name: currentDevilFruit.name, type: currentDevilFruit.type }
|
||||
])
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
return characters.map((currentCharacter) =>
|
||||
mergeCharacterWithOverride(
|
||||
currentCharacter,
|
||||
overrideByCharacterId.get(currentCharacter.id),
|
||||
relationMaps
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function getDateKey(date: Date): number {
|
||||
return normalizeDay(date).getTime();
|
||||
}
|
||||
@@ -168,26 +70,22 @@ function pickDailyCharacter(characters: CharacterWithRelations[], date: Date): C
|
||||
}
|
||||
|
||||
export async function getDailyModeCharacters(): Promise<CharacterWithRelations[]> {
|
||||
const characters = (await db
|
||||
return (await db
|
||||
.select(characterWithRelationsSelect)
|
||||
.from(character)
|
||||
.leftJoin(arc, eq(character.arcId, arc.id))
|
||||
.leftJoin(devilFruit, eq(character.devilFruitId, devilFruit.id))
|
||||
.where(eq(character.isInDailyMode, true))
|
||||
.all()) as CharacterWithRelations[];
|
||||
|
||||
return applyCharacterOverrides(characters);
|
||||
}
|
||||
|
||||
export async function getAllCharacters(): Promise<CharacterWithRelations[]> {
|
||||
const characters = (await db
|
||||
return (await db
|
||||
.select(characterWithRelationsSelect)
|
||||
.from(character)
|
||||
.leftJoin(arc, eq(character.arcId, arc.id))
|
||||
.leftJoin(devilFruit, eq(character.devilFruitId, devilFruit.id))
|
||||
.all()) as CharacterWithRelations[];
|
||||
|
||||
return applyCharacterOverrides(characters);
|
||||
}
|
||||
|
||||
export async function getCharacterById(characterId: string): Promise<CharacterWithRelations | null> {
|
||||
@@ -203,8 +101,7 @@ export async function getCharacterById(characterId: string): Promise<CharacterWi
|
||||
return null;
|
||||
}
|
||||
|
||||
const [overriddenCharacter] = await applyCharacterOverrides([found as CharacterWithRelations]);
|
||||
return overriddenCharacter ?? null;
|
||||
return found as CharacterWithRelations
|
||||
}
|
||||
|
||||
export async function getOrCreateTodayCharacter(
|
||||
|
||||
@@ -43,8 +43,8 @@ export const character = sqliteTable('character', {
|
||||
frName: text('fr_name'),
|
||||
gender: text('gender'),
|
||||
age: integer('age'),
|
||||
affiliations: text('affiliations', { mode: 'json' }).$type<string[]>(),
|
||||
frAffiliations: text('fr_affiliations', { mode: 'json' }).$type<string[]>(),
|
||||
affiliation: text('affiliation'),
|
||||
frAffiliation: text('fr_affiliation'),
|
||||
devilFruitId: text('devil_fruit_id').references(() => devilFruit.id),
|
||||
hakiObservation: integer('haki_observation', { mode: 'boolean' }).default(false),
|
||||
hakiArmament: integer('haki_armament', { mode: 'boolean' }).default(false),
|
||||
@@ -66,35 +66,6 @@ export const character = sqliteTable('character', {
|
||||
|
||||
export type Character = InferSelectModel<typeof character>;
|
||||
|
||||
// Define the character override table schema
|
||||
export const characterOverride = sqliteTable('character_override', {
|
||||
characterId: text('character_id').primaryKey().references(() => character.id, { onDelete: 'cascade' }),
|
||||
name: text('name'),
|
||||
gender: text('gender'),
|
||||
age: integer('age'),
|
||||
affiliations: text('affiliations', { mode: 'json' }).$type<string[]>(),
|
||||
frAffiliations: text('fr_affiliations', { mode: 'json' }).$type<string[]>(),
|
||||
devilFruitId: text('devil_fruit_id').references(() => devilFruit.id, { onDelete: 'set null' }),
|
||||
hakiObservation: integer('haki_observation', { mode: 'boolean' }),
|
||||
hakiArmament: integer('haki_armament', { mode: 'boolean' }),
|
||||
hakiConqueror: integer('haki_conqueror', { mode: 'boolean' }),
|
||||
bounty: integer('bounty'),
|
||||
height: real('height'),
|
||||
origin: text('origin'),
|
||||
frOrigin: text('fr_origin'),
|
||||
firstAppearance: integer('first_appearance'),
|
||||
pictureUrl: text('picture_url'),
|
||||
epithets: text('epithets', { mode: 'json' }).$type<string[]>(),
|
||||
frEpithets: text('fr_epithets', { mode: 'json' }).$type<string[]>(),
|
||||
status: text('status').$type<Status | null>(),
|
||||
arcId: text('arc_id').references(() => arc.id, { onDelete: 'set null' }),
|
||||
url: text('url'),
|
||||
frUrl: text('fr_url'),
|
||||
notes: text('notes')
|
||||
});
|
||||
|
||||
export type CharacterOverride = InferSelectModel<typeof characterOverride>;
|
||||
|
||||
// Define the character scrape validation table schema
|
||||
export const characterScrapeValidation = sqliteTable('character_scrape_validation', {
|
||||
id: text('id').primaryKey(),
|
||||
@@ -102,8 +73,8 @@ export const characterScrapeValidation = sqliteTable('character_scrape_validatio
|
||||
frName: text('fr_name'),
|
||||
gender: text('gender'),
|
||||
age: integer('age'),
|
||||
affiliations: text('affiliations', { mode: 'json' }).$type<string[]>(),
|
||||
frAffiliations: text('fr_affiliations', { mode: 'json' }).$type<string[]>(),
|
||||
affiliation: text('affiliation'),
|
||||
frAffiliation: text('fr_affiliation'),
|
||||
devilFruitId: text('devil_fruit_id').references(() => devilFruit.id, { onDelete: 'set null' }),
|
||||
hakiObservation: integer('haki_observation', { mode: 'boolean' }).default(false),
|
||||
hakiArmament: integer('haki_armament', { mode: 'boolean' }).default(false),
|
||||
@@ -119,7 +90,8 @@ export const characterScrapeValidation = sqliteTable('character_scrape_validatio
|
||||
status: text('status').$type<Status | null>(),
|
||||
arcId: text('arc_id').references(() => arc.id, { onDelete: 'set null' }),
|
||||
url: text('url'),
|
||||
frUrl: text('fr_url')
|
||||
frUrl: text('fr_url'),
|
||||
isDeleted: integer('is_deleted', { mode: 'boolean' }).default(false),
|
||||
});
|
||||
|
||||
export type CharacterScrapeValidation = InferSelectModel<typeof characterScrapeValidation>;
|
||||
|
||||
@@ -11,7 +11,7 @@ const EXEC_OPTIONS = {
|
||||
maxBuffer: 50 * 1024 * 1024
|
||||
};
|
||||
|
||||
async function upsertCharacterFromScrapeValidation(characterId: string): Promise<boolean> {
|
||||
async function applyCharacterChangeFromScrapeValidation(characterId: string): Promise<boolean> {
|
||||
const [scraped] = await db
|
||||
.select()
|
||||
.from(characterScrapeValidation)
|
||||
@@ -21,6 +21,11 @@ async function upsertCharacterFromScrapeValidation(characterId: string): Promise
|
||||
return false;
|
||||
}
|
||||
|
||||
if (scraped.isDeleted) {
|
||||
await db.delete(character).where(eq(character.id, characterId));
|
||||
return true;
|
||||
}
|
||||
|
||||
await db
|
||||
.insert(character)
|
||||
.values({
|
||||
@@ -29,7 +34,8 @@ async function upsertCharacterFromScrapeValidation(characterId: string): Promise
|
||||
frName: scraped.frName,
|
||||
gender: scraped.gender,
|
||||
age: scraped.age,
|
||||
affiliations: scraped.affiliations,
|
||||
affiliation: scraped.affiliation,
|
||||
frAffiliation: scraped.frAffiliation,
|
||||
devilFruitId: scraped.devilFruitId,
|
||||
hakiObservation: scraped.hakiObservation,
|
||||
hakiArmament: scraped.hakiArmament,
|
||||
@@ -54,7 +60,8 @@ async function upsertCharacterFromScrapeValidation(characterId: string): Promise
|
||||
frName: scraped.frName,
|
||||
gender: scraped.gender,
|
||||
age: scraped.age,
|
||||
affiliations: scraped.affiliations,
|
||||
affiliation: scraped.affiliation,
|
||||
frAffiliation: scraped.frAffiliation,
|
||||
devilFruitId: scraped.devilFruitId,
|
||||
hakiObservation: scraped.hakiObservation,
|
||||
hakiArmament: scraped.hakiArmament,
|
||||
@@ -87,7 +94,7 @@ export async function load() {
|
||||
|
||||
// Compare and categorize changes
|
||||
const changes: {
|
||||
type: 'new' | 'modified';
|
||||
type: 'new' | 'modified' | 'deleted';
|
||||
id: string;
|
||||
scraped: (typeof scrapedCharacters)[0];
|
||||
current?: (typeof currentCharacters)[0];
|
||||
@@ -97,6 +104,18 @@ export async function load() {
|
||||
for (const scraped of scrapedCharacters) {
|
||||
const current = currentCharMap.get(scraped.id);
|
||||
|
||||
if (scraped.isDeleted) {
|
||||
if (current) {
|
||||
changes.push({
|
||||
type: 'deleted',
|
||||
id: scraped.id,
|
||||
scraped,
|
||||
current
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!current) {
|
||||
// New character
|
||||
changes.push({
|
||||
@@ -112,7 +131,8 @@ export async function load() {
|
||||
'frName',
|
||||
'gender',
|
||||
'age',
|
||||
'affiliations',
|
||||
'affiliation',
|
||||
'frAffiliation',
|
||||
'devilFruitId',
|
||||
'hakiObservation',
|
||||
'hakiArmament',
|
||||
@@ -156,11 +176,16 @@ export async function load() {
|
||||
}
|
||||
}
|
||||
|
||||
const typeOrder: Record<'new' | 'modified' | 'deleted', number> = {
|
||||
new: 0,
|
||||
modified: 1,
|
||||
deleted: 2
|
||||
};
|
||||
|
||||
return {
|
||||
changes: changes.sort((a, b) => {
|
||||
// Show 'new' first, then 'modified'
|
||||
if (a.type !== b.type) {
|
||||
return a.type === 'new' ? -1 : 1;
|
||||
return typeOrder[a.type] - typeOrder[b.type];
|
||||
}
|
||||
return a.id.localeCompare(b.id);
|
||||
})
|
||||
@@ -221,10 +246,10 @@ export const actions = {
|
||||
return { success: false, message: 'characterId is required' };
|
||||
}
|
||||
|
||||
const applied = await upsertCharacterFromScrapeValidation(characterId);
|
||||
const applied = await applyCharacterChangeFromScrapeValidation(characterId);
|
||||
return {
|
||||
success: applied,
|
||||
message: applied ? 'Character applied successfully' : 'Character not found in scrape validation table'
|
||||
message: applied ? 'Character change applied successfully' : 'Character not found in scrape validation table'
|
||||
};
|
||||
},
|
||||
|
||||
@@ -233,7 +258,7 @@ export const actions = {
|
||||
let appliedCount = 0;
|
||||
|
||||
for (const scraped of scrapedCharacters) {
|
||||
const applied = await upsertCharacterFromScrapeValidation(scraped.id);
|
||||
const applied = await applyCharacterChangeFromScrapeValidation(scraped.id);
|
||||
if (applied) {
|
||||
appliedCount++;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,30 @@
|
||||
<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.filter((c: any) => c.type === 'new'));
|
||||
const modifiedCharacters = $derived(data.changes.filter((c: any) => c.type === 'modified'));
|
||||
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: any): string {
|
||||
function formatValue(value: unknown): string {
|
||||
if (value === null || value === undefined) {
|
||||
return '—';
|
||||
}
|
||||
@@ -25,7 +45,7 @@
|
||||
<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>
|
||||
<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"
|
||||
@@ -42,7 +62,7 @@
|
||||
{#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 > 0}
|
||||
{#if newCharacters.length + modifiedCharacters.length + deletedCharacters.length > 0}
|
||||
<form method="POST" action="?/acceptAll" class="mt-4">
|
||||
<button
|
||||
type="submit"
|
||||
@@ -68,7 +88,7 @@
|
||||
{#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}
|
||||
src={change.scraped.pictureUrl ?? undefined}
|
||||
alt={change.scraped.name}
|
||||
class="w-12 h-12 rounded object-cover hover:opacity-80 transition"
|
||||
/>
|
||||
@@ -127,8 +147,8 @@
|
||||
{#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}
|
||||
alt={change.current.name}
|
||||
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>
|
||||
@@ -174,7 +194,49 @@
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if newCharacters.length === 0 && modifiedCharacters.length === 0}
|
||||
<!-- 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>
|
||||
|
||||
@@ -1,22 +1,32 @@
|
||||
import { db } from '$lib/server/db';
|
||||
import { character, devilFruit, arc, characterOverride } from '$lib/server/db/schema';
|
||||
import { character, devilFruit, arc, type Status } from '$lib/server/db/schema';
|
||||
import { eq, sql } from 'drizzle-orm';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
import { writeFile } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import { existsSync, mkdirSync } from 'fs';
|
||||
import { env } from '$env/dynamic/private';
|
||||
|
||||
// Helper function to normalize data (parse JSON arrays)
|
||||
const normalizeArray = (value: any): any => {
|
||||
if (!value) return value;
|
||||
if (Array.isArray(value)) return value;
|
||||
if (typeof value === 'string' && value.includes('[')) {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
const [charactersData, devilFruits, arcs, overrides, statusesData, gendersData] = await Promise.all([
|
||||
let [characters, devilFruits, arcs, statusesData, gendersData] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
id: character.id,
|
||||
name: character.name,
|
||||
gender: character.gender,
|
||||
age: character.age,
|
||||
affiliations: character.affiliations,
|
||||
affiliation: character.affiliation,
|
||||
devilFruitId: character.devilFruitId,
|
||||
hakiObservation: character.hakiObservation,
|
||||
hakiArmament: character.hakiArmament,
|
||||
@@ -26,7 +36,7 @@ export const load: PageServerLoad = async () => {
|
||||
origin: character.origin,
|
||||
firstAppearance: character.firstAppearance,
|
||||
pictureUrl: character.pictureUrl,
|
||||
epithets: character.epithets,
|
||||
epithets: normalizeArray(character.epithets),
|
||||
status: character.status,
|
||||
url: character.url,
|
||||
arcId: character.arcId,
|
||||
@@ -41,7 +51,6 @@ export const load: PageServerLoad = async () => {
|
||||
.orderBy(character.name),
|
||||
db.select().from(devilFruit).orderBy(devilFruit.name),
|
||||
db.select().from(arc).orderBy(arc.name),
|
||||
db.select().from(characterOverride),
|
||||
db.selectDistinct({ status: character.status })
|
||||
.from(character)
|
||||
.where(sql`${character.status} IS NOT NULL AND ${character.status} != ''`),
|
||||
@@ -50,76 +59,13 @@ export const load: PageServerLoad = async () => {
|
||||
.where(sql`${character.gender} IS NOT NULL AND ${character.gender} != ''`)
|
||||
]);
|
||||
|
||||
// Create a map of overrides by characterId for easy lookup
|
||||
const overridesMap = new Map(overrides.map((o) => [o.characterId, o]));
|
||||
|
||||
// Create maps for arcs and devil fruits to lookup names by ID
|
||||
const arcMap = new Map(arcs.map((a) => [a.id, a.name]));
|
||||
const devilFruitMap = new Map(devilFruits.map((f) => [f.id, { name: f.name, type: f.type }]));
|
||||
|
||||
// Helper function to normalize data (parse JSON arrays)
|
||||
const normalizeArray = (value: any): any => {
|
||||
if (!value) return value;
|
||||
if (Array.isArray(value)) return value;
|
||||
if (typeof value === 'string' && value.includes('[')) {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
// Merge character data with overrides
|
||||
const charactersWithOverrides = charactersData.map((char) => {
|
||||
const override = overridesMap.get(char.id);
|
||||
|
||||
// Build displayValues by only applying non-null override fields
|
||||
const displayValues = { ...char } as any;
|
||||
if (override) {
|
||||
Object.keys(override).forEach((key) => {
|
||||
if (override[key as keyof typeof override] !== null && key !== 'characterId') {
|
||||
displayValues[key as keyof typeof displayValues] = override[key as keyof typeof override];
|
||||
}
|
||||
});
|
||||
|
||||
// Update arcName if arcId was overridden
|
||||
if (override.arcId !== null && override.arcId !== undefined) {
|
||||
displayValues.arcName = arcMap.get(override.arcId) || null;
|
||||
}
|
||||
|
||||
// Update devilFruitName and devilFruitType if devilFruitId was overridden
|
||||
if (override.devilFruitId !== null && override.devilFruitId !== undefined) {
|
||||
const fruit = devilFruitMap.get(override.devilFruitId);
|
||||
displayValues.devilFruitName = fruit?.name || null;
|
||||
displayValues.devilFruitType = fruit?.type || null;
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-normalize arrays (epithets, affiliations) for performance
|
||||
displayValues.epithets = normalizeArray(displayValues.epithets);
|
||||
displayValues.affiliations = normalizeArray(displayValues.affiliations);
|
||||
|
||||
// Create search text for epithets
|
||||
displayValues.epithetsSearchText = Array.isArray(displayValues.epithets)
|
||||
? displayValues.epithets.join(' ').toLowerCase()
|
||||
: (displayValues.epithets || '').toLowerCase();
|
||||
|
||||
return {
|
||||
...char,
|
||||
override,
|
||||
displayValues
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
characters: charactersWithOverrides,
|
||||
characters,
|
||||
devilFruits,
|
||||
arcs,
|
||||
availableStatuses: statusesData
|
||||
.map(s => s.status)
|
||||
.filter((s): s is string => !!s)
|
||||
.filter((s): s is Status => !!s)
|
||||
.sort((a, b) => a.localeCompare(b)),
|
||||
availableGenders: gendersData
|
||||
.map(g => g.gender)
|
||||
@@ -129,112 +75,6 @@ export const load: PageServerLoad = async () => {
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
update: async ({ request, locals }) => {
|
||||
if (!locals.user?.isAdmin) {
|
||||
return fail(401, { error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const id = formData.get('id') as string;
|
||||
|
||||
if (!id) {
|
||||
return fail(400, { error: 'Character ID is required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const [originalCharacter] = await db
|
||||
.select({
|
||||
hakiObservation: character.hakiObservation,
|
||||
hakiArmament: character.hakiArmament,
|
||||
hakiConqueror: character.hakiConqueror
|
||||
})
|
||||
.from(character)
|
||||
.where(eq(character.id, id))
|
||||
.limit(1);
|
||||
|
||||
if (!originalCharacter) {
|
||||
return fail(404, { error: 'Character not found' });
|
||||
}
|
||||
|
||||
const updates: Record<string, any> = {};
|
||||
|
||||
// Handle file upload
|
||||
const pictureFile = formData.get('pictureFile') as File;
|
||||
const hasUploadedPicture = !!pictureFile && pictureFile.size > 0;
|
||||
if (hasUploadedPicture) {
|
||||
try {
|
||||
const uploadsDir = env.UPLOADS_DIR || join(process.cwd(),'uploads');
|
||||
if (!existsSync(uploadsDir)) {
|
||||
mkdirSync(uploadsDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Get file extension
|
||||
const extension = pictureFile.name.split('.').pop();
|
||||
const filename = `${id}.${extension}`;
|
||||
const filepath = join(uploadsDir, filename);
|
||||
|
||||
// Convert file to buffer and save
|
||||
const buffer = Buffer.from(await pictureFile.arrayBuffer());
|
||||
await writeFile(filepath, buffer);
|
||||
|
||||
// Update pictureUrl to point to the handler route
|
||||
updates.pictureUrl = `/uploads/${filename}`;
|
||||
} catch (error) {
|
||||
console.error('File upload error:', error);
|
||||
return fail(500, { error: 'Failed to upload file' });
|
||||
}
|
||||
}
|
||||
|
||||
formData.forEach((value, key) => {
|
||||
if (key !== 'id' && key !== 'pictureFile') {
|
||||
if (hasUploadedPicture && key === 'pictureUrl') {
|
||||
return;
|
||||
}
|
||||
// Handle integers (age, bounty, height)
|
||||
if (key === 'age' || key === 'bounty' || key === 'height') {
|
||||
const strValue = value as string;
|
||||
updates[key] = strValue && strValue !== '' ? parseInt(strValue) : null;
|
||||
}
|
||||
// Handle text IDs (devilFruitId, arcId)
|
||||
else if (key === 'devilFruitId' || key === 'arcId') {
|
||||
const strValue = value as string;
|
||||
updates[key] = strValue && strValue !== '' ? strValue : null;
|
||||
}
|
||||
// Handle checkboxes (haki fields) after parsing all form data
|
||||
else if (key === 'hakiObservation' || key === 'hakiArmament' || key === 'hakiConqueror') {
|
||||
return;
|
||||
}
|
||||
// Handle strings (name, gender, status, origin, affiliations, epithets, pictureUrl, url, firstAppearance)
|
||||
else {
|
||||
updates[key] = value || null;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const submittedHakiObservation = formData.has('hakiObservation');
|
||||
const submittedHakiArmament = formData.has('hakiArmament');
|
||||
const submittedHakiConqueror = formData.has('hakiConqueror');
|
||||
|
||||
updates.hakiObservation =
|
||||
submittedHakiObservation === originalCharacter.hakiObservation ? null : submittedHakiObservation;
|
||||
updates.hakiArmament =
|
||||
submittedHakiArmament === originalCharacter.hakiArmament ? null : submittedHakiArmament;
|
||||
updates.hakiConqueror =
|
||||
submittedHakiConqueror === originalCharacter.hakiConqueror ? null : submittedHakiConqueror;
|
||||
|
||||
// Update or insert into characterOverride table
|
||||
await db
|
||||
.insert(characterOverride)
|
||||
.values({ characterId: id, ...updates })
|
||||
.onConflictDoUpdate({ target: characterOverride.characterId, set: updates });
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Character update error:', error);
|
||||
return fail(500, { error: 'Failed to update character' });
|
||||
}
|
||||
},
|
||||
|
||||
delete: async ({ request, locals }) => {
|
||||
if (!locals.user?.isAdmin) {
|
||||
return fail(401, { error: 'Unauthorized' });
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
bounty: 0,
|
||||
height: 0,
|
||||
origin: '',
|
||||
affiliations: '',
|
||||
affiliation: '',
|
||||
epithets: '',
|
||||
pictureUrl: '',
|
||||
url: '',
|
||||
@@ -63,23 +63,22 @@
|
||||
|
||||
const matchesSearch =
|
||||
normalizedQuery === '' ||
|
||||
char.displayValues.name.toLowerCase().includes(normalizedQuery) ||
|
||||
char.displayValues.epithetsSearchText.includes(normalizedQuery);
|
||||
char.name.toLowerCase().includes(normalizedQuery);
|
||||
const matchesDaily =
|
||||
filterDaily === 'all' ||
|
||||
(filterDaily === 'daily' && char.displayValues.isInDailyMode) ||
|
||||
(filterDaily === 'not-daily' && !char.displayValues.isInDailyMode);
|
||||
const matchesStatus = filterStatus === 'all' || (char.displayValues.status || '') === filterStatus;
|
||||
const matchesGender = filterGender === 'all' || (char.displayValues.gender || '') === filterGender;
|
||||
(filterDaily === 'daily' && char.isInDailyMode) ||
|
||||
(filterDaily === 'not-daily' && !char.isInDailyMode);
|
||||
const matchesStatus = filterStatus === 'all' || (char.status || '') === filterStatus;
|
||||
const matchesGender = filterGender === 'all' || (char.gender || '') === filterGender;
|
||||
const matchesArc =
|
||||
filterArc === 'all' ||
|
||||
String(char.displayValues.arcId ?? '') === filterArc;
|
||||
String(char.arcId ?? '') === filterArc;
|
||||
const matchesHaki =
|
||||
filterHaki === 'all' ||
|
||||
(filterHaki === 'observation' && !!char.displayValues.hakiObservation) ||
|
||||
(filterHaki === 'armament' && !!char.displayValues.hakiArmament) ||
|
||||
(filterHaki === 'conqueror' && !!char.displayValues.hakiConqueror) ||
|
||||
(filterHaki === 'none' && !char.displayValues.hakiObservation && !char.displayValues.hakiArmament && !char.displayValues.hakiConqueror);
|
||||
(filterHaki === 'observation' && !!char.hakiObservation) ||
|
||||
(filterHaki === 'armament' && !!char.hakiArmament) ||
|
||||
(filterHaki === 'conqueror' && !!char.hakiConqueror) ||
|
||||
(filterHaki === 'none' && !char.hakiObservation && !char.hakiArmament && !char.hakiConqueror);
|
||||
|
||||
return matchesSearch && matchesDaily && matchesStatus && matchesGender && matchesArc && matchesHaki;
|
||||
});
|
||||
@@ -102,7 +101,7 @@
|
||||
bounty: override.bounty ?? null,
|
||||
height: override.height ?? null,
|
||||
origin: override.origin ?? '',
|
||||
affiliations: override.affiliations ?? '',
|
||||
affiliation: override.affiliation ?? '',
|
||||
epithets: override.epithets ?? '',
|
||||
pictureUrl: override.pictureUrl ?? '',
|
||||
url: override.url ?? '',
|
||||
@@ -128,7 +127,7 @@
|
||||
bounty: 0,
|
||||
height: 0,
|
||||
origin: '',
|
||||
affiliations: '',
|
||||
affiliation: '',
|
||||
epithets: '',
|
||||
pictureUrl: '',
|
||||
url: '',
|
||||
@@ -277,116 +276,112 @@
|
||||
{#each filteredCharacters as char (char.id)}
|
||||
<tr class="border-b border-white/5 hover:bg-slate-800/50">
|
||||
<!-- Character -->
|
||||
<td class="px-4 py-4 text-sm text-white w-64 max-w-64 {isFieldOverridden(char, 'name') || isFieldOverridden(char, 'pictureUrl') ? 'bg-amber-500/10' : ''}">
|
||||
<td class="px-4 py-4 text-sm text-white w-64 max-w-64">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
{#if char.displayValues.url}
|
||||
{#if char.url}
|
||||
<a
|
||||
href={"https://onepiece.fandom.com/fr/wiki/" + char.displayValues.url}
|
||||
href={"https://onepiece.fandom.com/wiki/" + char.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="shrink-0 transition-opacity hover:opacity-80"
|
||||
>
|
||||
{#if char.displayValues.pictureUrl}
|
||||
{#if char.pictureUrl}
|
||||
<img
|
||||
src={char.displayValues.pictureUrl}
|
||||
alt={char.displayValues.name}
|
||||
src={char.pictureUrl}
|
||||
alt={char.name}
|
||||
loading="lazy"
|
||||
class="h-10 w-10 rounded-full object-cover"
|
||||
/>
|
||||
{:else}
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-slate-700 text-gray-400">
|
||||
{char.displayValues.name?.charAt(0).toUpperCase() || '?'}
|
||||
{char.name?.charAt(0).toUpperCase() || '?'}
|
||||
</div>
|
||||
{/if}
|
||||
</a>
|
||||
{:else}
|
||||
{#if char.displayValues.pictureUrl}
|
||||
{#if char.pictureUrl}
|
||||
<img
|
||||
src={char.displayValues.pictureUrl}
|
||||
alt={char.displayValues.name}
|
||||
src={char.pictureUrl}
|
||||
alt={char.name}
|
||||
loading="lazy"
|
||||
class="h-10 w-10 shrink-0 rounded-full object-cover"
|
||||
/>
|
||||
{:else}
|
||||
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-slate-700 text-gray-400">
|
||||
{char.displayValues.name?.charAt(0).toUpperCase() || '?'}
|
||||
{char.name?.charAt(0).toUpperCase() || '?'}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
<div class="flex flex-col min-w-0">
|
||||
{#if char.displayValues.url}
|
||||
{#if char.url}
|
||||
<a
|
||||
href="https://onepiece.fandom.com/fr/wiki/${char.displayValues.url}"
|
||||
href="https://onepiece.fandom.com/wiki/{char.url}"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="font-medium truncate text-white hover:text-amber-200 hover:underline"
|
||||
>
|
||||
{char.displayValues.name}
|
||||
{char.name}
|
||||
</a>
|
||||
{:else}
|
||||
<span class="font-medium truncate">{char.displayValues.name}</span>
|
||||
<span class="font-medium truncate">{char.name}</span>
|
||||
{/if}
|
||||
{#if char.displayValues.epithets}
|
||||
{#if char.epithets}
|
||||
<span class="text-xs text-gray-500 truncate">
|
||||
{Array.isArray(char.displayValues.epithets)
|
||||
? char.displayValues.epithets.join(', ')
|
||||
: char.displayValues.epithets}
|
||||
{Array.isArray(char.epithets)
|
||||
? char.epithets.join(', ')
|
||||
: char.epithets}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<!-- Status -->
|
||||
<td class="px-4 py-4 text-sm text-gray-400 {isFieldOverridden(char, 'status') ? 'bg-amber-500/10' : ''}">{char.displayValues.status || '-'}</td>
|
||||
<td class="px-4 py-4 text-sm text-gray-400">{char.status || '-'}</td>
|
||||
<!-- Gender -->
|
||||
<td class="px-4 py-4 text-sm text-gray-400 {isFieldOverridden(char, 'gender') ? 'bg-amber-500/10' : ''}">{char.displayValues.gender || '-'}</td>
|
||||
<td class="px-4 py-4 text-sm text-gray-400">{char.gender || '-'}</td>
|
||||
<!-- Affiliations -->
|
||||
<td class="px-4 py-4 text-sm text-gray-400 {isFieldOverridden(char, 'affiliations') ? 'bg-amber-500/10' : ''}">
|
||||
{#if char.displayValues.affiliations}
|
||||
{#if Array.isArray(char.displayValues.affiliations) && char.displayValues.affiliations.length > 0}
|
||||
<span class="inline-block" title={char.displayValues.affiliations.join(', ')}>{char.displayValues.affiliations[0]}</span>
|
||||
{:else}
|
||||
{char.displayValues.affiliations}
|
||||
{/if}
|
||||
<td class="px-4 py-4 text-sm text-gray-400">
|
||||
{#if char.affiliation}
|
||||
{char.affiliation}
|
||||
{:else}
|
||||
-
|
||||
{/if}
|
||||
</td>
|
||||
<!-- Fruit -->
|
||||
<td class="px-4 py-4 text-sm text-gray-400 {isFieldOverridden(char, 'devilFruitId') ? 'bg-amber-500/10' : ''}">{char.displayValues.devilFruitName || '-'}</td>
|
||||
<td class="px-4 py-4 text-sm text-gray-400">{char.devilFruitName || '-'}</td>
|
||||
<!-- Haki -->
|
||||
<td class="px-4 py-4 text-sm {isFieldOverridden(char, 'hakiObservation') || isFieldOverridden(char, 'hakiArmament') || isFieldOverridden(char, 'hakiConqueror') ? 'bg-amber-500/10' : ''}">
|
||||
<td class="px-4 py-4 text-sm">
|
||||
<div class="flex gap-1">
|
||||
{#if char.displayValues.hakiObservation}<span title="Haki de l'Observation">👁️</span>{/if}
|
||||
{#if char.displayValues.hakiArmament}<span title="Haki de l'Armement">🦾</span>{/if}
|
||||
{#if char.displayValues.hakiConqueror}<span title="Haki des Rois">👑</span>{/if}
|
||||
{#if !char.displayValues.hakiObservation && !char.displayValues.hakiArmament && !char.displayValues.hakiConqueror}
|
||||
{#if char.hakiObservation}<span title="Haki de l'Observation">👁️</span>{/if}
|
||||
{#if char.hakiArmament}<span title="Haki de l'Armement">🦾</span>{/if}
|
||||
{#if char.hakiConqueror}<span title="Haki des Rois">👑</span>{/if}
|
||||
{#if !char.hakiObservation && !char.hakiArmament && !char.hakiConqueror}
|
||||
<span class="text-gray-400">-</span>
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
<!-- Bounty -->
|
||||
<td class="px-4 py-4 text-sm text-gray-400 {isFieldOverridden(char, 'bounty') ? 'bg-amber-500/10' : ''}">
|
||||
{#if char.displayValues.bounty != null}
|
||||
{formatBounty(char.displayValues.bounty)} ฿
|
||||
<td class="px-4 py-4 text-sm text-gray-400">
|
||||
{#if char.bounty != null}
|
||||
{formatBounty(char.bounty)} ฿
|
||||
{:else}
|
||||
-
|
||||
{/if}
|
||||
</td>
|
||||
<!-- Height -->
|
||||
<td class="px-4 py-4 text-sm text-gray-400 {isFieldOverridden(char, 'height') ? 'bg-amber-500/10' : ''}">
|
||||
{#if char.displayValues.height}
|
||||
{char.displayValues.height} m
|
||||
<td class="px-4 py-4 text-sm text-gray-400">
|
||||
{#if char.height}
|
||||
{char.height} m
|
||||
{:else}
|
||||
-
|
||||
{/if}
|
||||
</td>
|
||||
<!-- Origin -->
|
||||
<td class="px-4 py-4 text-sm text-gray-400 {isFieldOverridden(char, 'origin') ? 'bg-amber-500/10' : ''}">{char.displayValues.origin || '-'}</td>
|
||||
<td class="px-4 py-4 text-sm text-gray-400">{char.origin || '-'}</td>
|
||||
<!-- Arc -->
|
||||
<td class="px-4 py-4 text-sm text-gray-400 {isFieldOverridden(char, 'arcId') || isFieldOverridden(char, 'arcName') ? 'bg-amber-500/10' : ''}">{char.displayValues.arcName || '-'}</td>
|
||||
<td class="px-4 py-4 text-sm text-gray-400">{char.arcName || '-'}</td>
|
||||
<!-- Daily Mode -->
|
||||
<td class="px-4 py-4 text-sm {isFieldOverridden(char, 'isInDailyMode') ? 'bg-amber-500/10' : ''}">
|
||||
<td class="px-4 py-4 text-sm">
|
||||
<form
|
||||
method="POST"
|
||||
action="?/toggleDailyMode"
|
||||
@@ -404,11 +399,11 @@
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="id" value={char.id} />
|
||||
<input type="hidden" name="isInDailyMode" value={(!char.displayValues.isInDailyMode).toString()} />
|
||||
<input type="hidden" name="isInDailyMode" value={(!char.isInDailyMode).toString()} />
|
||||
<label class="flex items-center justify-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={char.displayValues.isInDailyMode}
|
||||
checked={char.isInDailyMode}
|
||||
onchange={(e) => {
|
||||
const form = e.currentTarget.closest('form');
|
||||
if (form) form.requestSubmit();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { db } from '$lib/server/db';
|
||||
import { characterHistory, config, friendship, user, userCharacterHistory } from '$lib/server/db/schema';
|
||||
import { character, characterHistory, config, friendship, user, userCharacterHistory } from '$lib/server/db/schema';
|
||||
import { getDailyModeCharacters, getOrCreateTodayCharacter, getYesterdayCharacter, getTodayCharacterWinsCount, getDateKey } from '$lib/server/daily-character';
|
||||
import { and, eq, inArray, like, or } from 'drizzle-orm';
|
||||
|
||||
@@ -17,7 +17,13 @@ export async function load(event) {
|
||||
// Load the win count for today
|
||||
const winCount = await getTodayCharacterWinsCount(dailyCharacter.id);
|
||||
|
||||
let friendsTodayResults: Array<{ userId: string; name: string; image: string | null; tryCount: number }> = [];
|
||||
let friendsTodayResults: Array<{
|
||||
userId: string;
|
||||
name: string;
|
||||
image: string | null;
|
||||
tryCount: number;
|
||||
triedCharacters: Array<{ id: string; name: string; pictureUrl: string | null }>;
|
||||
}> = [];
|
||||
|
||||
if (event.locals.user) {
|
||||
const currentUserId = event.locals.user.id;
|
||||
@@ -51,12 +57,13 @@ export async function load(event) {
|
||||
const todayCharacterHistoryId = todayHistoryEntry?.id;
|
||||
|
||||
if (todayCharacterHistoryId) {
|
||||
friendsTodayResults = await db
|
||||
const friendResultsRaw = await db
|
||||
.select({
|
||||
userId: user.id,
|
||||
name: user.name,
|
||||
image: user.image,
|
||||
tryCount: userCharacterHistory.tryCount
|
||||
tryCount: userCharacterHistory.tryCount,
|
||||
triedCharacterIds: userCharacterHistory.triedCharacterIds
|
||||
})
|
||||
.from(userCharacterHistory)
|
||||
.innerJoin(user, eq(userCharacterHistory.userId, user.id))
|
||||
@@ -67,6 +74,33 @@ export async function load(event) {
|
||||
)
|
||||
)
|
||||
.orderBy(userCharacterHistory.tryCount);
|
||||
|
||||
const uniqueTriedCharacterIds = Array.from(new Set(
|
||||
friendResultsRaw.flatMap((entry) => entry.triedCharacterIds ?? [])
|
||||
));
|
||||
|
||||
const triedCharacters = uniqueTriedCharacterIds.length > 0
|
||||
? await db
|
||||
.select({
|
||||
id: character.id,
|
||||
name: character.name,
|
||||
pictureUrl: character.pictureUrl
|
||||
})
|
||||
.from(character)
|
||||
.where(inArray(character.id, uniqueTriedCharacterIds))
|
||||
: [];
|
||||
|
||||
const triedCharactersById = new Map(triedCharacters.map((entry) => [entry.id, entry]));
|
||||
|
||||
friendsTodayResults = friendResultsRaw.map((entry) => ({
|
||||
userId: entry.userId,
|
||||
name: entry.name,
|
||||
image: entry.image,
|
||||
tryCount: entry.tryCount,
|
||||
triedCharacters: (entry.triedCharacterIds ?? [])
|
||||
.map((characterId) => triedCharactersById.get(characterId))
|
||||
.filter((triedEntry): triedEntry is (typeof triedCharacters)[number] => !!triedEntry)
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import CharacterSearchInput from '$lib/components/CharacterSearchInput.svelte';
|
||||
import GuessHistoryTable from '$lib/components/GuessHistoryTable.svelte';
|
||||
import WinPanel from '$lib/components/WinPanel.svelte';
|
||||
import FriendsTodaySection from '$lib/components/FriendsTodaySection.svelte';
|
||||
import type { CharacterWithRelations } from '$lib/server/daily-character.js';
|
||||
import { t } from '$lib/i18n';
|
||||
|
||||
@@ -159,6 +160,8 @@
|
||||
|
||||
// Check if player won
|
||||
if (character.id === dailyCharacter.id) {
|
||||
const triedCharacterIds = selectedCharacters.map(selected => selected.id);
|
||||
|
||||
// Send request to record win in database
|
||||
fetch('/daily', {
|
||||
method: 'POST',
|
||||
@@ -167,7 +170,8 @@
|
||||
},
|
||||
body: JSON.stringify({
|
||||
characterId: dailyCharacter.id,
|
||||
tryCount: selectedCharacters.length
|
||||
tryCount: selectedCharacters.length,
|
||||
triedCharacterIds
|
||||
})
|
||||
}).catch(err => console.error('Failed to record win:', err));
|
||||
|
||||
@@ -313,32 +317,7 @@
|
||||
|
||||
|
||||
{#if hasWon && data.friendsTodayResults && data.friendsTodayResults.length > 0}
|
||||
<section class="mt-6 rounded-3xl border border-white/10 bg-white/5 p-6 shadow-[0_24px_60px_rgba(0,0,0,0.45)] backdrop-blur">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.28em] text-amber-100 text-center">{$t.game.daily.friendsToday}</p>
|
||||
<div class="mt-4 space-y-2">
|
||||
{#each data.friendsTodayResults as friendResult (friendResult.userId)}
|
||||
<div class="flex items-center justify-between rounded-lg border border-white/10 bg-slate-950/50 px-4 py-2">
|
||||
<div class="flex items-center gap-3">
|
||||
{#if friendResult.image}
|
||||
<img
|
||||
src={friendResult.image}
|
||||
alt={friendResult.name}
|
||||
class="h-8 w-8 rounded-full border border-white/20 object-cover"
|
||||
/>
|
||||
{:else}
|
||||
<div class="flex h-8 w-8 items-center justify-center rounded-full bg-amber-300/20 text-xs font-semibold text-amber-100">
|
||||
{friendResult.name?.charAt(0).toUpperCase() || 'U'}
|
||||
</div>
|
||||
{/if}
|
||||
<p class="text-sm font-semibold text-slate-100">{friendResult.name}</p>
|
||||
</div>
|
||||
<p class="text-sm text-amber-300">
|
||||
{friendResult.tryCount} {friendResult.tryCount > 1 ? $t.game.daily.friendTryPlural : $t.game.daily.friendTrySingular}
|
||||
</p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
<FriendsTodaySection friendsTodayResults={data.friendsTodayResults} />
|
||||
{/if}
|
||||
|
||||
<GuessHistoryTable
|
||||
|
||||
@@ -7,7 +7,10 @@ import { getDateKey } from '$lib/server/daily-character';
|
||||
|
||||
export async function POST({ request, locals }) {
|
||||
try {
|
||||
const { characterId, tryCount } = await request.json();
|
||||
const { characterId, tryCount, triedCharacterIds } = await request.json();
|
||||
const normalizedTriedCharacterIds = Array.isArray(triedCharacterIds)
|
||||
? triedCharacterIds.filter((id): id is string => typeof id === 'string')
|
||||
: [];
|
||||
|
||||
if (!characterId) {
|
||||
return json({ error: 'Missing characterId' }, { status: 400 });
|
||||
@@ -51,7 +54,8 @@ export async function POST({ request, locals }) {
|
||||
await db.insert(userCharacterHistory).values({
|
||||
userId: locals.user.id,
|
||||
characterHistoryId: todayHistoryEntry.id,
|
||||
tryCount: tryCount
|
||||
tryCount: tryCount,
|
||||
triedCharacterIds: normalizedTriedCharacterIds
|
||||
});
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
hasDevilFruit: null as boolean | null, // null = all, true = with fruit, false = without fruit
|
||||
status: [] as string[],
|
||||
hasHeight: false,
|
||||
hasAge: false,
|
||||
hasOrigin: false,
|
||||
arcs: [] as string[]
|
||||
};
|
||||
@@ -141,6 +142,9 @@
|
||||
if (!characterFilters.arcs) {
|
||||
characterFilters.arcs = [];
|
||||
}
|
||||
if (typeof characterFilters.hasAge !== 'boolean') {
|
||||
characterFilters.hasAge = false;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to parse filters', e);
|
||||
}
|
||||
@@ -277,6 +281,11 @@
|
||||
return false;
|
||||
}
|
||||
|
||||
// Age filter
|
||||
if (characterFilters.hasAge && (char.age === null || char.age === undefined)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Origin filter
|
||||
if (characterFilters.hasOrigin && (char.origin === null || char.origin === undefined || char.origin === '')) {
|
||||
return false;
|
||||
@@ -306,6 +315,7 @@
|
||||
haki: $t.game.components.guessHistory.haki,
|
||||
bounty: $t.game.components.guessHistory.bounty,
|
||||
height: $t.game.components.guessHistory.height,
|
||||
age: $t.game.components.guessHistory.age,
|
||||
origin: $t.game.components.guessHistory.origin,
|
||||
arc: $t.game.components.guessHistory.arc
|
||||
};
|
||||
@@ -424,6 +434,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
function toggleAgeFilter() {
|
||||
characterFilters.hasAge = !characterFilters.hasAge;
|
||||
if (!hasWon) {
|
||||
generateNewCharacter();
|
||||
}
|
||||
}
|
||||
|
||||
function toggleOriginFilter() {
|
||||
characterFilters.hasOrigin = !characterFilters.hasOrigin;
|
||||
// Regenerate character with new filters
|
||||
@@ -451,6 +468,7 @@
|
||||
hasDevilFruit: null,
|
||||
status: [],
|
||||
hasHeight: false,
|
||||
hasAge: false,
|
||||
hasOrigin: false,
|
||||
arcs: []
|
||||
};
|
||||
@@ -661,7 +679,7 @@
|
||||
<h3 class="text-xs font-semibold tracking-[0.2em] text-amber-200 uppercase">
|
||||
{$t.game.infinite.filtersTitle}
|
||||
</h3>
|
||||
{#if characterFilters.gender.length > 0 || characterFilters.hasHaki || characterFilters.hasDevilFruit !== null || characterFilters.status.length > 0 || characterFilters.hasHeight || characterFilters.hasOrigin || characterFilters.arcs.length > 0}
|
||||
{#if characterFilters.gender.length > 0 || characterFilters.hasHaki || characterFilters.hasDevilFruit !== null || characterFilters.status.length > 0 || characterFilters.hasHeight || characterFilters.hasAge || characterFilters.hasOrigin || characterFilters.arcs.length > 0}
|
||||
<button
|
||||
type="button"
|
||||
onclick={clearAllFilters}
|
||||
@@ -758,6 +776,15 @@
|
||||
>
|
||||
{$t.game.infinite.heightDefined}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={toggleAgeFilter}
|
||||
class="rounded-full border px-2.5 py-1 text-xs font-medium transition-colors {characterFilters.hasAge
|
||||
? 'border-amber-300/50 bg-amber-300/10 text-amber-100 hover:bg-amber-300/20'
|
||||
: 'border-white/20 bg-slate-900/40 text-slate-400 hover:bg-slate-900/60'}"
|
||||
>
|
||||
{$t.game.infinite.ageDefined}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={toggleOriginFilter}
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Actions, PageServerLoad } from './$types';
|
||||
import { auth } from '$lib/server/auth';
|
||||
import { db } from '$lib/server/db';
|
||||
import { session, userCharacterHistory, characterHistory, character, friendship, user } from '$lib/server/db/schema';
|
||||
import { and, desc, eq, or, sql } from 'drizzle-orm';
|
||||
import { and, desc, eq, inArray, or, sql } from 'drizzle-orm';
|
||||
import { APIError } from 'better-auth/api';
|
||||
|
||||
export const load: PageServerLoad = async (event) => {
|
||||
@@ -20,12 +20,13 @@ export const load: PageServerLoad = async (event) => {
|
||||
.where(eq(session.userId, event.locals.user.id));
|
||||
|
||||
// Fetch daily history for this user
|
||||
const dailyHistory = await db
|
||||
const dailyHistoryRaw = await db
|
||||
.select({
|
||||
id: userCharacterHistory.id,
|
||||
characterId: characterHistory.characterId,
|
||||
date: characterHistory.date,
|
||||
tryCount: userCharacterHistory.tryCount,
|
||||
triedCharacterIds: userCharacterHistory.triedCharacterIds,
|
||||
won: characterHistory.won,
|
||||
characterName: character.name,
|
||||
characterImage: character.pictureUrl
|
||||
@@ -36,6 +37,30 @@ export const load: PageServerLoad = async (event) => {
|
||||
.where(eq(userCharacterHistory.userId, event.locals.user.id))
|
||||
.orderBy(desc(characterHistory.date));
|
||||
|
||||
const uniqueTriedCharacterIds = Array.from(new Set(
|
||||
dailyHistoryRaw.flatMap((entry) => entry.triedCharacterIds ?? [])
|
||||
));
|
||||
|
||||
const triedCharacters = uniqueTriedCharacterIds.length > 0
|
||||
? await db
|
||||
.select({
|
||||
id: character.id,
|
||||
name: character.name,
|
||||
pictureUrl: character.pictureUrl
|
||||
})
|
||||
.from(character)
|
||||
.where(inArray(character.id, uniqueTriedCharacterIds))
|
||||
: [];
|
||||
|
||||
const triedCharactersById = new Map(triedCharacters.map((entry) => [entry.id, entry]));
|
||||
|
||||
const dailyHistory = dailyHistoryRaw.map((entry) => ({
|
||||
...entry,
|
||||
triedCharacters: (entry.triedCharacterIds ?? [])
|
||||
.map((characterId) => triedCharactersById.get(characterId))
|
||||
.filter((triedEntry): triedEntry is (typeof triedCharacters)[number] => !!triedEntry)
|
||||
}));
|
||||
|
||||
const incomingRequests = await db
|
||||
.select({
|
||||
id: friendship.id,
|
||||
|
||||
@@ -9,6 +9,21 @@
|
||||
form?: { success?: boolean; message?: string } | null;
|
||||
}
|
||||
|
||||
interface DailyHistoryEntry {
|
||||
id: string;
|
||||
characterId: string | null;
|
||||
date: number;
|
||||
tryCount: number;
|
||||
won: number;
|
||||
characterName: string;
|
||||
characterImage: string | null;
|
||||
triedCharacters?: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
pictureUrl: string | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
let { data, form }: Props = $props();
|
||||
|
||||
let isLoading = $state(false);
|
||||
@@ -20,7 +35,7 @@
|
||||
let newPassword = $state('');
|
||||
let confirmPassword = $state('');
|
||||
let sessions = $derived(data.sessions || []);
|
||||
let dailyHistory = $derived(data.dailyHistory || []);
|
||||
let dailyHistory = $derived((data.dailyHistory || []) as DailyHistoryEntry[]);
|
||||
let friends = $derived(data.friends || []);
|
||||
let incomingRequests = $derived(data.incomingRequests || []);
|
||||
let outgoingRequests = $derived(data.outgoingRequests || []);
|
||||
@@ -455,6 +470,29 @@
|
||||
day: 'numeric'
|
||||
})}
|
||||
</p>
|
||||
<div class="mt-3">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-[0.2em] text-amber-100">
|
||||
{$t.game.profile.triedCharactersTitle}
|
||||
</p>
|
||||
{#if day.triedCharacters && day.triedCharacters.length > 0}
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
{#each day.triedCharacters as triedCharacter (triedCharacter.id)}
|
||||
<span class="inline-flex items-center gap-2 rounded-full border border-white/15 bg-white/5 px-2.5 py-1 text-xs text-slate-200">
|
||||
{#if triedCharacter.pictureUrl}
|
||||
<img
|
||||
src={triedCharacter.pictureUrl}
|
||||
alt={triedCharacter.name}
|
||||
class="h-4 w-4 rounded-full object-cover"
|
||||
/>
|
||||
{/if}
|
||||
{triedCharacter.name}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="mt-1 text-xs text-slate-500">{$t.game.profile.noTriedCharacters}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tries -->
|
||||
|
||||
Reference in New Issue
Block a user