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:
13
.dockerignore
Normal file
13
.dockerignore
Normal 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
1
.gitignore
vendored
@@ -11,6 +11,7 @@ node_modules
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
/.vscode
|
||||
|
||||
# Env
|
||||
.env
|
||||
|
||||
26
Dockerfile
Normal file
26
Dockerfile
Normal 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"]
|
||||
130
SCRAPER.md
130
SCRAPER.md
@@ -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
9
docker-entrypoint.sh
Normal 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
|
||||
|
||||
@@ -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
|
||||
@@ -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": {
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "6",
|
||||
"when": 1772148571269,
|
||||
"tag": "0000_dapper_sage",
|
||||
"when": 1772325597983,
|
||||
"tag": "0000_graceful_master_mold",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
145
scripts/daily-characters.json
Normal file
145
scripts/daily-characters.json
Normal 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
406
scripts/import-json.ts
Normal 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);
|
||||
});
|
||||
@@ -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);
|
||||
64
scripts/init-column-config.ts
Normal file
64
scripts/init-column-config.ts
Normal 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
84
scripts/set-daily-mode.ts
Normal 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);
|
||||
});
|
||||
@@ -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 });
|
||||
|
||||
|
||||
152
src/lib/server/daily-character.ts
Normal file
152
src/lib/server/daily-character.ts
Normal 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);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
9
src/routes/+page.server.ts
Normal file
9
src/routes/+page.server.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { getYesterdayCharacter } from '$lib/server/daily-character';
|
||||
|
||||
export async function load() {
|
||||
const yesterdayCharacter = await getYesterdayCharacter();
|
||||
|
||||
return {
|
||||
yesterdayCharacter: yesterdayCharacter || null
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
38
src/routes/daily/+page.server.ts
Normal file
38
src/routes/daily/+page.server.ts
Normal 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
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
<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">
|
||||
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>
|
||||
<h1 class="text-3xl font-black uppercase tracking-[0.25em] text-amber-50 sm:text-5xl">
|
||||
Mystere du jour
|
||||
</h1>
|
||||
<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">
|
||||
<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>
|
||||
{#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">
|
||||
<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>
|
||||
</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">
|
||||
<input
|
||||
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"
|
||||
/>
|
||||
<button class="rounded-full bg-amber-300 px-6 py-3 text-sm font-semibold text-slate-900 transition hover:bg-amber-200">
|
||||
<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}
|
||||
/>
|
||||
{#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>
|
||||
|
||||
33
src/routes/daily/+server.ts
Normal file
33
src/routes/daily/+server.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user