feat: implement daily character guessing game with local storage and hint system

- Added character selection and history management using local storage.
- Implemented hint system that unlocks based on the number of guesses.
- Enhanced UI with animations for hint unlocks and special win conditions.
- Created a server endpoint to record wins in the database.
This commit is contained in:
2026-03-01 03:59:16 +01:00
parent 6f7bae2307
commit b8b3f8bddc
23 changed files with 2988 additions and 620 deletions

13
.dockerignore Normal file
View File

@@ -0,0 +1,13 @@
node_modules
npm-debug.log
.git
.gitignore
.vscode
.svelte-kit
build
dist
coverage
.env
.env.*
local.db
drizzle/meta

1
.gitignore vendored
View File

@@ -11,6 +11,7 @@ node_modules
# OS
.DS_Store
Thumbs.db
/.vscode
# Env
.env

26
Dockerfile Normal file
View File

@@ -0,0 +1,26 @@
FROM node:24-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:24-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app ./
COPY docker-entrypoint.sh /app/docker-entrypoint.sh
RUN chmod +x /app/docker-entrypoint.sh
# Create non-root user
RUN addgroup -g 1000 node && adduser -D -u 1000 -G node node
RUN chown -R node:node /app
USER node
EXPOSE 4173
ENTRYPOINT ["/app/docker-entrypoint.sh"]

View File

@@ -1,130 +0,0 @@
# One Piece Scraper
Script pour scraper les données des personnages de One Piece depuis le fandom wiki français.
## Installation
Installe les dépendances d'abord :
```bash
npm install
```
## Utilisation
```bash
# Scraper tous les formats (JSON, CSV, SQL)
npm run scrape
# Ou spécifier un format
node scripts/scrape-onepiece.js json # JSON uniquement
node scripts/scrape-onepiece.js csv # CSV uniquement
node scripts/scrape-onepiece.js sql # SQL uniquement
node scripts/scrape-onepiece.js all # Tous les formats (défaut)
```
## Sortie
Les données seront sauvegardées dans le dossier `scraped-data/` :
- **characters.json** - Format JSON avec toutes les données structurées
- **characters.csv** - Format CSV pour importer dans Excel/Sheets
- **characters.sql** - Statements SQL avec gestion des conflits (upsert)
## Données extraites
Pour chaque personnage :
- 📝 **Nom** - Nom du personnage
- 👤 **Genre** - Masculin/Féminin (extrait des catégories)
- 🎂 **Âge** - Âge le plus récent (post-ellipse), chiffres uniquement
- 📏 **Taille** - Normalisée en mètres (format: "2.74" ou "174" pour cm)
- 🌍 **Origine** - Lieu d'origine (sans parenthèses)
- 😈 **Fruit du Démon** - Nom du fruit (si applicable)
- 👥 **Affiliations** - Liste des affiliations (équipages, organisations)
- 💰 **Prime** - Bounty la plus récente
-**Haki** - Liste des types de Haki (Observation, Armament, Conqueror)
- 📖 **Première Apparition** - Numéro de chapitre
- 🖼️ **Image** - URL de l'image portrait nettoyée
- 🔗 **Fandom URL** - Lien vers la page wiki
## Personnages scrapés
Le script scrape tous les personnages canon de la liste officielle du Fandom wiki français.
**Personnages actuellement filtrés** : Luffy et Moria (modifiable dans `fetchAllCharactersUrl`)
Pour scraper tous les personnages, retire le filtre dans la fonction `fetchAllCharactersUrl`.
## Fonctionnalités avancées
### Requêtes parallèles
- Le scraper traite 5 personnages en parallèle pour plus d'efficacité
- Concurrency configurable dans le code
### Nettoyage des données
- Suppression automatique des citations/références (`<sup>` tags)
- Âge : extraction du dernier âge (après ellipse), sans parenthèses
- Taille : normalisation m/cm et suppression des parenthèses
- Origine : suppression du contenu entre parenthèses
- Image : sélection automatique du portrait
- Première apparition : extraction du numéro de chapitre uniquement
### SQL Upsert
- Le SQL généré utilise `INSERT ... ON CONFLICT(name) DO UPDATE`
- Met à jour les personnages existants au lieu de créer des doublons
- Compatible SQLite (utilisé par le projet)
### Formats de données
- **Haki** : Stocké comme array JSON dans SQL : `["Observation","Armament"]`
- **Affiliations** : Liste dans JSON, comma-separated dans CSV/SQL
- Tous les champs nullable supportent `NULL` dans SQL
## Configuration
### Modifier les personnages filtrés
Dans `scripts/scrape-onepiece.js`, fonction `fetchAllCharactersUrl` :
```javascript
// Filtrer pour des personnages spécifiques
if (nameLower.includes('luffy') || nameLower.includes('moria')) {
characters.push({ name: charName, url: charLink });
}
```
### Ajuster la concurrence
Dans la fonction `main` :
```javascript
const concurrency = 5; // Nombre de requêtes simultanées
```
## Importer les données SQL
Après avoir généré le fichier SQL, importe-le dans la base de données :
```bash
npm run db:import
```
Ce script :
- Lit automatiquement `scraped-data/characters.sql`
- Exécute chaque statement individuellement
- Affiche une barre de progression
- Gère les erreurs sans bloquer l'import complet
- Utilise le upsert pour éviter les doublons
**Note** : Assure-toi d'avoir exécuté les migrations avant l'import :
```bash
npm run db:migrate
```
## Notes techniques
- Source : `https://onepiece.fandom.com/fr/wiki`
- Parseur : Cheerio (DOM parsing)
- Traitement parallèle avec `Promise.all`
- User-Agent configuré pour éviter les blocages
- Pas de délai entre requêtes (batches parallèles)
- Gestion d'erreurs par personnage (ne bloque pas le scraping complet)

9
docker-entrypoint.sh Normal file
View File

@@ -0,0 +1,9 @@
#!/bin/bash
set -e
# Migrate the database
npm run db:migrate
# Start the production server
exec npm run preview --host 0.0.0.0

View File

@@ -1,32 +1,103 @@
CREATE TABLE `arc` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`startChapter` integer NOT NULL,
`endChapter` integer,
`url` text
);
--> statement-breakpoint
CREATE TABLE `character` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`gender` text,
`age` integer,
`affiliations` text,
`devilFruit` text,
`haki` text,
`bounty` integer,
`devilFruitId` text,
`hakiObservation` integer DEFAULT false,
`hakiArmament` integer DEFAULT false,
`hakiConqueror` integer DEFAULT false,
`bounty` integer DEFAULT 0,
`height` real,
`origin` text,
`firstAppearance` text,
`firstAppearance` integer NOT NULL,
`pictureUrl` text,
FOREIGN KEY (`devilFruit`) REFERENCES `devilFruit`(`id`) ON UPDATE no action ON DELETE no action
`epithets` text,
`status` text,
`arcId` text,
`url` text,
`isInDailyMode` integer DEFAULT true,
FOREIGN KEY (`devilFruitId`) REFERENCES `devilFruit`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`arcId`) REFERENCES `arc`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `characterHistory` (
`id` text PRIMARY KEY NOT NULL,
`characterId` text,
`date` integer,
`date` text,
`won` integer DEFAULT 0 NOT NULL,
`createdAt` integer NOT NULL,
`updatedAt` integer NOT NULL,
FOREIGN KEY (`characterId`) REFERENCES `character`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `characterOverride` (
`characterId` text PRIMARY KEY NOT NULL,
`name` text,
`gender` text,
`age` integer,
`affiliations` text,
`devilFruitId` text,
`hakiObservation` integer,
`hakiArmament` integer,
`hakiConqueror` integer,
`bounty` integer,
`height` real,
`origin` text,
`firstAppearance` integer NOT NULL,
`pictureUrl` text,
`epithets` text,
`status` text,
`arcId` text,
`url` text,
`notes` text,
FOREIGN KEY (`characterId`) REFERENCES `character`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`devilFruitId`) REFERENCES `devilFruit`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`arcId`) REFERENCES `arc`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `characterScrapeValidation` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`gender` text,
`age` integer,
`affiliations` text,
`devilFruitId` text,
`hakiObservation` integer DEFAULT false,
`hakiArmament` integer DEFAULT false,
`hakiConqueror` integer DEFAULT false,
`bounty` integer,
`height` real,
`origin` text,
`firstAppearance` integer NOT NULL,
`pictureUrl` text,
`epithets` text,
`status` text,
`arcId` text,
`url` text,
FOREIGN KEY (`devilFruitId`) REFERENCES `devilFruit`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`arcId`) REFERENCES `arc`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `config` (
`key` text PRIMARY KEY NOT NULL,
`value` text
);
--> statement-breakpoint
CREATE TABLE `devilFruit` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`type` text
`type` text,
`url` text
);
--> statement-breakpoint
CREATE UNIQUE INDEX `devilFruit_name_unique` ON `devilFruit` (`name`);--> statement-breakpoint

View File

@@ -1,9 +1,54 @@
{
"version": "6",
"dialect": "sqlite",
"id": "40edd98b-5a47-4a5e-a5b8-8a0f6eaaec76",
"id": "d1237d76-8f1c-4721-b8dd-d31082ed7b9a",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"arc": {
"name": "arc",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"startChapter": {
"name": "startChapter",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"endChapter": {
"name": "endChapter",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"url": {
"name": "url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"character": {
"name": "character",
"columns": {
@@ -42,26 +87,44 @@
"notNull": false,
"autoincrement": false
},
"devilFruit": {
"name": "devilFruit",
"devilFruitId": {
"name": "devilFruitId",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"haki": {
"name": "haki",
"type": "text",
"hakiObservation": {
"name": "hakiObservation",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
"autoincrement": false,
"default": false
},
"hakiArmament": {
"name": "hakiArmament",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": false
},
"hakiConqueror": {
"name": "hakiConqueror",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": false
},
"bounty": {
"name": "bounty",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
"autoincrement": false,
"default": 0
},
"height": {
"name": "height",
@@ -79,9 +142,9 @@
},
"firstAppearance": {
"name": "firstAppearance",
"type": "text",
"type": "integer",
"primaryKey": false,
"notNull": false,
"notNull": true,
"autoincrement": false
},
"pictureUrl": {
@@ -90,16 +153,65 @@
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"epithets": {
"name": "epithets",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"arcId": {
"name": "arcId",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"url": {
"name": "url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"isInDailyMode": {
"name": "isInDailyMode",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": true
}
},
"indexes": {},
"foreignKeys": {
"character_devilFruit_devilFruit_id_fk": {
"name": "character_devilFruit_devilFruit_id_fk",
"character_devilFruitId_devilFruit_id_fk": {
"name": "character_devilFruitId_devilFruit_id_fk",
"tableFrom": "character",
"tableTo": "devilFruit",
"columnsFrom": [
"devilFruit"
"devilFruitId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"character_arcId_arc_id_fk": {
"name": "character_arcId_arc_id_fk",
"tableFrom": "character",
"tableTo": "arc",
"columnsFrom": [
"arcId"
],
"columnsTo": [
"id"
@@ -131,11 +243,19 @@
},
"date": {
"name": "date",
"type": "integer",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"won": {
"name": "won",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"createdAt": {
"name": "createdAt",
"type": "integer",
@@ -171,6 +291,379 @@
"uniqueConstraints": {},
"checkConstraints": {}
},
"characterOverride": {
"name": "characterOverride",
"columns": {
"characterId": {
"name": "characterId",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"gender": {
"name": "gender",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"age": {
"name": "age",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"affiliations": {
"name": "affiliations",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"devilFruitId": {
"name": "devilFruitId",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"hakiObservation": {
"name": "hakiObservation",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"hakiArmament": {
"name": "hakiArmament",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"hakiConqueror": {
"name": "hakiConqueror",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"bounty": {
"name": "bounty",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"height": {
"name": "height",
"type": "real",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"origin": {
"name": "origin",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"firstAppearance": {
"name": "firstAppearance",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"pictureUrl": {
"name": "pictureUrl",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"epithets": {
"name": "epithets",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"arcId": {
"name": "arcId",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"url": {
"name": "url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"notes": {
"name": "notes",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"characterOverride_characterId_character_id_fk": {
"name": "characterOverride_characterId_character_id_fk",
"tableFrom": "characterOverride",
"tableTo": "character",
"columnsFrom": [
"characterId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"characterOverride_devilFruitId_devilFruit_id_fk": {
"name": "characterOverride_devilFruitId_devilFruit_id_fk",
"tableFrom": "characterOverride",
"tableTo": "devilFruit",
"columnsFrom": [
"devilFruitId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"characterOverride_arcId_arc_id_fk": {
"name": "characterOverride_arcId_arc_id_fk",
"tableFrom": "characterOverride",
"tableTo": "arc",
"columnsFrom": [
"arcId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"characterScrapeValidation": {
"name": "characterScrapeValidation",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"gender": {
"name": "gender",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"age": {
"name": "age",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"affiliations": {
"name": "affiliations",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"devilFruitId": {
"name": "devilFruitId",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"hakiObservation": {
"name": "hakiObservation",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": false
},
"hakiArmament": {
"name": "hakiArmament",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": false
},
"hakiConqueror": {
"name": "hakiConqueror",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": false
},
"bounty": {
"name": "bounty",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"height": {
"name": "height",
"type": "real",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"origin": {
"name": "origin",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"firstAppearance": {
"name": "firstAppearance",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"pictureUrl": {
"name": "pictureUrl",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"epithets": {
"name": "epithets",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"arcId": {
"name": "arcId",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"url": {
"name": "url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"characterScrapeValidation_devilFruitId_devilFruit_id_fk": {
"name": "characterScrapeValidation_devilFruitId_devilFruit_id_fk",
"tableFrom": "characterScrapeValidation",
"tableTo": "devilFruit",
"columnsFrom": [
"devilFruitId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"characterScrapeValidation_arcId_arc_id_fk": {
"name": "characterScrapeValidation_arcId_arc_id_fk",
"tableFrom": "characterScrapeValidation",
"tableTo": "arc",
"columnsFrom": [
"arcId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"config": {
"name": "config",
"columns": {
"key": {
"name": "key",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"devilFruit": {
"name": "devilFruit",
"columns": {
@@ -194,6 +687,13 @@
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"url": {
"name": "url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {

View File

@@ -5,8 +5,8 @@
{
"idx": 0,
"version": "6",
"when": 1772148571269,
"tag": "0000_dapper_sage",
"when": 1772325597983,
"tag": "0000_graceful_master_mold",
"breakpoints": true
}
]

View File

@@ -14,9 +14,10 @@
"format": "prettier --write .",
"db:push": "drizzle-kit push",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:migrate": "drizzle-kit migrate && npx tsx scripts/init-column-config.ts",
"db:studio": "drizzle-kit studio",
"db:import": "node scripts/import-sql.js",
"db:import": "npx tsx scripts/import-json.ts",
"db:set-daily-mode": "npx tsx scripts/set-daily-mode.ts",
"auth:schema": "npx @better-auth/cli generate --config src/lib/server/auth.ts --output src/lib/server/db/auth.schema.ts --yes",
"scrape": "node scripts/scrape-onepiece.js"
},

View File

@@ -0,0 +1,145 @@
[
"aladdin_aladdin",
"alvida_alvida",
"aramaki_aramaki",
"arlong_arlong",
"ashura_doji_ashura_doji",
"baby_5_baby_5",
"baggy_baggy",
"bartholomew_kuma_bartholomew_kuma",
"bartolomeo_bartolomeo",
"basil_hawkins_basil_hawkins",
"batman_batman",
"bellamy_bellamy",
"belo_betty_belo_betty",
"ben_beckman_ben_beckman",
"bentham_bentham",
"bepo_bepo",
"black_maria_black_maria",
"boa_hancock_boa_hancock",
"boa_marigold_boa_marigold",
"boa_sandersonia_boa_sandersonia",
"borsalino_borsalino",
"brogy_brogy",
"brook_brook",
"camie_camie",
"capone_bege_capone_bege",
"caribou_caribou",
"carrot_carrot",
"catarina_devon_catarina_devon",
"cavendish_cavendish",
"cesar_clown_cesar_clown",
"chinjao_chinjao",
"coby_coby",
"corazon_corazon",
"crocodile_crocodile",
"crocus_crocus",
"curly_dadan_curly_dadan",
"dalton_dalton",
"daz_bones_daz_bones",
"denjiro_denjiro",
"diamante_diamante",
"doc_q_doc_q",
"don_quichotte_doflamingo_don_quichotte_doflamingo",
"don_quichotte_rossinante_don_quichotte_rossinante",
"dorry_dorry",
"dracule_mihawk_dracule_mihawk",
"duval_duval",
"edward_newgate_edward_newgate",
"edward_weevil_edward_weevil",
"emporio_ivankov_emporio_ivankov",
"enel_enel",
"eustass_kid_eustass_kid",
"fisher_tiger_fisher_tiger",
"foxy_foxy",
"franky_franky",
"fujitora_fujitora",
"gan_forr_gan_forr",
"gecko_moria_gecko_moria",
"gin_gin",
"gol_d_roger_gol_d_roger",
"haguar_d_sauro_haguar_d_sauro",
"hajrudin_hajrudin",
"hannyabal_hannyabal",
"hatchan_hatchan",
"hina_hina",
"hody_jones_hody_jones",
"hyogoro_hyogoro",
"iceburg_iceburg",
"imu_imu",
"inazuma_inazuma",
"inuarashi_inuarashi",
"issho_issho",
"izo_izo",
"jabra_jabra",
"jack_jack",
"jesus_burgess_jesus_burgess",
"jewelry_bonney_jewelry_bonney",
"jinbei_jinbei",
"joy_boy_joy_boy",
"kaidou_kaidou",
"kaku_kaku",
"kalgara_kalgara",
"kalifa_kalifa",
"karasu_karasu",
"karoo_karoo",
"kawamatsu_kawamatsu",
"kaya_kaya",
"killer_killer",
"kinemon_kinemon",
"koala_koala",
"koby_koby",
"kong_kong",
"kozuki_hiyori_kozuki_hiyori",
"kozuki_momonosuke_kozuki_momonosuke",
"kozuki_oden_kozuki_oden",
"krieg_krieg",
"kureha_kureha",
"kuro_kuro",
"kurozumi_orochi_kurozumi_orochi",
"kuzan_kuzan",
"kyros_kyros",
"laboon_laboon",
"laffitte_laffitte",
"lao_g_lao_g",
"leo_leo",
"lindbergh_lindbergh",
"loki_loki",
"lucky_roux_lucky_roux",
"magellan_magellan",
"makino_makino",
"marco_marco",
"marshall_d_teach_marshall_d_teach",
"monkey_d_dragon_monkey_d_dragon",
"monkey_d_garp_monkey_d_garp",
"monkey_d_luffy_monkey_d_luffy",
"montblanc_norland_montblanc_norland",
"morgans_morgans",
"morley_morley",
"mr_3_mr_3",
"nami_nami",
"nefertari_cobra_nefertari_cobra",
"nefertari_vivi_nefertari_vivi",
"nekomamushi_nekomamushi",
"neptune_neptune",
"nico_robin_nico_robin",
"oars_oars",
"orlumbus_orlumbus",
"otohime_otohime",
"page_one_page_one",
"pandaman_pandaman",
"pekoms_pekoms",
"pell_pell",
"perona_perona",
"pica_pica",
"portgas_d_ace_portgas_d_ace",
"queen_queen",
"raizo_raizo",
"rebecca_rebecca",
"rob_lucci_rob_lucci",
"rocks_d_xebec_rocks_d_xebec",
"roronoa_zoro_roronoa_zoro",
"sabo_sabo",
"vegapunk_vegapunk",
"yamato_yamato"
]

406
scripts/import-json.ts Normal file
View File

@@ -0,0 +1,406 @@
import { createClient } from '@libsql/client';
import { drizzle } from 'drizzle-orm/libsql';
import { sql, eq } from 'drizzle-orm';
import fs from 'fs';
import { arc, character, devilFruit, characterScrapeValidation, type DevilFruitType } from '../src/lib/server/db/schema';
type ArcRecord = {
id: string;
name: string;
startChapter: number;
endChapter?: number | null;
url?: string | null;
};
type DevilFruitRecord = {
id: string;
name: string;
type?: DevilFruitType | string | null;
url?: string | null;
};
type CharacterRecord = {
id: string;
name: string;
gender?: string | null;
age?: number | null;
affiliations?: string[] | string | null;
devilFruitId?: string | null;
hakiObservation?: boolean;
hakiArmament?: boolean;
hakiConqueror?: boolean;
bounty?: number | null;
height?: number | null;
origin?: string | null;
firstAppearance?: number;
pictureUrl?: string | null;
epithets?: string[] | string | null;
status?: string | null;
arcId?: string | null;
url?: string | null;
};
const DATABASE_URL = process.env.DATABASE_URL || 'file:local.db';
const client = createClient({ url: DATABASE_URL });
const db = drizzle(client);
function readJsonFile<T>(path: string): T[] | null {
if (!fs.existsSync(path)) {
return null;
}
const content = fs.readFileSync(path, 'utf-8');
return JSON.parse(content) as T[];
}
function toNullable<T>(value: T | undefined | null | ''): T | null {
return value === undefined || value === null || value === '' ? null : value;
}
function toJsonArray(value: string[] | string | null | undefined): string[] | null {
if (Array.isArray(value)) {
return value.length > 0 ? value : null;
}
if (typeof value === 'string' && value.trim() !== '') {
if (value.trim().startsWith('[')) {
try {
const parsed = JSON.parse(value);
return Array.isArray(parsed) ? parsed : [value];
} catch {
return [value];
}
}
const splitValues = value
.split(',')
.map((item) => item.trim())
.filter(Boolean);
return splitValues.length > 0 ? splitValues : null;
}
return null;
}
function toDevilFruitType(value: DevilFruitType | string | null | undefined): DevilFruitType | null {
if (!value) return null;
if (value === 'Paramecia' || value === 'Zoan' || value === 'Logia' || value === 'Unknown') {
return value;
}
return 'Unknown';
}
function toNumber(value: string | number | null | undefined): number | null {
if (value === null || value === undefined || value === '') return null;
const num = typeof value === 'string' ? parseFloat(value) : value;
return isNaN(num) ? null : num;
}
function getErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function logSqlOnError(statement: { sql: string; params: unknown[] } | null): void {
if (!statement) return;
console.error(` SQL: ${statement.sql}`);
console.error(` Params: ${JSON.stringify(statement.params)}`);
}
function transformCharacterData(item: CharacterRecord) {
return {
id: item.id,
name: item.name,
gender: toNullable(item.gender),
age: toNullable(item.age),
affiliations: toJsonArray(item.affiliations),
devilFruitId: toNullable(item.devilFruitId),
hakiObservation: !!item.hakiObservation,
hakiArmament: !!item.hakiArmament,
hakiConqueror: !!item.hakiConqueror,
bounty: item.bounty ?? 0,
height: toNumber(item.height as any),
origin: toNullable(item.origin),
firstAppearance: item.firstAppearance ?? 0,
pictureUrl: toNullable(item.pictureUrl),
epithets: toJsonArray(item.epithets),
status: toNullable(item.status),
arcId: toNullable(item.arcId),
url: toNullable(item.url)
};
}
function hasChanged(jsonData: any, dbData: any): boolean {
if (!dbData) return true;
// Print any differences for debugging
for (const key in jsonData) {
const jsonValue = jsonData[key];
const dbValue = dbData[key];
const jsonString = typeof jsonValue === 'object' ? JSON.stringify(jsonValue) : String(jsonValue);
const dbString = typeof dbValue === 'object' ? JSON.stringify(dbValue) : String(dbValue);
if (jsonString !== dbString) {
console.log(`\nField "${key}" changed for character ID ${jsonData.id}:`);
console.log(` JSON: ${jsonString}`);
console.log(` DB: ${dbString}`);
} }
// Compare each field
return (
jsonData.name != dbData.name ||
jsonData.gender != dbData.gender ||
jsonData.age != dbData.age ||
JSON.stringify(jsonData.affiliations) != JSON.stringify(dbData.affiliations) ||
jsonData.devilFruitId != dbData.devilFruitId ||
jsonData.hakiObservation != dbData.hakiObservation ||
jsonData.hakiArmament != dbData.hakiArmament ||
jsonData.hakiConqueror != dbData.hakiConqueror ||
jsonData.bounty != dbData.bounty ||
jsonData.height != dbData.height ||
jsonData.origin != dbData.origin ||
jsonData.firstAppearance != dbData.firstAppearance ||
jsonData.pictureUrl != dbData.pictureUrl ||
JSON.stringify(jsonData.epithets) != JSON.stringify(dbData.epithets) ||
jsonData.status != dbData.status ||
jsonData.arcId != dbData.arcId ||
jsonData.url != dbData.url
);
}
async function isCharacterTableEmpty(): Promise<boolean> {
const result = await db.select({ count: sql<number>`COUNT(*)` }).from(character);
return result[0]?.count === 0;
}
async function importFromJson(): Promise<void> {
let totalSuccess = 0;
let totalErrors = 0;
try {
const arcs = readJsonFile<ArcRecord>('./scraped-data/arcs.json');
if (arcs) {
console.log('\n=== Importing Arcs ===\n');
console.log(`Found ${arcs.length} arcs\n`);
let successCount = 0;
let errorCount = 0;
for (let i = 0; i < arcs.length; i++) {
const item = arcs[i];
let lastSql: { sql: string; params: unknown[] } | null = null;
try {
const query = db
.insert(arc)
.values({
id: item.id,
name: item.name,
startChapter: item.startChapter,
endChapter: toNullable(item.endChapter),
url: toNullable(item.url)
})
.onConflictDoUpdate({
target: arc.id,
set: {
name: item.name,
startChapter: item.startChapter,
endChapter: toNullable(item.endChapter),
url: toNullable(item.url)
}
});
lastSql = query.toSQL();
await query;
successCount++;
process.stdout.write(`\rExecuted: ${successCount}/${arcs.length}`);
} catch (error) {
errorCount++;
console.error(`\n✗ Error at arc ${i + 1}:`);
console.error(` ID: ${item.id ?? 'N/A'}`);
console.error(` Message: ${getErrorMessage(error)}`);
logSqlOnError(lastSql);
}
}
console.log(`\n\n✓ Arcs imported!`);
console.log(` Success: ${successCount}`);
console.log(` Errors: ${errorCount}`);
totalSuccess += successCount;
totalErrors += errorCount;
} else {
console.log('\n⚠ No arcs.json found, skipping...\n');
}
const fruits = readJsonFile<DevilFruitRecord>('./scraped-data/devil-fruits.json');
if (fruits) {
console.log('\n=== Importing Devil Fruits ===\n');
console.log(`Found ${fruits.length} devil fruits\n`);
let successCount = 0;
let errorCount = 0;
for (let i = 0; i < fruits.length; i++) {
const item = fruits[i];
let lastSql: { sql: string; params: unknown[] } | null = null;
try {
const query = db
.insert(devilFruit)
.values({
id: item.id,
name: item.name,
type: toDevilFruitType(item.type),
url: toNullable(item.url)
})
.onConflictDoUpdate({
target: devilFruit.id,
set: {
name: item.name,
type: toDevilFruitType(item.type),
url: toNullable(item.url)
}
});
lastSql = query.toSQL();
await query;
successCount++;
process.stdout.write(`\rExecuted: ${successCount}/${fruits.length}`);
} catch (error) {
errorCount++;
console.error(`\n✗ Error at devil fruit ${i + 1}:`);
console.error(` ID: ${item.id ?? 'N/A'}`);
console.error(` Message: ${getErrorMessage(error)}`);
logSqlOnError(lastSql);
}
}
console.log(`\n\n✓ Devil Fruits imported!`);
console.log(` Success: ${successCount}`);
console.log(` Errors: ${errorCount}`);
totalSuccess += successCount;
totalErrors += errorCount;
} else {
console.log('\n⚠ No devil-fruits.json found, skipping...\n');
}
const characters = readJsonFile<CharacterRecord>('./scraped-data/characters.json');
if (characters) {
console.log('\n=== Importing Characters ===\n');
console.log(`Found ${characters.length} characters\n`);
const isEmpty = await isCharacterTableEmpty();
let successCount = 0;
let errorCount = 0;
if (isEmpty) {
// Populate empty character table
console.log('Characters table is empty, populating...\n');
for (let i = 0; i < characters.length; i++) {
const item = characters[i];
let lastSql: { sql: string; params: unknown[] } | null = null;
try {
const data = transformCharacterData(item);
const query = db
.insert(character)
.values(data)
.onConflictDoUpdate({
target: character.id,
set: data
});
lastSql = query.toSQL();
await query;
successCount++;
process.stdout.write(`\rExecuted: ${successCount}/${characters.length}`);
} catch (error) {
errorCount++;
console.error(`\n✗ Error at character ${i + 1}:`);
console.error(` ID: ${item.id ?? 'N/A'}`);
console.error(` Message: ${getErrorMessage(error)}`);
logSqlOnError(lastSql);
}
}
} else {
// Check for changes and update scrapeValidation table
console.log('Characters table not empty, checking for changes...\n');
for (let i = 0; i < characters.length; i++) {
const item = characters[i];
let lastSql: { sql: string; params: unknown[] } | null = null;
try {
const selectQuery = db
.select()
.from(character)
.where(eq(character.id, item.id));
lastSql = selectQuery.toSQL();
const [dbCharacter] = await selectQuery;
const jsonData = transformCharacterData(item);
const changed = hasChanged(jsonData, dbCharacter);
if (changed) {
// Update scrapeValidation table with changes
const upsertQuery = db
.insert(characterScrapeValidation)
.values(jsonData)
.onConflictDoUpdate({
target: characterScrapeValidation.id,
set: jsonData
});
lastSql = upsertQuery.toSQL();
await upsertQuery;
} else {
// No changes, delete from scrapeValidation if it exists
const deleteQuery = db
.delete(characterScrapeValidation)
.where(eq(characterScrapeValidation.id, item.id));
lastSql = deleteQuery.toSQL();
await deleteQuery;
}
successCount++;
process.stdout.write(`\rProcessed: ${successCount}/${characters.length}`);
} catch (error) {
errorCount++;
console.error(`\n✗ Error at character ${i + 1}:`);
console.error(` ID: ${item.id ?? 'N/A'}`);
console.error(` Message: ${getErrorMessage(error)}`);
logSqlOnError(lastSql);
}
}
}
console.log(`\n\n✓ Characters imported!`);
console.log(` Success: ${successCount}`);
console.log(` Errors: ${errorCount}`);
totalSuccess += successCount;
totalErrors += errorCount;
} else {
console.log('\n⚠ No characters.json found, skipping...\n');
}
console.log(`\n=== Total Import Summary ===`);
console.log(` Total Success: ${totalSuccess}`);
console.log(` Total Errors: ${totalErrors}\n`);
} catch (error) {
console.error('✗ Import failed:', getErrorMessage(error));
process.exit(1);
} finally {
client.close();
}
}
importFromJson().catch((error) => {
console.error(getErrorMessage(error));
process.exit(1);
});

View File

@@ -1,104 +0,0 @@
import { createClient } from '@libsql/client';
import fs from 'fs';
// Load environment variables
const DATABASE_URL = process.env.DATABASE_URL || 'file:local.db';
const client = createClient({
url: DATABASE_URL
});
async function importSQL() {
try {
let totalSuccess = 0;
let totalErrors = 0;
// Step 1: Import Devil Fruits
if (fs.existsSync('./scraped-data/devil-fruits.sql')) {
console.log('\n=== Importing Devil Fruits ===\n');
const devilFruitsSql = fs.readFileSync('./scraped-data/devil-fruits.sql', 'utf-8');
const dfStatements = devilFruitsSql.split(';\n\n').filter(s => s.trim());
console.log(`Found ${dfStatements.length} devil fruit statements\n`);
let successCount = 0;
let errorCount = 0;
for (let i = 0; i < dfStatements.length; i++) {
const statement = dfStatements[i];
if (statement.trim()) {
try {
await client.execute(statement.trim() + ';');
successCount++;
process.stdout.write(`\rExecuted: ${successCount}/${dfStatements.length}`);
} catch (error) {
errorCount++;
const valuesMatch = statement.match(/VALUES\s*\(([^)]+)\)/);
const values = valuesMatch ? valuesMatch[1] : 'N/A';
console.error(`\n✗ Error at statement ${i + 1}:`);
console.error(` Values: ${values}`);
console.error(` Message: ${error.message}`);
}
}
}
console.log(`\n\n✓ Devil Fruits imported!`);
console.log(` Success: ${successCount}`);
console.log(` Errors: ${errorCount}`);
totalSuccess += successCount;
totalErrors += errorCount;
} else {
console.log('\n⚠ No devil-fruits.sql found, skipping...\n');
}
// Step 2: Import Characters
if (fs.existsSync('./scraped-data/characters.sql')) {
console.log('\n=== Importing Characters ===\n');
const charactersSql = fs.readFileSync('./scraped-data/characters.sql', 'utf-8');
const charStatements = charactersSql.split(';\n\n').filter(s => s.trim());
console.log(`Found ${charStatements.length} character statements\n`);
let successCount = 0;
let errorCount = 0;
for (let i = 0; i < charStatements.length; i++) {
const statement = charStatements[i];
if (statement.trim()) {
try {
await client.execute(statement.trim() + ';');
successCount++;
process.stdout.write(`\rExecuted: ${successCount}/${charStatements.length}`);
} catch (error) {
errorCount++;
const valuesMatch = statement.match(/VALUES\s*\(([^)]+)\)/);
const values = valuesMatch ? valuesMatch[1] : 'N/A';
console.error(`\n✗ Error at statement ${i + 1}:`);
console.error(` Values: ${values}`);
console.error(` Message: ${error.message}`);
}
}
}
console.log(`\n\n✓ Characters imported!`);
console.log(` Success: ${successCount}`);
console.log(` Errors: ${errorCount}`);
totalSuccess += successCount;
totalErrors += errorCount;
} else {
console.log('\n⚠ No characters.sql found, skipping...\n');
}
console.log(`\n=== Total Import Summary ===`);
console.log(` Total Success: ${totalSuccess}`);
console.log(` Total Errors: ${totalErrors}\n`);
} catch (error) {
console.error('✗ Import failed:', error.message);
process.exit(1);
}
}
importSQL().catch(console.error);

View File

@@ -0,0 +1,64 @@
/**
* Initialize config table with column visibility settings for characterHistory table
*/
import { createClient } from '@libsql/client';
import { drizzle } from 'drizzle-orm/libsql';
import { config } from '../src/lib/server/db/schema';
import { like } from 'drizzle-orm';
// Load environment variables
const DATABASE_URL = process.env.DATABASE_URL || 'file:local.db';
const client = createClient({ url: DATABASE_URL });
const db = drizzle(client);
// Define the columns in characterHistory table
const columns = [
'gender',
'affiliations',
'haki',
'bounty',
'height',
'origin',
'devilFruitType',
'arc',
'status'
] as const;
async function initColumnConfig(): Promise<void> {
try {
console.log('Initializing column visibility config...\n');
for (const column of columns) {
const key = `characterHistory.column.${column}.visible`;
const value = 'true';
await db.insert(config)
.values({ key, value })
.onConflictDoNothing();
console.log(`✓ Added config key: ${key} = ${value}`);
}
console.log('\n✓ Successfully initialized column visibility config');
// Display all config entries
const allConfig = await db.select()
.from(config)
.where(like(config.key, 'characterHistory.column.%.visible'));
console.log('\nCurrent column visibility config:');
allConfig.forEach(row => {
console.log(` ${row.key}: ${row.value}`);
});
} catch (error) {
console.error('Error initializing config:', error);
process.exit(1);
} finally {
client.close();
}
}
initColumnConfig();

File diff suppressed because it is too large Load Diff

84
scripts/set-daily-mode.ts Normal file
View File

@@ -0,0 +1,84 @@
import { createClient } from '@libsql/client';
import { drizzle } from 'drizzle-orm/libsql';
import { eq } from 'drizzle-orm';
import fs from 'fs';
import { character } from '../src/lib/server/db/schema';
const DATABASE_URL = process.env.DATABASE_URL || 'file:local.db';
const client = createClient({ url: DATABASE_URL });
const db = drizzle(client);
function readJsonFile(path: string): string[] | null {
if (!fs.existsSync(path)) {
return null;
}
const content = fs.readFileSync(path, 'utf-8');
return JSON.parse(content) as string[];
}
function getErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
async function setDailyCharacters(): Promise<void> {
try {
const dailyCharacterIds = readJsonFile('./scripts/daily-characters.json');
if (!dailyCharacterIds || dailyCharacterIds.length === 0) {
console.error('❌ No daily characters found in daily-characters.json');
process.exit(1);
}
console.log(`\n=== Setting Daily Mode Characters ===\n`);
console.log(`Found ${dailyCharacterIds.length} characters to set as daily\n`);
// Step 1: Disable isInDailyMode for all characters
console.log('Step 1: Disabling isInDailyMode for all characters...');
await db.update(character).set({ isInDailyMode: false });
console.log('✓ All characters disabled from daily mode\n');
// Step 2: Enable isInDailyMode for characters in the list
console.log('Step 2: Enabling isInDailyMode for daily characters...\n');
let successCount = 0;
let errorCount = 0;
for (let i = 0; i < dailyCharacterIds.length; i++) {
const charId = dailyCharacterIds[i];
try {
const result = await db
.update(character)
.set({ isInDailyMode: true })
.where(eq(character.id, charId));
successCount++;
process.stdout.write(`\rUpdated: ${successCount}/${dailyCharacterIds.length}`);
} catch (error) {
errorCount++;
console.error(`\n✗ Error updating character ${i + 1}:`);
console.error(` ID: ${charId}`);
console.error(` Message: ${getErrorMessage(error)}`);
}
}
console.log(`\n\n✓ Daily characters updated!`);
console.log(` Success: ${successCount}`);
console.log(` Errors: ${errorCount}`);
if (errorCount === 0) {
console.log(`\n✅ Successfully set ${successCount} characters as daily mode characters\n`);
}
} catch (error) {
console.error('✗ Operation failed:', getErrorMessage(error));
process.exit(1);
} finally {
client.close();
}
}
setDailyCharacters().catch((error) => {
console.error(getErrorMessage(error));
process.exit(1);
});

View File

@@ -1,8 +1,36 @@
import type { Handle } from '@sveltejs/kit';
import { building } from '$app/environment';
import { auth } from '$lib/server/auth';
import { getDailyModeCharacters, getOrCreateTodayCharacter } from '$lib/server/daily-character';
import { svelteKitHandler } from 'better-auth/svelte-kit';
declare global {
// eslint-disable-next-line no-var
var __dailyCharacterSchedulerStarted: boolean | undefined;
}
async function runDailyCharacterSchedulerJob() {
try {
const characters = await getDailyModeCharacters();
if (characters.length === 0) {
return;
}
await getOrCreateTodayCharacter(characters);
} catch (error) {
console.error('Daily character scheduler failed:', error);
}
}
if (!building && !globalThis.__dailyCharacterSchedulerStarted) {
globalThis.__dailyCharacterSchedulerStarted = true;
void runDailyCharacterSchedulerJob();
setInterval(() => {
void runDailyCharacterSchedulerJob();
}, 60_000);
}
const handleBetterAuth: Handle = async ({ event, resolve }) => {
const session = await auth.api.getSession({ headers: event.request.headers });

View File

@@ -0,0 +1,152 @@
import { db } from '$lib/server/db';
import { arc, character, characterHistory, devilFruit } from '$lib/server/db/schema';
import { desc, eq } from 'drizzle-orm';
const characterWithRelationsSelect = {
id: character.id,
name: character.name,
gender: character.gender,
age: character.age,
affiliations: character.affiliations,
devilFruitId: character.devilFruitId,
devilFruitName: devilFruit.name,
devilFruitType: devilFruit.type,
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,
arcName: arc.name
};
export type CharacterWithRelations = typeof character.$inferSelect & {
devilFruitName: string | null;
devilFruitType: string | null;
arcName: string | null;
};
function getDateKey(date: Date): string {
return date.toISOString().split('T')[0];
}
function normalizeDay(date: Date = new Date()): Date {
const normalized = new Date(date);
normalized.setHours(0, 0, 0, 0);
return normalized;
}
function pickDailyCharacter(characters: CharacterWithRelations[], date: Date): CharacterWithRelations {
const dateStr = getDateKey(date);
const seed = dateStr.split('-').reduce((acc, value) => acc + parseInt(value), 0);
const index = seed % characters.length;
return characters[index];
}
export async function getDailyModeCharacters(): Promise<CharacterWithRelations[]> {
return 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 Promise<CharacterWithRelations[]>;
}
export async function getCharacterById(characterId: string): Promise<CharacterWithRelations | null> {
const [found] = await db
.select(characterWithRelationsSelect)
.from(character)
.leftJoin(arc, eq(character.arcId, arc.id))
.leftJoin(devilFruit, eq(character.devilFruitId, devilFruit.id))
.where(eq(character.id, characterId))
.limit(1);
return (found ?? null) as CharacterWithRelations | null;
}
export async function getOrCreateTodayCharacter(
characters: CharacterWithRelations[],
date: Date = new Date()
): Promise<CharacterWithRelations | null> {
if (characters.length === 0) {
return null;
}
const today = normalizeDay(date);
const todayDate = getDateKey(today);
const [existingEntry] = await db
.select()
.from(characterHistory)
.where(eq(characterHistory.date, todayDate))
.limit(1);
if (existingEntry?.characterId) {
return (
characters.find((currentCharacter) => currentCharacter.id === existingEntry.characterId) ??
(await getCharacterById(existingEntry.characterId))
);
}
const recentHistory = await db
.select({ characterId: characterHistory.characterId })
.from(characterHistory)
.orderBy(desc(characterHistory.date))
.limit(100);
const excludedIds = new Set(recentHistory.map((entry) => entry.characterId));
const availableCharacters = characters.filter((currentCharacter) => !excludedIds.has(currentCharacter.id));
const dailyCharacter = pickDailyCharacter(
availableCharacters.length > 0 ? availableCharacters : characters,
today
);
try {
await db.insert(characterHistory).values({
characterId: dailyCharacter.id,
date: todayDate,
createdAt: Date.now(),
updatedAt: Date.now()
});
} catch (error) {
console.error('Failed to record daily character:', error);
}
return dailyCharacter;
}
export async function getYesterdayCharacter(
date: Date = new Date(),
characters?: CharacterWithRelations[]
): Promise<CharacterWithRelations | null> {
const baseDate = normalizeDay(date);
baseDate.setDate(baseDate.getDate() - 1);
const yesterdayDate = getDateKey(baseDate);
const [yesterdayEntry] = await db
.select()
.from(characterHistory)
.where(eq(characterHistory.date, yesterdayDate))
.limit(1);
if (!yesterdayEntry?.characterId) {
return null;
}
if (characters) {
return (
characters.find((currentCharacter) => currentCharacter.id === yesterdayEntry.characterId) ??
(await getCharacterById(yesterdayEntry.characterId))
);
}
return getCharacterById(yesterdayEntry.characterId);
}

View File

@@ -1,16 +1,29 @@
import { integer, sqliteTable, text, real } from 'drizzle-orm/sqlite-core';
// Define haki types
export type HakiType = 'Observation' | 'Armament' | 'Conqueror';
// Define devil fruit types
export type DevilFruitType = 'Paramecia' | 'Zoan' | 'Logia' | 'Unknown';
// Define the site config table schema
export const config = sqliteTable('config', {
key: text('key').primaryKey(),
value: text('value')
});
// Define the arc table schema
export const arc = sqliteTable('arc', {
id: text('id').primaryKey(),
name: text('name').notNull(),
startChapter: integer('startChapter').notNull(),
endChapter: integer('endChapter'),
url: text('url')
});
// Define the devil fruit table schema
export const devilFruit = sqliteTable('devilFruit', {
id: text('id').primaryKey(),
name: text('name').notNull().unique(),
type: text('type').$type<DevilFruitType>()
type: text('type').$type<DevilFruitType>(),
url: text('url')
});
// Define the character table schema
@@ -19,15 +32,66 @@ export const character = sqliteTable('character', {
name: text('name').notNull(),
gender: text('gender'),
age: integer('age'),
affiliations: text('affiliations'),
devilFruit: text('devilFruit').references(() => devilFruit.id),
haki: text('haki', { mode: 'json' }).$type<HakiType[]>(),
bounty: integer('bounty'),
// height in meters as a float (e.g. 1.75)
affiliations: text('affiliations', { mode: 'json' }).$type<string[]>(),
devilFruitId: text('devilFruitId').references(() => devilFruit.id),
hakiObservation: integer('hakiObservation', { mode: 'boolean' }).default(false),
hakiArmament: integer('hakiArmament', { mode: 'boolean' }).default(false),
hakiConqueror: integer('hakiConqueror', { mode: 'boolean' }).default(false),
bounty: integer('bounty').default(0),
height: real('height'),
origin: text('origin'),
firstAppearance: text('firstAppearance'),
pictureUrl: text('pictureUrl')
firstAppearance: integer('firstAppearance').notNull(),
pictureUrl: text('pictureUrl'),
epithets: text('epithets', { mode: 'json' }).$type<string[]>(),
status: text('status'),
arcId: text('arcId').references(() => arc.id),
url: text('url'),
isInDailyMode: integer('isInDailyMode', { mode: 'boolean' }).default(true)
});
// Define the character override table schema
export const characterOverride = sqliteTable('characterOverride', {
characterId: text('characterId').primaryKey().references(() => character.id),
name: text('name'),
gender: text('gender'),
age: integer('age'),
affiliations: text('affiliations', { mode: 'json' }).$type<string[]>(),
devilFruitId: text('devilFruitId').references(() => devilFruit.id),
hakiObservation: integer('hakiObservation', { mode: 'boolean' }),
hakiArmament: integer('hakiArmament', { mode: 'boolean' }),
hakiConqueror: integer('hakiConqueror', { mode: 'boolean' }),
bounty: integer('bounty'),
height: real('height'),
origin: text('origin'),
firstAppearance: integer('firstAppearance').notNull(),
pictureUrl: text('pictureUrl'),
epithets: text('epithets', { mode: 'json' }).$type<string[]>(),
status: text('status'),
arcId: text('arcId').references(() => arc.id),
url: text('url'),
notes: text('notes')
});
// Define the character scrape validation table schema
export const characterScrapeValidation = sqliteTable('characterScrapeValidation', {
id: text('id').primaryKey(),
name: text('name').notNull(),
gender: text('gender'),
age: integer('age'),
affiliations: text('affiliations', { mode: 'json' }).$type<string[]>(),
devilFruitId: text('devilFruitId').references(() => devilFruit.id),
hakiObservation: integer('hakiObservation', { mode: 'boolean' }).default(false),
hakiArmament: integer('hakiArmament', { mode: 'boolean' }).default(false),
hakiConqueror: integer('hakiConqueror', { mode: 'boolean' }).default(false),
bounty: integer('bounty'),
height: real('height'),
origin: text('origin'),
firstAppearance: integer('firstAppearance').notNull(),
pictureUrl: text('pictureUrl'),
epithets: text('epithets', { mode: 'json' }).$type<string[]>(),
status: text('status'),
arcId: text('arcId').references(() => arc.id),
url: text('url')
});
// Define the caracter history table schema
@@ -36,9 +100,11 @@ export const characterHistory = sqliteTable('characterHistory', {
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
characterId: text('characterId').references(() => character.id),
date: integer('date'),
date: text('date'),
won: integer('won').notNull().default(0),
createdAt: integer('createdAt').notNull().$default(() => Date.now()),
updatedAt: integer('updatedAt').notNull().$default(() => Date.now()),
});
export * from './auth.schema';

View File

@@ -0,0 +1,9 @@
import { getYesterdayCharacter } from '$lib/server/daily-character';
export async function load() {
const yesterdayCharacter = await getYesterdayCharacter();
return {
yesterdayCharacter: yesterdayCharacter || null
};
}

View File

@@ -1,19 +1,21 @@
<script lang="ts">
export let data;
$: yesterdayCharacter = data.yesterdayCharacter;
</script>
<svelte:head>
<title>OnePieceDle</title>
</svelte:head>
<main
class="relative min-h-screen overflow-hidden bg-slate-950 text-slate-100"
style="background-image: url('/one-piece-bg.jpg');"
>
<div class="absolute inset-0 bg-gradient-to-br from-slate-950/85 via-slate-900/60 to-slate-950/80"></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%)]"></div>
<div class="relative mx-auto flex min-h-screen w-full max-w-6xl flex-col items-center justify-between px-6 py-24 sm:py-28">
<div class="relative mx-auto flex min-h-screen w-full max-w-6xl flex-col items-center justify-between px-6 py-10">
<div class="flex w-full flex-1 flex-col items-center justify-between gap-12">
<div class="rounded-full border border-amber-200/30 bg-amber-100/10 px-5 py-2 text-xs font-semibold uppercase tracking-[0.35em] text-amber-100">
Jeu de devinettes Grand Line
</div>
<div class="text-center">
<h1 class="text-4xl font-black uppercase tracking-[0.3em] text-amber-50 sm:text-6xl">
OnePieceDle
@@ -24,7 +26,7 @@
</div>
<div class="grid w-full gap-4 sm:grid-cols-2">
<div class="rounded-2xl border border-white/10 bg-white/5 p-6 shadow-[0_24px_60px_rgba(0,0,0,0.45)] backdrop-blur">
<h2 class="text-sm font-semibold uppercase tracking-[0.2em] text-amber-100">Voyage du jour</h2>
<h2 class="text-sm font-semibold uppercase tracking-[0.2em] text-amber-100">Personnage du jour</h2>
<p class="mt-3 text-lg font-semibold text-white">Nouveau mystere toutes les 24 heures</p>
<p class="mt-2 text-sm text-slate-200">Compare tes essais, debloque des indices et garde ta serie.</p>
<a
@@ -39,24 +41,56 @@
<p class="mt-3 text-lg font-semibold text-white">Entraine-toi avec des pirates legendaires</p>
<p class="mt-2 text-sm text-slate-200">Choisis une epoque, regle la difficulte et vogue a ton rythme.</p>
<button class="mt-5 w-full rounded-full border border-amber-200/40 bg-transparent px-5 py-3 text-sm font-semibold text-amber-100 transition hover:border-amber-200 hover:text-amber-50">
Choisir un voyage
En construction
</button>
</div>
</div>
<div class="w-full rounded-3xl border border-white/10 bg-white/5 p-6 shadow-[0_24px_60px_rgba(0,0,0,0.45)] backdrop-blur sm:p-8">
{#if yesterdayCharacter}
<div class="flex flex-col items-center gap-5 text-center sm:flex-row sm:text-left">
{#if yesterdayCharacter.pictureUrl}
<img
src={yesterdayCharacter.pictureUrl}
alt={yesterdayCharacter.name}
class="h-20 w-20 rounded-full border border-amber-200/40 object-cover"
/>
{:else}
<div class="flex h-20 w-20 items-center justify-center rounded-full border border-amber-200/40 bg-slate-900/70 text-xs uppercase tracking-[0.25em] text-amber-100">
Photo
</div>
{/if}
<div class="flex-1">
<p class="text-xs font-semibold uppercase tracking-[0.28em] text-amber-100">Personnage d'hier</p>
<p class="mt-2 text-lg font-semibold text-white">{yesterdayCharacter.name}</p>
{#if yesterdayCharacter.epithets}
<p class="mt-1 text-sm text-slate-400">
{typeof yesterdayCharacter.epithets === 'string'
? JSON.parse(yesterdayCharacter.epithets).join(', ')
: (yesterdayCharacter.epithets as string[]).join(', ')}
</p>
{/if}
</div>
<a
href={"https://onepiece.fandom.com/fr/wiki/" + yesterdayCharacter.url}
target="_blank"
rel="noopener noreferrer"
class="w-full rounded-full border border-amber-200/40 bg-transparent px-5 py-3 text-sm font-semibold text-amber-100 transition hover:border-amber-200 hover:text-amber-50 sm:w-auto"
>
Voir la page
</a>
</div>
{:else}
<div class="flex flex-col items-center gap-5 text-center sm:flex-row sm:text-left">
<div class="flex h-20 w-20 items-center justify-center rounded-full border border-amber-200/40 bg-slate-900/70 text-xs uppercase tracking-[0.25em] text-amber-100">
Photo
</div>
<div class="flex-1">
<p class="text-xs font-semibold uppercase tracking-[0.28em] text-amber-100">Personnage de la veille</p>
<p class="mt-2 text-lg font-semibold text-white">Placeholder</p>
<p class="mt-1 text-sm text-slate-200">Le revele sera visible apres la partie du jour.</p>
<p class="text-xs font-semibold uppercase tracking-[0.28em] text-amber-100">Personnage d'hier</p>
<p class="mt-2 text-lg font-semibold text-white">Aucun personnage</p>
<p class="mt-1 text-sm text-slate-200">Aucun personnage d'hier disponible</p>
</div>
<button class="w-full rounded-full border border-amber-200/40 bg-transparent px-5 py-3 text-sm font-semibold text-amber-100 transition hover:border-amber-200 hover:text-amber-50 sm:w-auto">
Voir l'archive
</button>
</div>
{/if}
</div>
</div>
</div>

View File

@@ -0,0 +1,38 @@
import { error } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { config } from '$lib/server/db/schema';
import { getDailyModeCharacters, getOrCreateTodayCharacter, getYesterdayCharacter } from '$lib/server/daily-character';
import { like } from 'drizzle-orm';
export async function load() {
const characters = await getDailyModeCharacters();
const dailyCharacter = await getOrCreateTodayCharacter(characters);
if (!dailyCharacter) {
throw error(404, 'No daily character available. Please check if characters are configured in daily mode.');
}
const yesterdayCharacter = await getYesterdayCharacter(new Date(), characters);
// Load column visibility config
const columnConfig = await db
.select()
.from(config)
.where(like(config.key, 'characterHistory.column.%.visible'));
// Convert to object for easier access
const columnVisibility: Record<string, boolean> = {};
columnConfig.forEach(row => {
const match = row.key.match(/characterHistory\.column\.(.+)\.visible/);
if (match) {
columnVisibility[match[1]] = row.value === 'true';
}
});
return {
characters,
dailyCharacter,
yesterdayCharacter,
columnVisibility
};
}

View File

@@ -1,87 +1,772 @@
<script lang="ts">
import { onMount } from 'svelte';
export let data;
let searchInput = '';
let selectedCharacters: any[] = [];
let highlightedIndex = 0;
let isLoaded = false;
let isGeckoMoriaWin = false;
let dropdownContainer: HTMLDivElement;
let showHintOrigin = false;
let showHintFruit = false;
let showHintAffiliation = false;
let wasOriginAvailable = false;
let wasFruitAvailable = false;
let wasAffiliationAvailable = false;
let showOriginUnlock = false;
let showFruitUnlock = false;
let showAffiliationUnlock = false;
// Load from localStorage on mount
onMount(() => {
const stored = localStorage.getItem('dailyCharacterHistory');
if (stored) {
try {
const storedIds = JSON.parse(stored);
// Reconstruct character objects from IDs
if (Array.isArray(storedIds)) {
selectedCharacters = storedIds
.map((id: string) => data.characters.find((c: any) => c.id === id))
.filter((c: any) => c !== undefined);
}
} catch (e) {
console.error('Failed to parse stored history', e);
}
}
isLoaded = true;
});
// Save to localStorage whenever selectedCharacters changes (only store IDs)
$: if (isLoaded && selectedCharacters) {
const ids = selectedCharacters.map(char => char.id);
localStorage.setItem('dailyCharacterHistory', JSON.stringify(ids));
}
$: characters = data.characters || [];
$: dailyCharacter = data.dailyCharacter;
$: yesterdayCharacter = data.yesterdayCharacter;
$: columnVisibility = data.columnVisibility || {};
$: hasWon = selectedCharacters.some(char => char.id === dailyCharacter.id);
// Hint availability - indices are available after a certain number of guesses
$: isOriginAvailable = selectedCharacters.length >= 5; // Always available
$: isFruitAvailable = selectedCharacters.length >= 10; // Available after 5 guesses
$: isAffiliationAvailable = selectedCharacters.length >= 15; // Available after 10 guesses
// 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;
}
$: filteredCharacters = characters.filter(char => {
const searchTerm = searchInput.toLowerCase();
const nameMatches = char.name.toLowerCase().includes(searchTerm);
let epithetsMatches = false;
if (char.epithets) {
try {
const parsedEpithets = typeof char.epithets === 'string'
? JSON.parse(char.epithets)
: char.epithets;
if (Array.isArray(parsedEpithets)) {
epithetsMatches = parsedEpithets.some((epithet: string) =>
epithet.toLowerCase().includes(searchTerm)
);
} else if (typeof parsedEpithets === 'string') {
epithetsMatches = parsedEpithets.toLowerCase().includes(searchTerm);
}
} catch {
epithetsMatches = String(char.epithets).toLowerCase().includes(searchTerm);
}
}
return (nameMatches || epithetsMatches) &&
!selectedCharacters.some(selected => selected.id === char.id);
});
// Reset highlighted index when filtered list changes
$: if (filteredCharacters) {
highlightedIndex = 0;
}
// Scroll highlighted item into view
$: if (dropdownContainer && highlightedIndex >= 0) {
const highlightedButton = dropdownContainer.querySelector(
`button:nth-child(${highlightedIndex + 1})`
) as HTMLElement;
if (highlightedButton) {
highlightedButton.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}
}
function selectCharacter(character: any) {
selectedCharacters = [character, ...selectedCharacters];
searchInput = '';
highlightedIndex = 0;
// Check if player won
if (character.id === dailyCharacter.id) {
// Send request to record win in database
fetch('/daily', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
characterId: dailyCharacter.id
})
}).catch(err => console.error('Failed to record win:', err));
// Check if it's gecko_moria for special animation
if (dailyCharacter.id === 'gecko_moria') {
isGeckoMoriaWin = true;
}
}
}
function resetHistory() {
selectedCharacters = [];
localStorage.removeItem('dailyCharacterHistory');
}
function handleKeydown(event: KeyboardEvent) {
if (filteredCharacters.length === 0) return;
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
highlightedIndex = Math.min(highlightedIndex + 1, filteredCharacters.length - 1);
break;
case 'ArrowUp':
event.preventDefault();
highlightedIndex = Math.max(highlightedIndex - 1, 0);
break;
case 'Enter':
event.preventDefault();
if (filteredCharacters[highlightedIndex]) {
selectCharacter(filteredCharacters[highlightedIndex]);
}
break;
}
}
function submitGuess() {
if (filteredCharacters.length === 0) return;
const characterToSelect =
filteredCharacters[highlightedIndex] ?? filteredCharacters[0];
if (characterToSelect) {
selectCharacter(characterToSelect);
}
}
function formatBounty(bounty: number): string {
if (bounty >= 1_000_000_000) {
const billions = bounty / 1_000_000_000;
return `${billions}B`;
} else if (bounty >= 1_000_000) {
const millions = bounty / 1_000_000;
return `${millions}M`;
} else if (bounty >= 1_000) {
const thousands = bounty / 1_000;
return `${thousands}K`;
}
return bounty.toString();
}
</script>
<svelte:head>
<title>OnePieceDle - Mode du jour</title>
<style>
@keyframes hint-unlock {
0% {
box-shadow: 0 0 0 rgba(251, 146, 60, 0);
}
50% {
box-shadow: 0 0 20px rgba(251, 146, 60, 0.8);
}
100% {
box-shadow: 0 0 0 rgba(251, 146, 60, 0);
}
}
.hint-unlocking {
animation: hint-unlock 0.6s ease-out;
}
@keyframes shadow-pulse {
0% {
text-shadow: 0 0 20px rgba(0, 0, 0, 0.5), 0 0 40px rgba(50, 50, 50, 0.8);
opacity: 1;
}
50% {
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);
opacity: 0.9;
}
100% {
text-shadow: 0 0 20px rgba(0, 0, 0, 0.5), 0 0 40px rgba(50, 50, 50, 0.8);
opacity: 1;
}
}
@keyframes moria-chaos {
0% {
transform: rotate(0deg) scale(1);
filter: invert(0%) hue-rotate(0deg) blur(0px);
}
10% {
transform: rotate(15deg) scale(1.02);
filter: invert(30%) hue-rotate(45deg) blur(2px);
}
20% {
transform: rotate(-10deg) scale(0.98);
filter: invert(60%) hue-rotate(90deg) blur(1px);
}
30% {
transform: rotate(25deg) scale(1.05);
filter: invert(100%) hue-rotate(180deg) blur(3px);
}
40% {
transform: rotate(-20deg) scale(0.95);
filter: invert(80%) hue-rotate(270deg) blur(2px);
}
50% {
transform: rotate(30deg) scale(1.08);
filter: invert(100%) hue-rotate(0deg) blur(4px);
}
60% {
transform: rotate(-25deg) scale(0.92);
filter: invert(70%) hue-rotate(90deg) blur(2px);
}
70% {
transform: rotate(20deg) scale(1.03);
filter: invert(50%) hue-rotate(180deg) blur(3px);
}
80% {
transform: rotate(-15deg) scale(1.01);
filter: invert(80%) hue-rotate(270deg) blur(1px);
}
100% {
transform: rotate(360deg) scale(1);
filter: invert(0%) hue-rotate(360deg) blur(0px);
}
}
.gecko-moria-effect {
animation: shadow-pulse 1.5s ease-in-out infinite;
}
.moria-screen-chaos {
animation: moria-chaos 4s ease-in-out;
}
</style>
</svelte:head>
<main
class="relative min-h-screen overflow-hidden bg-slate-950 text-slate-100"
style="background-image: url('/one-piece-bg.jpg');"
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 class="absolute inset-0 mix-blend-screen opacity-20 bg-[radial-gradient(circle_at_top,rgba(255,215,84,0.35),transparent_55%)]"></div>
<div class="relative mx-auto flex min-h-screen w-full max-w-6xl flex-col px-6 py-16 sm:py-20">
<header class="flex flex-col items-start gap-6">
<div class="rounded-full border border-amber-200/30 bg-amber-100/10 px-5 py-2 text-xs font-semibold uppercase tracking-[0.35em] text-amber-100">
Mode du jour
</div>
<nav class="absolute left-6 top-6 sm:left-8 sm:top-8">
<a
href="/"
class="text-xl font-black uppercase tracking-[0.25em] text-amber-50 transition hover:text-amber-100"
>
OnePieceDle
</a>
</nav>
<header class="flex flex-col items-start gap-6 w-full">
<div class="flex w-full items-center justify-between gap-4">
<h1 class="text-3xl font-black uppercase tracking-[0.25em] text-amber-50 sm:text-5xl">
Mystere du jour
Personnage du jour
</h1>
{#if hasWon}
<button
class="rounded-full border border-amber-200/40 bg-transparent px-5 py-3 text-sm font-semibold text-amber-100 transition hover:border-amber-200 hover:text-amber-50"
onclick={resetHistory}
>
Recommencer
</button>
{/if}
</div>
<p class="max-w-2xl text-base text-slate-200 sm:text-lg">
Devine le personnage. Chaque indice deblocque une nouvelle piste.
Devine le personnage. Chaque indice se débloque après un certain nombre de tentatives. Bonne chance !
</p>
</header>
<section class="mt-10 grid gap-6">
{#if selectedCharacters.length > 0 && !hasWon}
<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">
<h2 class="text-sm font-semibold uppercase tracking-[0.2em] text-amber-100">Indices du jour</h2>
<div class="mt-4 grid gap-3 sm:grid-cols-3">
<div class="rounded-2xl border border-white/10 bg-slate-950/60 px-3 py-3">
<p class="text-xs uppercase tracking-[0.25em] text-amber-100">Indice 1</p>
<p class="mt-2 text-sm text-slate-200">Origine: ???</p>
</div>
<div class="rounded-2xl border border-white/10 bg-slate-950/60 px-3 py-3">
<p class="text-xs uppercase tracking-[0.25em] text-amber-100">Indice 2</p>
<p class="mt-2 text-sm text-slate-200">Fruit du demon: ???</p>
</div>
<div class="rounded-2xl border border-white/10 bg-slate-950/60 px-3 py-3">
<p class="text-xs uppercase tracking-[0.25em] text-amber-100">Indice 3</p>
<p class="mt-2 text-sm text-slate-200">Affiliation: ???</p>
</div>
<div class="grid gap-3 sm:grid-cols-3">
<button
type="button"
class="rounded-2xl border border-white/10 px-3 py-3 flex flex-col items-center justify-center cursor-pointer hover:bg-slate-900/80 transition-colors {isOriginAvailable ? 'bg-slate-950/60' : 'bg-slate-950/30 opacity-50 cursor-not-allowed hover:bg-slate-950/30'} {showOriginUnlock ? 'hint-unlocking' : ''}"
disabled={!isOriginAvailable}
onclick={() => showHintOrigin = !showHintOrigin}
>
<p class="text-sm font-medium text-amber-100">Origine</p>
{#if showHintOrigin}
<p class="mt-2 text-xs text-white font-semibold">{dailyCharacter.origin || 'Inconnue'}</p>
{:else if Math.max(0, 5 - selectedCharacters.length) > 0}
<p class="mt-2 text-xs text-slate-400">{Math.max(0, 5 - selectedCharacters.length)} essais avant déblocage</p>
{:else}
<p class="mt-2 text-xs text-slate-400">Indice disponible !</p>
{/if}
</button>
<button
type="button"
class="rounded-2xl border border-white/10 px-3 py-3 flex flex-col items-center justify-center cursor-pointer hover:bg-slate-900/80 transition-colors {isFruitAvailable ? 'bg-slate-950/60' : 'bg-slate-950/30 opacity-50 cursor-not-allowed hover:bg-slate-950/30'} {showFruitUnlock ? 'hint-unlocking' : ''}"
disabled={!isFruitAvailable}
onclick={() => showHintFruit = !showHintFruit}
>
<p class="text-sm font-medium text-amber-100">Fruit du démon</p>
{#if showHintFruit}
<p class="mt-2 text-xs text-white font-semibold">{dailyCharacter.devilFruitName || 'Aucun'}</p>
{:else if Math.max(0, 10 - selectedCharacters.length) > 0}
<p class="mt-2 text-xs text-slate-400">{Math.max(0, 10 - selectedCharacters.length)} essais avant déblocage</p>
{:else}
<p class="mt-2 text-xs text-slate-400">Indice disponible !</p>
{/if}
</button>
<button
type="button"
class="rounded-2xl border border-white/10 px-3 py-3 flex flex-col items-center justify-center cursor-pointer hover:bg-slate-900/80 transition-colors {isAffiliationAvailable ? 'bg-slate-950/60' : 'bg-slate-950/30 opacity-50 cursor-not-allowed hover:bg-slate-950/30'} {showAffiliationUnlock ? 'hint-unlocking' : ''}"
disabled={!isAffiliationAvailable}
onclick={() => showHintAffiliation = !showHintAffiliation}
>
<p class="text-sm font-medium text-amber-100">Affiliation</p>
{#if showHintAffiliation}
{@const affiliations = typeof dailyCharacter.affiliations === 'string'
? (dailyCharacter.affiliations.includes('[') ? JSON.parse(dailyCharacter.affiliations) : dailyCharacter.affiliations.split(',').map((a: string) => a.trim()))
: dailyCharacter.affiliations}
<p class="mt-2 text-xs text-white font-semibold">{Array.isArray(affiliations) ? affiliations[0] : affiliations || 'Inconnue'}</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)} essais avant déblocage</p>
{:else}
<p class="mt-2 text-xs text-slate-400">Indice disponible !</p>
{/if}
</button>
</div>
</div>
{/if}
<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">
{#if hasWon}
{#if isGeckoMoriaWin}
<div class="rounded-3xl border border-slate-700/80 bg-slate-950/80 p-4 shadow-[0_24px_60px_rgba(0,0,0,0.8)] backdrop-blur gecko-moria-effect">
<div class="text-center">
<div class="text-3xl mb-2">🌑</div>
<h2 class="text-xl font-bold text-slate-300 mb-1">Moria vous contrôle...</h2>
<p class="text-sm text-slate-400">Vous avez succombé à l'ombre en {selectedCharacters.length} {selectedCharacters.length > 1 ? 'tentatives' : 'tentative'} !</p>
<div class="mt-3">
{#if dailyCharacter.pictureUrl}
<a
href={"https://onepiece.fandom.com/fr/wiki/" + dailyCharacter.url}
target="_blank"
rel="noopener noreferrer"
class="inline-block"
>
<img
src={dailyCharacter.pictureUrl}
alt={dailyCharacter.name}
class="w-20 h-20 mx-auto rounded-full border-2 border-slate-600 shadow-lg object-cover hover:border-slate-500 transition-colors cursor-pointer opacity-80"
/>
</a>
{/if}
<p class="mt-2 text-lg font-bold text-slate-200">{dailyCharacter.name}</p>
</div>
</div>
</div>
{:else}
<div class="rounded-3xl border border-emerald-500/50 bg-emerald-500/10 p-4 shadow-[0_24px_60px_rgba(16,185,129,0.3)] backdrop-blur">
<div class="text-center">
<div class="text-3xl mb-2">🎉</div>
<h2 class="text-xl font-bold text-emerald-400 mb-1">Félicitations !</h2>
<p class="text-sm text-emerald-300">Vous avez trouvé le personnage en {selectedCharacters.length} {selectedCharacters.length > 1 ? 'tentatives' : 'tentative'} !</p>
<div class="mt-3">
{#if dailyCharacter.pictureUrl}
<a
href={"https://onepiece.fandom.com/fr/wiki/" + dailyCharacter.url}
target="_blank"
rel="noopener noreferrer"
class="inline-block"
>
<img
src={dailyCharacter.pictureUrl}
alt={dailyCharacter.name}
class="w-20 h-20 mx-auto rounded-full border-2 border-emerald-400 shadow-lg object-cover hover:border-emerald-300 transition-colors cursor-pointer"
/>
</a>
{/if}
<p class="mt-2 text-lg font-bold text-white">{dailyCharacter.name}</p>
</div>
</div>
</div>
{/if}
{: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 z-10">
<h2 class="text-sm font-semibold uppercase tracking-[0.2em] text-amber-100">Entrer une supposition</h2>
<div class="mt-4 flex flex-col gap-3 sm:flex-row">
<div class="relative w-full">
<input
bind:value={searchInput}
class="w-full rounded-full border border-amber-200/30 bg-slate-900/60 px-5 py-3 text-sm text-slate-100 placeholder:text-slate-400 focus:border-amber-200/70 focus:outline-none"
placeholder="Nom du personnage"
type="text"
onkeydown={handleKeydown}
/>
<button class="rounded-full bg-amber-300 px-6 py-3 text-sm font-semibold text-slate-900 transition hover:bg-amber-200">
{#if searchInput.length > 0 && filteredCharacters.length > 0}
<div bind:this={dropdownContainer} class="absolute top-full left-0 right-0 mt-2 max-h-96 overflow-y-auto rounded-2xl border border-amber-200/30 bg-slate-900/90 backdrop-blur z-10">
{#each filteredCharacters as character, index (character.id)}
<button
class="w-full px-5 py-4 text-left text-base text-slate-100 transition border-b border-slate-800/50 last:border-b-0 flex items-center gap-4 {index === highlightedIndex ? 'bg-slate-700' : 'hover:bg-slate-800/70'}"
type="button"
onmouseenter={() => highlightedIndex = index}
onclick={() => selectCharacter(character)}
>
{#if character.pictureUrl}
<img
src={character.pictureUrl}
alt={character.name}
class="w-12 h-12 rounded-full object-cover border border-amber-200/30"
/>
{:else}
<div class="w-12 h-12 rounded-full bg-slate-800 border border-amber-200/30 flex items-center justify-center">
<span class="text-xs text-slate-400">?</span>
</div>
{/if}
<div class="flex-1">
<span class="font-semibold text-amber-100">{character.name}</span>
{#if character.epithets}
{@const parsedEpithets = typeof character.epithets === 'string'
? JSON.parse(character.epithets)
: character.epithets}
{#if Array.isArray(parsedEpithets) && parsedEpithets.length > 0}
<span class="ml-2 text-xs text-slate-400">
{parsedEpithets.join(', ')}
</span>
{/if}
{/if}
</div>
</button>
{/each}
</div>
{/if}
</div>
<button
type="button"
onclick={submitGuess}
disabled={filteredCharacters.length === 0}
class="rounded-full bg-amber-300 px-6 py-3 text-sm font-semibold text-slate-900 transition hover:bg-amber-200 disabled:cursor-not-allowed disabled:opacity-50"
>
Valider
</button>
</div>
</div>
{/if}
</section>
<section class="mt-8 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="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<div class="flex flex-col gap-4">
<div class="flex flex-col items-center gap-4 text-center">
<p class="text-xs font-semibold uppercase tracking-[0.28em] text-amber-100">Historique</p>
<p class="mt-2 text-sm text-slate-200">Aucune tentative pour le moment.</p>
</div>
<button class="rounded-full border border-amber-200/40 bg-transparent px-5 py-3 text-sm font-semibold text-amber-100 transition hover:border-amber-200 hover:text-amber-50">
Recommencer
</button>
{#if selectedCharacters.length === 0}
<p class="text-sm text-slate-200 text-center">Aucune tentative pour le moment.</p>
{:else}
<div class="overflow-x-auto pb-2 -mx-6 px-6 sm:mx-0 sm:px-0">
<div class="w-max min-w-max mx-auto">
<!-- Header -->
<div class="flex gap-2 mb-2">
<div class="w-24 shrink-0 rounded-lg border border-amber-200/30 bg-amber-900/30 p-2 text-center flex items-center justify-center">
<p class="text-xs font-semibold uppercase tracking-wider text-amber-100">Personnage</p>
</div>
{#if columnVisibility.status !== false}
<div class="w-24 shrink-0 rounded-lg border border-amber-200/30 bg-amber-900/30 p-2 text-center flex items-center justify-center">
<p class="text-xs font-semibold uppercase tracking-wider text-amber-100">Statut</p>
</div>
{/if}
{#if columnVisibility.gender !== false}
<div class="w-24 shrink-0 rounded-lg border border-amber-200/30 bg-amber-900/30 p-2 text-center flex items-center justify-center">
<p class="text-xs font-semibold uppercase tracking-wider text-amber-100">Genre</p>
</div>
{/if}
{#if columnVisibility.affiliations !== false}
<div class="w-24 shrink-0 rounded-lg border border-amber-200/30 bg-amber-900/30 p-2 text-center flex items-center justify-center">
<p class="text-xs font-semibold uppercase tracking-wider text-amber-100">Affiliations</p>
</div>
{/if}
{#if columnVisibility.haki !== false}
<div class="w-24 shrink-0 rounded-lg border border-amber-200/30 bg-amber-900/30 p-2 text-center flex items-center justify-center">
<p class="text-xs font-semibold uppercase tracking-wider text-amber-100">Haki</p>
</div>
{/if}
{#if columnVisibility.bounty !== false}
<div class="w-24 shrink-0 rounded-lg border border-amber-200/30 bg-amber-900/30 p-2 text-center flex items-center justify-center">
<p class="text-xs font-semibold uppercase tracking-wider text-amber-100">Prime</p>
</div>
{/if}
{#if columnVisibility.height !== false}
<div class="w-24 shrink-0 rounded-lg border border-amber-200/30 bg-amber-900/30 p-2 text-center flex items-center justify-center">
<p class="text-xs font-semibold uppercase tracking-wider text-amber-100">Taille</p>
</div>
{/if}
{#if columnVisibility.origin !== false}
<div class="w-24 shrink-0 rounded-lg border border-amber-200/30 bg-amber-900/30 p-2 text-center flex items-center justify-center">
<p class="text-xs font-semibold uppercase tracking-wider text-amber-100">Origine</p>
</div>
{/if}
{#if columnVisibility.arc !== false}
<div class="w-24 shrink-0 rounded-lg border border-amber-200/30 bg-amber-900/30 p-2 text-center flex items-center justify-center">
<p class="text-xs font-semibold uppercase tracking-wider text-amber-100">Arc</p>
</div>
{/if}
{#if columnVisibility.devilFruitType !== false}
<div class="w-24 shrink-0 rounded-lg border border-amber-200/30 bg-amber-900/30 p-2 text-center flex items-center justify-center">
<p class="text-xs font-semibold uppercase tracking-wider text-amber-100">Fruit</p>
</div>
{/if}
</div>
<!-- Rows -->
{#each selectedCharacters as character (character.id)}
<div class="flex gap-2 mb-2">
<!-- Personnage -->
<div class="w-24 h-24 shrink-0 rounded-lg border border-white/10 bg-slate-950/60 overflow-hidden">
{#if character.pictureUrl}
<a
href={"https://onepiece.fandom.com/fr/wiki/" + character.url}
target="_blank"
rel="noopener noreferrer"
class="block w-full h-full"
>
<img
src={character.pictureUrl}
alt={character.name}
class="w-full h-full object-cover hover:opacity-80 transition-opacity cursor-pointer"
/>
</a>
{:else}
<div class="w-full h-full bg-slate-800 flex items-center justify-center p-2">
<span class="text-xl text-center font-semibold line-clamp-3">{character.name}</span>
</div>
{/if}
</div>
<!-- Vivant / Mort -->
{#if columnVisibility.status !== false}
<div class="w-24 h-24 shrink-0 rounded-lg border border-white/10 {character.status === dailyCharacter.status ? 'bg-emerald-600/90' : 'bg-red-900/60'} p-2 flex items-center justify-center">
<p class="text-sm font-bold text-white text-center">
{character.status === 'Alive' ? 'Vivant' : character.status === 'Deceased' || character.status === 'Dead' ? 'Mort' : character.status || 'Inconnu'}
</p>
</div>
{/if}
<!-- Genre -->
{#if columnVisibility.gender !== false}
<div class="w-24 h-24 shrink-0 rounded-lg border border-white/10 {character.gender === dailyCharacter.gender ? 'bg-emerald-600/90' : 'bg-red-900/60'} p-2 flex items-center justify-center">
<p class="text-base font-bold text-white text-center">
{character.gender === 'Male' ? 'Homme' : character.gender === 'Female' ? 'Femme' : character.gender || 'Inconnu'}
</p>
</div>
{/if}
<!-- Affiliations -->
{#if columnVisibility.affiliations !== false}
<div class="w-24 h-24 shrink-0 rounded-lg border border-white/10 {(() => {
try {
const charAff = typeof character.affiliations === 'string'
? ((character.affiliations as string).includes('[') ? JSON.parse(character.affiliations) : (character.affiliations as string).split(',').map((a: string) => a.trim()))
: character.affiliations;
const dailyAff = 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;
const charFirstAff = Array.isArray(charAff) ? charAff[0] : charAff;
const dailyFirstAff = Array.isArray(dailyAff) ? dailyAff[0] : dailyAff;
return charFirstAff && dailyFirstAff && charFirstAff === dailyFirstAff ? 'bg-emerald-600/90' : 'bg-red-900/60';
} catch (e) {
return 'bg-slate-950/60';
}
})()} p-2 flex items-center justify-center overflow-hidden">
{#if character.affiliations}
{@const parsedAffiliations = typeof character.affiliations === 'string'
? (character.affiliations.includes('[') ? JSON.parse(character.affiliations) : character.affiliations.split(',').map((a: string) => a.trim()))
: character.affiliations}
{#if Array.isArray(parsedAffiliations) && parsedAffiliations.length > 0}
<p class="w-full text-sm font-bold text-white text-center whitespace-normal break-words leading-tight">{parsedAffiliations[0]}</p>
{:else}
<p class="w-full text-sm font-bold text-white text-center whitespace-normal break-words leading-tight">{parsedAffiliations}</p>
{/if}
{:else}
<p class="text-base font-bold text-slate-400 text-center">-</p>
{/if}
</div>
{/if}
<!-- Haki -->
{#if columnVisibility.haki !== false}
<div class="w-24 h-24 shrink-0 rounded-lg border border-white/10 {(() => {
if (character.hakiObservation === dailyCharacter.hakiObservation && character.hakiArmament === dailyCharacter.hakiArmament && character.hakiConqueror === dailyCharacter.hakiConqueror) {
return 'bg-emerald-600/90';
} else if ((character.hakiObservation && dailyCharacter.hakiObservation) ||
(character.hakiArmament && dailyCharacter.hakiArmament) ||
(character.hakiConqueror && dailyCharacter.hakiConqueror)) {
return 'bg-yellow-600/80';
} else {
return 'bg-red-900/60';
}
})()} p-2 flex items-center justify-center">
<p class="text-2xl font-bold text-white text-center">
{#if character.hakiObservation}<span title="Haki de l'Observation">👁️</span>{/if}
{#if character.hakiArmament}<span title="Haki de l'Armement">🦾</span>{/if}
{#if character.hakiConqueror}<span title="Haki des Rois">👑</span>{/if}
{#if !character.hakiObservation && !character.hakiArmament && !character.hakiConqueror}
<span class="text-5xl"></span>
{/if}
</p>
</div>
{/if}
<!-- Prime -->
{#if columnVisibility.bounty !== false}
<div class="w-24 h-24 shrink-0 rounded-lg border border-white/10 {character.bounty === dailyCharacter.bounty ? 'bg-emerald-600/90' : 'bg-red-900/60'} p-2 flex items-center justify-center relative overflow-hidden">
{#if character.bounty != null && dailyCharacter.bounty != null && character.bounty !== dailyCharacter.bounty}
<div class="absolute w-full h-full opacity-30 pointer-events-none" style="
background-color: rgb(203, 213, 225);
clip-path: {character.bounty > dailyCharacter.bounty
? '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.bounty != null}
<p class="text-sm font-bold text-white text-center relative z-10">{formatBounty(character.bounty)} ฿</p>
{:else}
<p class="text-sm font-bold text-white text-center relative z-10">Inconnue</p>
{/if}
</div>
{/if}
<!-- Taille -->
{#if columnVisibility.height !== false}
<div class="w-24 h-24 shrink-0 rounded-lg border border-white/10 {character.height === dailyCharacter.height ? 'bg-emerald-600/90' : 'bg-red-900/60'} p-2 flex items-center justify-center relative overflow-hidden">
{#if character.height && dailyCharacter.height && character.height !== dailyCharacter.height}
<div class="absolute w-full h-full opacity-30 pointer-events-none" style="
background-color: rgb(203, 213, 225);
clip-path: {character.height > dailyCharacter.height
? '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.height}
<p class="text-sm font-bold text-white text-center relative z-10">{character.height} m</p>
{:else}
<p class="text-sm font-bold text-white text-center relative z-10">Inconnue</p>
{/if}
</div>
{/if}
<!-- Origine -->
{#if columnVisibility.origin !== false}
<div class="w-24 h-24 shrink-0 rounded-lg border border-white/10 {character.origin === dailyCharacter.origin ? 'bg-emerald-600/90' : 'bg-red-900/60'} p-2 flex items-center justify-center">
<p class="text-sm font-bold text-white text-center">{character.origin || 'Inconnue'}</p>
</div>
{/if}
<!-- Arc -->
{#if columnVisibility.arc !== false}
<div class="w-24 h-24 shrink-0 rounded-lg border border-white/10 {character.arcName === dailyCharacter.arcName ? 'bg-emerald-600/90' : 'bg-red-900/60'} p-2 flex items-center justify-center relative overflow-hidden">
{#if character.arcName !== dailyCharacter.arcName && character.firstAppearance && dailyCharacter.firstAppearance && character.firstAppearance !== dailyCharacter.firstAppearance}
<div class="absolute w-full h-full opacity-30 pointer-events-none" style="
background-color: rgb(203, 213, 225);
clip-path: {character.firstAppearance > dailyCharacter.firstAppearance
? '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}
<p class="text-sm font-bold text-white text-center relative z-10">{character.arcName || 'Inconnu'}</p>
</div>
{/if}
<!-- Fruit -->
{#if columnVisibility.devilFruitType !== false}
<div class="w-24 h-24 shrink-0 rounded-lg border border-white/10 {character.devilFruitType === dailyCharacter.devilFruitType ? 'bg-emerald-600/90' : 'bg-red-900/60'} p-2 flex items-center justify-center">
{#if character.devilFruitType}
<p class="text-sm font-bold text-white text-center">{character.devilFruitType}</p>
{:else}
<p class="text-5xl font-bold text-white text-center"></p>
{/if}
</div>
{/if}
</div>
{/each}
</div>
</div>
{/if}
</div>
</section>
<section class="mt-8 rounded-3xl border border-white/10 bg-white/5 p-6 shadow-[0_24px_60px_rgba(0,0,0,0.45)] backdrop-blur">
{#if yesterdayCharacter}
<div class="flex flex-col items-center gap-5 text-center sm:flex-row sm:text-left">
{#if yesterdayCharacter.pictureUrl}
<img
src={yesterdayCharacter.pictureUrl}
alt={yesterdayCharacter.name}
class="h-20 w-20 rounded-full border border-amber-200/40 object-cover"
/>
{:else}
<div class="flex h-20 w-20 items-center justify-center rounded-full border border-amber-200/40 bg-slate-900/70 text-xs uppercase tracking-[0.25em] text-amber-100">
Photo
</div>
{/if}
<div class="flex-1">
<p class="text-xs font-semibold uppercase tracking-[0.28em] text-amber-100">Personnage d'hier</p>
<p class="mt-2 text-lg font-semibold text-white">{yesterdayCharacter.name}</p>
{#if yesterdayCharacter.epithets}
<p class="mt-1 text-sm text-slate-400">
{typeof yesterdayCharacter.epithets === 'string'
? JSON.parse(yesterdayCharacter.epithets).join(', ')
: (yesterdayCharacter.epithets as string[]).join(', ')}
</p>
{/if}
</div>
<a
href={"https://onepiece.fandom.com/fr/wiki/" + yesterdayCharacter.url}
target="_blank"
rel="noopener noreferrer"
class="w-full rounded-full border border-amber-200/40 bg-transparent px-5 py-3 text-sm font-semibold text-amber-100 transition hover:border-amber-200 hover:text-amber-50 sm:w-auto"
>
Voir la page
</a>
</div>
{:else}
<div class="flex flex-col items-center gap-5 text-center sm:flex-row sm:text-left">
<div class="flex h-20 w-20 items-center justify-center rounded-full border border-amber-200/40 bg-slate-900/70 text-xs uppercase tracking-[0.25em] text-amber-100">
Photo
</div>
<div class="flex-1">
<p class="text-xs font-semibold uppercase tracking-[0.28em] text-amber-100">Personnage d'hier</p>
<p class="mt-2 text-lg font-semibold text-white">Placeholder</p>
<p class="mt-1 text-sm text-slate-200">Revele apres validation de ta tentative.</p>
<p class="mt-2 text-lg font-semibold text-white">Aucun personnage</p>
<p class="mt-1 text-sm text-slate-200">Aucun personnage d'hier disponible</p>
</div>
<button class="w-full rounded-full border border-amber-200/40 bg-transparent px-5 py-3 text-sm font-semibold text-amber-100 transition hover:border-amber-200 hover:text-amber-50 sm:w-auto">
Voir l'archive
</button>
</div>
{/if}
</section>
</div>
</main>

View File

@@ -0,0 +1,33 @@
import { json } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { characterHistory } from '$lib/server/db/schema';
import { eq } from 'drizzle-orm';
import { sql } from 'drizzle-orm';
export async function POST({ request }) {
try {
const { characterId } = await request.json();
if (!characterId) {
return json({ error: 'Missing characterId' }, { status: 400 });
}
const today = new Date();
today.setHours(0, 0, 0, 0);
const todayDate = today.toISOString().split('T')[0];
// Increment the won counter for today's entry
await db
.update(characterHistory)
.set({
won: sql`${characterHistory.won} + 1`,
updatedAt: Date.now()
})
.where(eq(characterHistory.date, todayDate));
return json({ success: true });
} catch (error) {
console.error('Error recording win:', error);
return json({ error: 'Failed to record win' }, { status: 500 });
}
}