Compare commits

...

17 Commits

Author SHA1 Message Date
ef6bf9862e feat: add FriendsTodaySection component for displaying friends' results
All checks were successful
Build Docker Image / build (push) Successful in 2m4s
2026-04-14 22:08:49 +02:00
d75c74ac3c Refactor character affiliations to singular form
- Updated character data structure to replace 'affiliations' and 'frAffiliations' with 'affiliation' and 'frAffiliation'.
- Modified related functions and components to accommodate the new structure.
- Adjusted database schema and server-side logic to reflect the changes in character affiliation handling.
- Ensured all references in the UI components and data import/export scripts are updated accordingly.
2026-04-14 21:56:26 +02:00
fa14156d82 feat: remove overrides
Some checks failed
Build Docker Image / build (push) Has been cancelled
2026-04-12 02:01:01 +02:00
29297d3773 fix: include 'Four Emperors' in character exclusion logic
All checks were successful
Build Docker Image / build (push) Successful in 1m12s
2026-03-18 22:42:32 +01:00
28bb8f526b fix: include Kuja in female character categorization
All checks were successful
Build Docker Image / build (push) Successful in 1m13s
2026-03-18 22:40:39 +01:00
288271fb04 fix: include Queens Regnant in female character categorization
All checks were successful
Build Docker Image / build (push) Successful in 1m12s
2026-03-18 22:22:54 +01:00
fb64c84a17 fix: include Gorgon Sisters in female character categorization
All checks were successful
Build Docker Image / build (push) Successful in 1m11s
2026-03-18 22:19:46 +01:00
81e205dd4e fix: expand gender categorization in character fetch logic
All checks were successful
Build Docker Image / build (push) Successful in 1m23s
2026-03-18 22:07:28 +01:00
ded1c8313d fix: update character link URLs to remove language prefix
All checks were successful
Build Docker Image / build (push) Successful in 1m11s
2026-03-16 23:15:02 +01:00
4426b5d28a fix: correct href interpolation for character links in admin page
Some checks failed
Build Docker Image / build (push) Has been cancelled
2026-03-16 23:14:21 +01:00
5ad0428420 feat: enhance character scrape validation and management
All checks were successful
Build Docker Image / build (push) Successful in 1m14s
- Added a new entry for "fuzzy_talisman" in the journal.
- Updated import-json script to handle character deletion and mark absent characters as deleted in the scrape validation.
- Modified schema to include an `isDeleted` field in the characterScrapeValidation table.
- Renamed function `upsertCharacterFromScrapeValidation` to `applyCharacterChangeFromScrapeValidation` for clarity.
- Enhanced character change loading to include deleted characters and updated UI to display them.
- Improved character change handling in the Svelte component to reflect new, modified, and deleted states.
2026-03-16 23:12:06 +01:00
7760570365 feat: exclude characters with 'family' in their name from fetch results
All checks were successful
Build Docker Image / build (push) Successful in 1m14s
2026-03-16 22:31:40 +01:00
5fde54a2a7 feat: add age filter functionality and localization support in guess history
All checks were successful
Build Docker Image / build (push) Successful in 1m12s
2026-03-16 22:05:09 +01:00
2a3c82f777 feat: add age attribute to character history and localization support
All checks were successful
Build Docker Image / build (push) Successful in 1m13s
2026-03-16 22:00:49 +01:00
835163f5bb feat: add tried characters tracking and display in daily game profile
All checks were successful
Build Docker Image / build (push) Successful in 1m10s
2026-03-16 21:39:44 +01:00
5020393b22 fix: normalize character status text to lowercase for consistency
All checks were successful
Build Docker Image / build (push) Successful in 1m22s
2026-03-16 21:17:01 +01:00
94393851c8 feat: support French epithets in character extraction logic
All checks were successful
Build Docker Image / build (push) Successful in 1m14s
2026-03-15 22:44:45 +01:00
27 changed files with 4373 additions and 536 deletions

View File

@@ -0,0 +1 @@
ALTER TABLE `character_scrape_validation` ADD `is_deleted` integer DEFAULT false;

View File

@@ -0,0 +1 @@
DROP TABLE `character_override`;

View 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`;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -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
}
]
}

View File

@@ -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!`);

View File

@@ -23,7 +23,8 @@ const columns = [
'origin',
'devilFruitType',
'arc',
'status'
'status',
'age'
] as const;
async function initColumnConfig(): Promise<void> {

View File

@@ -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,

View 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}

View File

@@ -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 class="text-center text-[10px] font-bold text-white sm:text-xs md:text-sm">
{getDislayAffiliation(character) || $t.game.components.guessHistory.unknown}
</p>
{:else}
<p class="text-center text-xs font-bold text-slate-400 sm:text-sm md:text-base">
-
</p>
{/if}
</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

View File

@@ -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}

View File

@@ -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",

View File

@@ -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",

View File

@@ -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(

View File

@@ -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>;

View File

@@ -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++;
}

View File

@@ -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>

View File

@@ -1,61 +1,8 @@
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';
export const load: PageServerLoad = async () => {
const [charactersData, devilFruits, arcs, overrides, statusesData, gendersData] = await Promise.all([
db
.select({
id: character.id,
name: character.name,
gender: character.gender,
age: character.age,
affiliations: character.affiliations,
devilFruitId: character.devilFruitId,
hakiObservation: character.hakiObservation,
hakiArmament: character.hakiArmament,
hakiConqueror: character.hakiConqueror,
bounty: character.bounty,
height: character.height,
origin: character.origin,
firstAppearance: character.firstAppearance,
pictureUrl: character.pictureUrl,
epithets: character.epithets,
status: character.status,
url: character.url,
arcId: character.arcId,
isInDailyMode: character.isInDailyMode,
arcName: arc.name,
devilFruitName: devilFruit.name,
devilFruitType: devilFruit.type
})
.from(character)
.leftJoin(arc, eq(character.arcId, arc.id))
.leftJoin(devilFruit, eq(character.devilFruitId, devilFruit.id))
.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} != ''`),
db.selectDistinct({ gender: character.gender })
.from(character)
.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 => {
@@ -71,55 +18,54 @@ export const load: PageServerLoad = async () => {
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();
export const load: PageServerLoad = async () => {
let [characters, devilFruits, arcs, statusesData, gendersData] = await Promise.all([
db
.select({
id: character.id,
name: character.name,
gender: character.gender,
age: character.age,
affiliation: character.affiliation,
devilFruitId: character.devilFruitId,
hakiObservation: character.hakiObservation,
hakiArmament: character.hakiArmament,
hakiConqueror: character.hakiConqueror,
bounty: character.bounty,
height: character.height,
origin: character.origin,
firstAppearance: character.firstAppearance,
pictureUrl: character.pictureUrl,
epithets: normalizeArray(character.epithets),
status: character.status,
url: character.url,
arcId: character.arcId,
isInDailyMode: character.isInDailyMode,
arcName: arc.name,
devilFruitName: devilFruit.name,
devilFruitType: devilFruit.type
})
.from(character)
.leftJoin(arc, eq(character.arcId, arc.id))
.leftJoin(devilFruit, eq(character.devilFruitId, devilFruit.id))
.orderBy(character.name),
db.select().from(devilFruit).orderBy(devilFruit.name),
db.select().from(arc).orderBy(arc.name),
db.selectDistinct({ status: character.status })
.from(character)
.where(sql`${character.status} IS NOT NULL AND ${character.status} != ''`),
db.selectDistinct({ gender: character.gender })
.from(character)
.where(sql`${character.gender} IS NOT NULL AND ${character.gender} != ''`)
]);
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' });

View File

@@ -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();

View File

@@ -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)
}));
}
}
}

View File

@@ -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

View File

@@ -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 {

View File

@@ -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}

View File

@@ -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,

View File

@@ -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 -->