refactor: improve bounty extraction logic and enhance character selection in infinite mode

This commit is contained in:
2026-03-14 17:09:33 +01:00
parent 3bd2506c2f
commit 57a0427e77
5 changed files with 233 additions and 126 deletions

View File

@@ -629,29 +629,22 @@ function extractBounty($: cheerio.CheerioAPI): number | null {
const div = $('[data-source="bounty"] .pi-data-value'); const div = $('[data-source="bounty"] .pi-data-value');
if (div.length === 0) return 0; if (div.length === 0) return 0;
let text = div.html(); const cleanedDiv = div.clone();
// Drop references and old crossed-out bounty values.
cleanedDiv.find('sup, s, del, strike').remove();
const text = cleanedDiv.text().replace(/\s+/g, ' ').trim();
if (!text) return 0; if (!text) return 0;
// Remove all sup blocks (citations) // Parse the first amount token (e.g. "3,189,000,000"), which is the active bounty.
text = text.replace(/<sup[^>]*>.*?<\/sup>/gi, ''); const amountMatch = text.match(/\d{1,3}(?:[\s,.']\d{3})+|\d+/);
if (!amountMatch) return 0;
// Extract the first value before any <br> tag const digits = amountMatch[0].replace(/\D/g, '');
const firstValue = text.split('<br')[0].trim(); if (!digits) return 0;
let cleanText = firstValue.replace(/<[^>]*>/g, '').trim();
// Check if cleanText contains digits const value = Number(digits);
if (!/\d/.test(cleanText)) { return Number.isFinite(value) ? value : 0;
// If no digits, try second value after <br>
const secondValue = text.split('<br>')[1];
if (secondValue) {
cleanText = secondValue.replace(/<[^>]*>/g, '').trim();
}
}
// Remove all non-digits
cleanText = cleanText.replace(/\D/g, '');
return cleanText ? parseInt(cleanText) : 0;
} }
/** /**

View File

@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import type { User } from 'better-auth/types'; import type { User } from 'better-auth/types';
import { resolve } from '$app/paths';
interface Props { interface Props {
user: (User & { isAdmin?: boolean }) | null; user: (User & { isAdmin?: boolean }) | null;
@@ -59,7 +60,7 @@
{user.name?.charAt(0).toUpperCase() || 'U'} {user.name?.charAt(0).toUpperCase() || 'U'}
</div> </div>
{/if} {/if}
<span class="max-w-[150px] truncate text-sm font-semibold text-slate-100"> <span class="max-w-37.5 truncate text-sm font-semibold text-slate-100">
{user.name || 'Utilisateur'} {user.name || 'Utilisateur'}
</span> </span>
<svg <svg
@@ -77,15 +78,15 @@
class="absolute right-0 top-full mt-2 w-48 rounded-xl border border-white/10 bg-slate-900/95 shadow-[0_24px_60px_rgba(0,0,0,0.45)] backdrop-blur" class="absolute right-0 top-full mt-2 w-48 rounded-xl border border-white/10 bg-slate-900/95 shadow-[0_24px_60px_rgba(0,0,0,0.45)] backdrop-blur"
> >
<a <a
href="/profile" href={resolve("/profile")}
onclick={closeMenu} onclick={closeMenu}
class="block border-b border-white/5 px-4 py-3 text-sm font-semibold text-slate-100 transition hover:bg-white/5 hover:text-amber-100 first:rounded-t-xl" class="block border-b border-white/5 px-4 py-3 text-sm font-semibold text-slate-100 transition hover:bg-white/5 hover:text-amber-100 first:rounded-t-xl"
> >
Voir mon profil Voir mon profil
</a> </a>
{#if (user as any).isAdmin} {#if (user).isAdmin}
<a <a
href="/admin" href={resolve("/admin")}
onclick={closeMenu} onclick={closeMenu}
class="block border-b border-white/5 px-4 py-3 text-sm font-semibold text-amber-300 transition hover:bg-white/5 hover:text-amber-200" class="block border-b border-white/5 px-4 py-3 text-sm font-semibold text-amber-300 transition hover:bg-white/5 hover:text-amber-200"
> >
@@ -102,7 +103,7 @@
{/if} {/if}
{:else} {:else}
<a <a
href="/login" href={resolve("/login")}
class="rounded-full bg-amber-300 px-5 py-2.5 text-sm font-semibold text-slate-900 transition hover:bg-amber-200" class="rounded-full bg-amber-300 px-5 py-2.5 text-sm font-semibold text-slate-900 transition hover:bg-amber-200"
> >
Se connecter Se connecter

View File

@@ -2,13 +2,16 @@ import { db } from '$lib/server/db';
import { character, devilFruit, arc, user } from '$lib/server/db/schema'; import { character, devilFruit, arc, user } from '$lib/server/db/schema';
import { getOrCreateTodayCharacter, getTodayCharacterWinsCount } from '$lib/server/daily-character'; import { getOrCreateTodayCharacter, getTodayCharacterWinsCount } from '$lib/server/daily-character';
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
import { count, eq } from 'drizzle-orm';
export const load: PageServerLoad = async () => { export const load: PageServerLoad = async () => {
const [characters, devilFruits, arcs, users] = await Promise.all([ const [totalCharacters, totalDevilFruits, totalArcs, totalUsers, adminUsers, charactersInDaily] = await Promise.all([
db.select().from(character), db.select({ count: count() }).from(character),
db.select().from(devilFruit), db.select({ count: count() }).from(devilFruit),
db.select().from(arc), db.select({ count: count() }).from(arc),
db.select().from(user) db.select({ count: count() }).from(user),
db.select({ count: count() }).from(user).where(eq(user.isAdmin, true)),
db.select({ count: count() }).from(character).where(eq(character.isInDailyMode, true))
]); ]);
// Get today's daily character and count wins // Get today's daily character and count wins
@@ -21,12 +24,12 @@ export const load: PageServerLoad = async () => {
return { return {
stats: { stats: {
totalCharacters: characters.length, totalCharacters: totalCharacters[0].count,
charactersInDaily: characters.filter((c) => c.isInDailyMode).length, charactersInDaily: charactersInDaily[0].count,
totalDevilFruits: devilFruits.length, totalDevilFruits: totalDevilFruits[0].count,
totalArcs: arcs.length, totalArcs: totalArcs[0].count,
totalUsers: users.length, totalUsers: totalUsers[0].count,
adminUsers: users.filter((u) => u.isAdmin).length, adminUsers: adminUsers[0].count,
dailyCharacterWins dailyCharacterWins
} }
}; };

View File

@@ -4,7 +4,7 @@ import { getAllCharacters } from '$lib/server/daily-character';
import { like } from 'drizzle-orm'; import { like } from 'drizzle-orm';
export async function load() { export async function load() {
let characters = await getAllCharacters(); const characters = await getAllCharacters();
// Load column visibility config // Load column visibility config
const columnConfig = await db const columnConfig = await db

View File

@@ -1,16 +1,22 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onDestroy, onMount } from 'svelte';
import CharacterSearchInput from '$lib/components/CharacterSearchInput.svelte'; import CharacterSearchInput from '$lib/components/CharacterSearchInput.svelte';
import GuessHistoryTable from '$lib/components/GuessHistoryTable.svelte'; import GuessHistoryTable from '$lib/components/GuessHistoryTable.svelte';
import WinPanel from '$lib/components/WinPanel.svelte'; import WinPanel from '$lib/components/WinPanel.svelte';
import HintsPanel from '$lib/components/HintsPanel.svelte'; import HintsPanel from '$lib/components/HintsPanel.svelte';
import type { CharacterWithRelations } from '$lib/server/daily-character.js';
export let data; export let data;
let selectedCharacters: any[] = []; let selectedCharacters: CharacterWithRelations[] = [];
let currentCharacter: any = null; let currentCharacter: CharacterWithRelations | null = null;
let isLoaded = false; let isLoaded = false;
let score = 0; let score = 0;
type ArcFilterOption = { id: string; name: string };
let allCharacters: CharacterWithRelations[] = [];
let characters: CharacterWithRelations[] = [];
let availableArcs: ArcFilterOption[] = [];
let hasWon = false;
let columnVisibility: Record<string, boolean> = {}; let columnVisibility: Record<string, boolean> = {};
const columnDisplayNames: Record<string, string> = { const columnDisplayNames: Record<string, string> = {
status: 'Statut', status: 'Statut',
@@ -35,13 +41,85 @@
arcs: [] as string[] arcs: [] as string[]
}; };
let wasOriginAvailable = false;
let wasFruitAvailable = false;
let wasAffiliationAvailable = false;
let showOriginUnlock = false; let showOriginUnlock = false;
let showFruitUnlock = false; let showFruitUnlock = false;
let showAffiliationUnlock = false; let showAffiliationUnlock = false;
let isGeckoMoriaWin = false; let isGeckoMoriaWin = false;
let originUnlockTimeout: ReturnType<typeof setTimeout> | null = null;
let fruitUnlockTimeout: ReturnType<typeof setTimeout> | null = null;
let affiliationUnlockTimeout: ReturnType<typeof setTimeout> | null = null;
function clearUnlockTimeout(timeout: ReturnType<typeof setTimeout> | null) {
if (timeout) {
clearTimeout(timeout);
}
}
function pulseUnlock(type: 'origin' | 'fruit' | 'affiliation') {
if (type === 'origin') {
clearUnlockTimeout(originUnlockTimeout);
showOriginUnlock = true;
originUnlockTimeout = setTimeout(() => {
showOriginUnlock = false;
originUnlockTimeout = null;
}, 600);
return;
}
if (type === 'fruit') {
clearUnlockTimeout(fruitUnlockTimeout);
showFruitUnlock = true;
fruitUnlockTimeout = setTimeout(() => {
showFruitUnlock = false;
fruitUnlockTimeout = null;
}, 600);
return;
}
clearUnlockTimeout(affiliationUnlockTimeout);
showAffiliationUnlock = true;
affiliationUnlockTimeout = setTimeout(() => {
showAffiliationUnlock = false;
affiliationUnlockTimeout = null;
}, 600);
}
function syncHintAvailability(previousGuessCount: number, nextGuessCount: number, animateUnlocks = false) {
const nextOriginAvailable = nextGuessCount >= 5;
const nextFruitAvailable = nextGuessCount >= 10;
const nextAffiliationAvailable = nextGuessCount >= 15;
if (animateUnlocks && nextOriginAvailable && previousGuessCount < 5) {
pulseUnlock('origin');
}
if (animateUnlocks && nextFruitAvailable && previousGuessCount < 10) {
pulseUnlock('fruit');
}
if (animateUnlocks && nextAffiliationAvailable && previousGuessCount < 15) {
pulseUnlock('affiliation');
}
if (!nextOriginAvailable) {
showOriginUnlock = false;
clearUnlockTimeout(originUnlockTimeout);
originUnlockTimeout = null;
}
if (!nextFruitAvailable) {
showFruitUnlock = false;
clearUnlockTimeout(fruitUnlockTimeout);
fruitUnlockTimeout = null;
}
if (!nextAffiliationAvailable) {
showAffiliationUnlock = false;
clearUnlockTimeout(affiliationUnlockTimeout);
affiliationUnlockTimeout = null;
}
}
// Load from localStorage on mount // Load from localStorage on mount
onMount(() => { onMount(() => {
@@ -56,6 +134,7 @@
try { try {
columnVisibility = JSON.parse(storedColumnVisibility); columnVisibility = JSON.parse(storedColumnVisibility);
} catch (e) { } catch (e) {
console.error('Failed to parse column visibility', e);
columnVisibility = data.columnVisibility || {}; columnVisibility = data.columnVisibility || {};
} }
} else { } else {
@@ -86,18 +165,19 @@
const historyIds = JSON.parse(storedHistoryIds); const historyIds = JSON.parse(storedHistoryIds);
// Find the character object by ID // Find the character object by ID
currentCharacter = characters.find((c: any) => c.id === charId); currentCharacter = characters.find((c: CharacterWithRelations) => c.id === charId) || null;
// Find all character objects by their IDs // Find all character objects by their IDs
selectedCharacters = historyIds selectedCharacters = historyIds
.map((id: string) => characters.find((c: any) => c.id === id)) .map((id: string) => characters.find((c: CharacterWithRelations) => c.id === id))
.filter((c: any) => c !== undefined); .filter((c: CharacterWithRelations | undefined) => !!c) as CharacterWithRelations[];
// If character not found, generate a new one // If character not found, generate a new one
if (!currentCharacter) { if (!currentCharacter) {
generateNewCharacter(); generateNewCharacter();
} }
} catch (e) { } catch (e) {
console.error('Failed to parse character data', e);
// If parsing fails, generate a new character // If parsing fails, generate a new character
generateNewCharacter(); generateNewCharacter();
} }
@@ -105,9 +185,16 @@
generateNewCharacter(); generateNewCharacter();
} }
syncHintAvailability(0, selectedCharacters.length);
isLoaded = true; isLoaded = true;
}); });
onDestroy(() => {
clearUnlockTimeout(originUnlockTimeout);
clearUnlockTimeout(fruitUnlockTimeout);
clearUnlockTimeout(affiliationUnlockTimeout);
});
// Save score to localStorage whenever it changes // Save score to localStorage whenever it changes
$: if (isLoaded) { $: if (isLoaded) {
localStorage.setItem('infiniteScore', score.toString()); localStorage.setItem('infiniteScore', score.toString());
@@ -130,26 +217,30 @@
// Save selected character IDs to localStorage whenever it changes // Save selected character IDs to localStorage whenever it changes
$: if (isLoaded) { $: if (isLoaded) {
const selectedIds = selectedCharacters.map((c: any) => c.id); const selectedIds = selectedCharacters.map((c: CharacterWithRelations) => c.id);
localStorage.setItem('infiniteSelectedCharacterIds', JSON.stringify(selectedIds)); localStorage.setItem('infiniteSelectedCharacterIds', JSON.stringify(selectedIds));
} }
$: allCharacters = data.characters || []; $: allCharacters = data.characters || [];
// Extract unique arcs from all characters // Extract unique arcs from all characters
$: availableArcs = [ $: {
...new Map( const arcMap = new Map<string, ArcFilterOption>(
allCharacters allCharacters
.filter((char: any) => char.arcId && char.arcName) .filter(
.map((char: any) => [char.arcId, { id: char.arcId, name: char.arcName }]) (char: CharacterWithRelations): char is CharacterWithRelations & { arcId: string; arcName: string } =>
).values() typeof char.arcId === 'string' && char.arcId.length > 0 && typeof char.arcName === 'string' && char.arcName.length > 0
] )
.sort((a: any, b: any) => (a.name || '').localeCompare(b.name || '')); .map((char: CharacterWithRelations & { arcId: string; arcName: string }) => [char.arcId, { id: char.arcId, name: char.arcName }])
);
availableArcs = [...arcMap.values()].sort((a, b) => a.name.localeCompare(b.name));
}
// Filter characters based on selected filters // Filter characters based on selected filters
$: characters = allCharacters.filter((char: any) => { $: characters = allCharacters.filter((char: CharacterWithRelations) => {
// Gender filter // Gender filter
if (characterFilters.gender.length > 0 && !characterFilters.gender.includes(char.gender)) { if (characterFilters.gender.length > 0 && (char.gender == null || !characterFilters.gender.includes(char.gender))) {
return false; return false;
} }
@@ -185,48 +276,26 @@
} }
// Arc filter // Arc filter
if (characterFilters.arcs.length > 0 && !characterFilters.arcs.includes(char.arcId)) { if (characterFilters.arcs.length > 0 && (char.arcId == null || !characterFilters.arcs.includes(char.arcId))) {
return false; return false;
} }
return true; return true;
}); });
$: hasWon = currentCharacter && selectedCharacters.some(char => char.id === currentCharacter.id); $: {
const currentCharacterId = currentCharacter?.id;
hasWon = currentCharacterId != null && selectedCharacters.some(char => char.id === currentCharacterId);
}
$: if (hasWon && currentCharacter?.id === 'gecko_moria_gecko_moria') { $: if (hasWon && currentCharacter?.id === 'gecko_moria_gecko_moria') {
isGeckoMoriaWin = true; isGeckoMoriaWin = true;
} else if (!hasWon) { } else if (!hasWon) {
isGeckoMoriaWin = false; isGeckoMoriaWin = false;
} }
// Hint availability tracking for unlock animations
$: isOriginAvailable = selectedCharacters.length >= 5;
$: isFruitAvailable = selectedCharacters.length >= 10;
$: isAffiliationAvailable = selectedCharacters.length >= 15;
// Track hint unlocks
$: if (isLoaded) {
if (isOriginAvailable && !wasOriginAvailable) {
showOriginUnlock = true;
setTimeout(() => (showOriginUnlock = false), 600);
}
wasOriginAvailable = isOriginAvailable;
if (isFruitAvailable && !wasFruitAvailable) {
showFruitUnlock = true;
setTimeout(() => (showFruitUnlock = false), 600);
}
wasFruitAvailable = isFruitAvailable;
if (isAffiliationAvailable && !wasAffiliationAvailable) {
showAffiliationUnlock = true;
setTimeout(() => (showAffiliationUnlock = false), 600);
}
wasAffiliationAvailable = isAffiliationAvailable;
}
function generateNewCharacter() { function generateNewCharacter() {
if (characters.length === 0) return; if (characters.length === 0) return;
currentCharacter = characters[Math.floor(Math.random() * characters.length)]; currentCharacter = characters[Math.floor(Math.random() * characters.length)];
syncHintAvailability(selectedCharacters.length, 0);
selectedCharacters = []; selectedCharacters = [];
} }
@@ -235,11 +304,18 @@
selectCharacter(character); selectCharacter(character);
} }
function selectCharacter(character: any) { function selectCharacter(character: CharacterWithRelations) {
const current = currentCharacter;
if (!current) {
return;
}
const previousGuessCount = selectedCharacters.length;
selectedCharacters = [character, ...selectedCharacters]; selectedCharacters = [character, ...selectedCharacters];
syncHintAvailability(previousGuessCount, selectedCharacters.length, isLoaded);
// Check if player won // Check if player won
if (character.id === currentCharacter.id) { if (character.id === current.id) {
// Increment score (saved to localStorage via reactive statement) // Increment score (saved to localStorage via reactive statement)
score++; score++;
// Don't auto-generate next character - wait for user to click "Recommencer" // Don't auto-generate next character - wait for user to click "Recommencer"
@@ -265,10 +341,16 @@
} }
function revealAnswer() { function revealAnswer() {
if (!currentCharacter) {
return;
}
// Reset score (strike) // Reset score (strike)
score = 0; score = 0;
// Add the current character as the correct answer // Add the current character as the correct answer
const previousGuessCount = selectedCharacters.length;
selectedCharacters = [currentCharacter, ...selectedCharacters]; selectedCharacters = [currentCharacter, ...selectedCharacters];
syncHintAvailability(previousGuessCount, selectedCharacters.length, isLoaded);
} }
function toggleGenderFilter(gender: string) { function toggleGenderFilter(gender: string) {
@@ -396,16 +478,22 @@
<style> <style>
@keyframes shadow-pulse { @keyframes shadow-pulse {
0% { 0% {
text-shadow: 0 0 20px rgba(0, 0, 0, 0.5), 0 0 40px rgba(50, 50, 50, 0.8); text-shadow:
0 0 20px rgba(0, 0, 0, 0.5),
0 0 40px rgba(50, 50, 50, 0.8);
opacity: 1; opacity: 1;
} }
50% { 50% {
text-shadow: 0 0 60px rgba(0, 0, 0, 0.9), 0 0 100px rgba(30, 30, 30, 1), text-shadow:
0 0 60px rgba(0, 0, 0, 0.9),
0 0 100px rgba(30, 30, 30, 1),
inset 0 0 50px rgba(0, 0, 0, 0.7); inset 0 0 50px rgba(0, 0, 0, 0.7);
opacity: 0.9; opacity: 0.9;
} }
100% { 100% {
text-shadow: 0 0 20px rgba(0, 0, 0, 0.5), 0 0 40px rgba(50, 50, 50, 0.8); text-shadow:
0 0 20px rgba(0, 0, 0, 0.5),
0 0 40px rgba(50, 50, 50, 0.8);
opacity: 1; opacity: 1;
} }
} }
@@ -461,18 +549,22 @@
</svelte:head> </svelte:head>
<main <main
class="relative min-h-screen overflow-hidden bg-slate-950 text-slate-100 {isGeckoMoriaWin ? 'moria-screen-chaos' : ''}" class="relative min-h-screen overflow-hidden bg-slate-950 text-slate-100 {isGeckoMoriaWin
? 'moria-screen-chaos'
: ''}"
> >
<div class="absolute inset-0 bg-gradient-to-br from-slate-950/85 via-slate-900/60 to-slate-950/80"></div>
<div <div
class="absolute inset-0 mix-blend-screen opacity-20 bg-[radial-gradient(circle_at_top,rgba(255,215,84,0.35),transparent_55%)]" class="absolute inset-0 bg-linear-to-br from-slate-950/85 via-slate-900/60 to-slate-950/80"
></div>
<div
class="absolute inset-0 bg-[radial-gradient(circle_at_top,rgba(255,215,84,0.35),transparent_55%)] opacity-20 mix-blend-screen"
></div> ></div>
<div class="relative mx-auto flex min-h-screen w-full max-w-6xl flex-col px-6 py-8 sm:py-10"> <div class="relative mx-auto flex min-h-screen w-full max-w-6xl flex-col px-6 py-8 sm:py-10">
<header class="flex flex-col items-start gap-6 w-full"> <header class="flex w-full flex-col items-start gap-6">
<div class="flex w-full items-center justify-between gap-4"> <div class="flex w-full items-center justify-between gap-4">
<div> <div>
<h1 class="text-3xl font-black uppercase tracking-[0.25em] text-amber-50 sm:text-5xl"> <h1 class="text-3xl font-black tracking-[0.25em] text-amber-50 uppercase sm:text-5xl">
Mode Infini Mode Infini
</h1> </h1>
<p class="mt-2 text-2xl font-bold text-amber-300">Score: {score}</p> <p class="mt-2 text-2xl font-bold text-amber-300">Score: {score}</p>
@@ -496,11 +588,7 @@
{#if currentCharacter} {#if currentCharacter}
{#if hasWon} {#if hasWon}
<div> <div>
<WinPanel <WinPanel selectedCharacter={currentCharacter} {selectedCharacters} {isGeckoMoriaWin} />
selectedCharacter={currentCharacter}
{selectedCharacters}
{isGeckoMoriaWin}
/>
<button <button
type="button" type="button"
onclick={nextCharacter} onclick={nextCharacter}
@@ -518,7 +606,7 @@
{showFruitUnlock} {showFruitUnlock}
{showAffiliationUnlock} {showAffiliationUnlock}
/> />
<div class="flex justify-center mt-2"> <div class="mt-2 flex justify-center">
<button <button
type="button" type="button"
onclick={revealAnswer} onclick={revealAnswer}
@@ -535,7 +623,9 @@
/> />
{/if} {/if}
{:else} {:else}
<div class="rounded-3xl border border-white/10 bg-white/5 p-6 shadow-[0_24px_60px_rgba(0,0,0,0.45)] backdrop-blur"> <div
class="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-center text-slate-300">Chargement du personnage...</p> <p class="text-center text-slate-300">Chargement du personnage...</p>
</div> </div>
{/if} {/if}
@@ -550,30 +640,34 @@
<!-- Character Filters --> <!-- Character Filters -->
<section class="mt-6"> <section class="mt-6">
<div class="rounded-2xl border border-white/10 bg-white/5 p-3 sm:p-4 backdrop-blur"> <div class="rounded-2xl border border-white/10 bg-white/5 p-3 backdrop-blur sm:p-4">
<div class="mb-3 flex items-center justify-between gap-3"> <div class="mb-3 flex items-center justify-between gap-3">
<h3 class="text-xs font-semibold uppercase tracking-[0.2em] text-amber-200">Filtres de personnages</h3> <h3 class="text-xs font-semibold tracking-[0.2em] text-amber-200 uppercase">
Filtres de personnages
</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.hasOrigin || characterFilters.arcs.length > 0}
<button <button
type="button" type="button"
onclick={clearAllFilters} onclick={clearAllFilters}
class="text-xs text-red-300 hover:text-red-200 transition" class="text-xs text-red-300 transition hover:text-red-200"
> >
Réinitialiser Réinitialiser
</button> </button>
{/if} {/if}
</div> </div>
<div class="space-y-3"> <div class="space-y-3">
<!-- Gender Filter --> <!-- Gender Filter -->
<div> <div>
<p class="text-xs text-slate-400 mb-2">Genre</p> <p class="mb-2 text-xs text-slate-400">Genre</p>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
{#each ['Male', 'Female'] as gender} {#each ['Male', 'Female'] as gender (gender)}
<button <button
type="button" type="button"
onclick={() => toggleGenderFilter(gender)} onclick={() => toggleGenderFilter(gender)}
class="rounded-full border px-2.5 py-1 text-xs font-medium transition-colors {characterFilters.gender.includes(gender) class="rounded-full border px-2.5 py-1 text-xs font-medium transition-colors {characterFilters.gender.includes(
gender
)
? 'border-amber-300/50 bg-amber-300/10 text-amber-100 hover:bg-amber-300/20' ? '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'}" : 'border-white/20 bg-slate-900/40 text-slate-400 hover:bg-slate-900/60'}"
> >
@@ -585,13 +679,15 @@
<!-- Status Filter --> <!-- Status Filter -->
<div> <div>
<p class="text-xs text-slate-400 mb-2">Statut</p> <p class="mb-2 text-xs text-slate-400">Statut</p>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
{#each ['Alive', 'Dead', 'Unknown'] as status} {#each ['Alive', 'Dead', 'Unknown'] as status (status)}
<button <button
type="button" type="button"
onclick={() => toggleStatusFilter(status)} onclick={() => toggleStatusFilter(status)}
class="rounded-full border px-2.5 py-1 text-xs font-medium transition-colors {characterFilters.status.includes(status) class="rounded-full border px-2.5 py-1 text-xs font-medium transition-colors {characterFilters.status.includes(
status
)
? 'border-amber-300/50 bg-amber-300/10 text-amber-100 hover:bg-amber-300/20' ? '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'}" : 'border-white/20 bg-slate-900/40 text-slate-400 hover:bg-slate-900/60'}"
> >
@@ -603,7 +699,7 @@
<!-- Haki Filter --> <!-- Haki Filter -->
<div> <div>
<p class="text-xs text-slate-400 mb-2">Capacités</p> <p class="mb-2 text-xs text-slate-400">Capacités</p>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<button <button
type="button" type="button"
@@ -617,20 +713,25 @@
<button <button
type="button" type="button"
onclick={toggleDevilFruitFilter} onclick={toggleDevilFruitFilter}
class="rounded-full border px-2.5 py-1 text-xs font-medium transition-colors {characterFilters.hasDevilFruit === true class="rounded-full border px-2.5 py-1 text-xs font-medium transition-colors {characterFilters.hasDevilFruit ===
true
? 'border-amber-300/50 bg-amber-300/10 text-amber-100 hover:bg-amber-300/20' ? 'border-amber-300/50 bg-amber-300/10 text-amber-100 hover:bg-amber-300/20'
: characterFilters.hasDevilFruit === false : characterFilters.hasDevilFruit === false
? 'border-purple-300/50 bg-purple-300/10 text-purple-100 hover:bg-purple-300/20' ? 'border-purple-300/50 bg-purple-300/10 text-purple-100 hover:bg-purple-300/20'
: 'border-white/20 bg-slate-900/40 text-slate-400 hover:bg-slate-900/60'}" : 'border-white/20 bg-slate-900/40 text-slate-400 hover:bg-slate-900/60'}"
> >
{characterFilters.hasDevilFruit === null ? 'Fruit (Tous)' : characterFilters.hasDevilFruit ? 'Avec Fruit' : 'Sans Fruit'} {characterFilters.hasDevilFruit === null
? 'Fruit (Tous)'
: characterFilters.hasDevilFruit
? 'Avec Fruit'
: 'Sans Fruit'}
</button> </button>
</div> </div>
</div> </div>
<!-- Informations Filter --> <!-- Informations Filter -->
<div> <div>
<p class="text-xs text-slate-400 mb-2">Informations</p> <p class="mb-2 text-xs text-slate-400">Informations</p>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<button <button
type="button" type="button"
@@ -652,16 +753,18 @@
</button> </button>
</div> </div>
</div> </div>
<!-- Arc Filter --> <!-- Arc Filter -->
<div> <div>
<p class="text-xs text-slate-400 mb-2">Arcs</p> <p class="mb-2 text-xs text-slate-400">Arcs</p>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
{#each availableArcs as arc (arc.id)} {#each availableArcs as arc (arc.id)}
<button <button
type="button" type="button"
onclick={() => toggleArcFilter(arc.id)} onclick={() => toggleArcFilter(arc.id)}
class="rounded-full border px-2.5 py-1 text-xs font-medium transition-colors {characterFilters.arcs.includes(arc.id) class="rounded-full border px-2.5 py-1 text-xs font-medium transition-colors {characterFilters.arcs.includes(
arc.id
)
? 'border-amber-300/50 bg-amber-300/10 text-amber-100 hover:bg-amber-300/20' ? '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'}" : 'border-white/20 bg-slate-900/40 text-slate-400 hover:bg-slate-900/60'}"
> >
@@ -670,9 +773,12 @@
{/each} {/each}
</div> </div>
</div> </div>
<p class="text-xs text-slate-500 mt-2"> <p class="mt-2 text-xs text-slate-500">
{characters.length} personnage{characters.length > 1 ? 's' : ''} disponible{characters.length > 1 ? 's' : ''} {characters.length} personnage{characters.length > 1 ? 's' : ''} disponible{characters.length >
1
? 's'
: ''}
</p> </p>
</div> </div>
</div> </div>
@@ -680,11 +786,15 @@
<!-- Column Visibility Toggle --> <!-- Column Visibility Toggle -->
<section class="mt-6"> <section class="mt-6">
<div class="rounded-2xl border border-white/10 bg-white/5 p-3 sm:p-4 backdrop-blur"> <div class="rounded-2xl border border-white/10 bg-white/5 p-3 backdrop-blur sm:p-4">
<div class="mb-3 flex items-center justify-between gap-3"> <div class="mb-3 flex items-center justify-between gap-3">
<h3 class="text-xs font-semibold uppercase tracking-[0.2em] text-amber-200">Colonnes</h3> <h3 class="text-xs font-semibold tracking-[0.2em] text-amber-200 uppercase">
Colonnes
</h3>
<p class="text-xs text-slate-400"> <p class="text-xs text-slate-400">
{Object.values(columnVisibility).filter(Boolean).length}/{Object.keys(columnVisibility).length} {Object.values(columnVisibility).filter(Boolean).length}/{Object.keys(
columnVisibility
).length}
</p> </p>
</div> </div>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">