Compare commits

...

30 Commits

Author SHA1 Message Date
ef6bf9862e feat: add FriendsTodaySection component for displaying friends' results
All checks were successful
Build Docker Image / build (push) Successful in 2m4s
2026-04-14 22:08:49 +02:00
d75c74ac3c Refactor character affiliations to singular form
- Updated character data structure to replace 'affiliations' and 'frAffiliations' with 'affiliation' and 'frAffiliation'.
- Modified related functions and components to accommodate the new structure.
- Adjusted database schema and server-side logic to reflect the changes in character affiliation handling.
- Ensured all references in the UI components and data import/export scripts are updated accordingly.
2026-04-14 21:56:26 +02:00
fa14156d82 feat: remove overrides
Some checks failed
Build Docker Image / build (push) Has been cancelled
2026-04-12 02:01:01 +02:00
29297d3773 fix: include 'Four Emperors' in character exclusion logic
All checks were successful
Build Docker Image / build (push) Successful in 1m12s
2026-03-18 22:42:32 +01:00
28bb8f526b fix: include Kuja in female character categorization
All checks were successful
Build Docker Image / build (push) Successful in 1m13s
2026-03-18 22:40:39 +01:00
288271fb04 fix: include Queens Regnant in female character categorization
All checks were successful
Build Docker Image / build (push) Successful in 1m12s
2026-03-18 22:22:54 +01:00
fb64c84a17 fix: include Gorgon Sisters in female character categorization
All checks were successful
Build Docker Image / build (push) Successful in 1m11s
2026-03-18 22:19:46 +01:00
81e205dd4e fix: expand gender categorization in character fetch logic
All checks were successful
Build Docker Image / build (push) Successful in 1m23s
2026-03-18 22:07:28 +01:00
ded1c8313d fix: update character link URLs to remove language prefix
All checks were successful
Build Docker Image / build (push) Successful in 1m11s
2026-03-16 23:15:02 +01:00
4426b5d28a fix: correct href interpolation for character links in admin page
Some checks failed
Build Docker Image / build (push) Has been cancelled
2026-03-16 23:14:21 +01:00
5ad0428420 feat: enhance character scrape validation and management
All checks were successful
Build Docker Image / build (push) Successful in 1m14s
- Added a new entry for "fuzzy_talisman" in the journal.
- Updated import-json script to handle character deletion and mark absent characters as deleted in the scrape validation.
- Modified schema to include an `isDeleted` field in the characterScrapeValidation table.
- Renamed function `upsertCharacterFromScrapeValidation` to `applyCharacterChangeFromScrapeValidation` for clarity.
- Enhanced character change loading to include deleted characters and updated UI to display them.
- Improved character change handling in the Svelte component to reflect new, modified, and deleted states.
2026-03-16 23:12:06 +01:00
7760570365 feat: exclude characters with 'family' in their name from fetch results
All checks were successful
Build Docker Image / build (push) Successful in 1m14s
2026-03-16 22:31:40 +01:00
5fde54a2a7 feat: add age filter functionality and localization support in guess history
All checks were successful
Build Docker Image / build (push) Successful in 1m12s
2026-03-16 22:05:09 +01:00
2a3c82f777 feat: add age attribute to character history and localization support
All checks were successful
Build Docker Image / build (push) Successful in 1m13s
2026-03-16 22:00:49 +01:00
835163f5bb feat: add tried characters tracking and display in daily game profile
All checks were successful
Build Docker Image / build (push) Successful in 1m10s
2026-03-16 21:39:44 +01:00
5020393b22 fix: normalize character status text to lowercase for consistency
All checks were successful
Build Docker Image / build (push) Successful in 1m22s
2026-03-16 21:17:01 +01:00
94393851c8 feat: support French epithets in character extraction logic
All checks were successful
Build Docker Image / build (push) Successful in 1m14s
2026-03-15 22:44:45 +01:00
997b2f1781 feat: add French localization support for character attributes and improve character display logic
All checks were successful
Build Docker Image / build (push) Successful in 1m18s
- Added optional French names, affiliations, origins, and epithets to character records.
- Updated character import logic to handle new French fields.
- Enhanced character search and display components to show French names and epithets based on selected language.
- Modified database schema to include French fields for characters.
- Improved error handling in daily character setup to check for existing characters.
- Refactored components to utilize helper functions for displaying names and attributes based on language.
2026-03-15 22:00:19 +01:00
bd121b7d85 feat(i18n): integrate internationalization for game pages
- Added translation support for various game-related texts in the home, daily, infinite, login, and profile pages.
- Replaced hardcoded French strings with translation keys using the `$t` function.
- Updated titles, descriptions, and button texts to enhance localization.
2026-03-15 20:19:26 +01:00
6d2dccd47f refactor: update link generation to use resolve for consistent path handling 2026-03-15 19:53:47 +01:00
5fdde9d177 refactor: remove unused fandomUrl and getDifferenceColor functions, simplify character link generation 2026-03-14 18:34:03 +01:00
b1cc691422 refactor: enhance character data transformation and improve fetching logic in character-related scripts 2026-03-14 18:32:43 +01:00
8b08950719 refactor: streamline character selection and improve rendering logic in +page.svelte 2026-03-14 17:29:57 +01:00
fd83ac911a refactor: improve type definitions for selectedCharacter and selectedCharacters in WinPanel component 2026-03-14 17:22:44 +01:00
eeccf812cf refactor: improve type definitions for dailyCharacter and selectedCharacters in HintsPanel component 2026-03-14 17:20:26 +01:00
9485d9841c refactor: improve type definitions and event handling in CharacterSearchInput component 2026-03-14 17:18:58 +01:00
31308ef126 refactor: enhance affiliation handling and improve type definitions in GuessHistoryTable component 2026-03-14 17:13:13 +01:00
57a0427e77 refactor: improve bounty extraction logic and enhance character selection in infinite mode 2026-03-14 17:09:33 +01:00
3bd2506c2f refactor: enhance type safety and improve layout structure in GuessHistoryTable component 2026-03-14 16:33:43 +01:00
e5a21cb0af refactor: improve type definitions and enhance state management in profile and daily components 2026-03-14 16:31:16 +01:00
46 changed files with 6661 additions and 1394 deletions

View File

@@ -14,6 +14,7 @@ CREATE TABLE `character` (
`gender` text,
`age` integer,
`affiliations` text,
`fr_affiliations` text,
`devil_fruit_id` text,
`haki_observation` integer DEFAULT false,
`haki_armament` integer DEFAULT false,
@@ -52,6 +53,7 @@ CREATE TABLE `character_override` (
`gender` text,
`age` integer,
`affiliations` text,
`fr_affiliations` text,
`devil_fruit_id` text,
`haki_observation` integer,
`haki_armament` integer,
@@ -59,9 +61,11 @@ CREATE TABLE `character_override` (
`bounty` integer,
`height` real,
`origin` text,
`fr_origin` text,
`first_appearance` integer,
`picture_url` text,
`epithets` text,
`fr_epithets` text,
`status` text,
`arc_id` text,
`url` text,
@@ -79,6 +83,7 @@ CREATE TABLE `character_scrape_validation` (
`gender` text,
`age` integer,
`affiliations` text,
`fr_affiliations` text,
`devil_fruit_id` text,
`haki_observation` integer DEFAULT false,
`haki_armament` integer DEFAULT false,

View File

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

View File

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

View File

@@ -0,0 +1,8 @@
ALTER TABLE `character` ADD `affiliation` text;--> statement-breakpoint
ALTER TABLE `character` ADD `fr_affiliation` text;--> statement-breakpoint
ALTER TABLE `character` DROP COLUMN `affiliations`;--> statement-breakpoint
ALTER TABLE `character` DROP COLUMN `fr_affiliations`;--> statement-breakpoint
ALTER TABLE `character_scrape_validation` ADD `affiliation` text;--> statement-breakpoint
ALTER TABLE `character_scrape_validation` ADD `fr_affiliation` text;--> statement-breakpoint
ALTER TABLE `character_scrape_validation` DROP COLUMN `affiliations`;--> statement-breakpoint
ALTER TABLE `character_scrape_validation` DROP COLUMN `fr_affiliations`;

View File

@@ -1,7 +1,7 @@
{
"version": "6",
"dialect": "sqlite",
"id": "8ffd14bd-bf33-410f-9778-92bc1abc8938",
"id": "4b4f14a1-b37b-44f4-aed3-7289bd8cb6a0",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"arc": {
@@ -101,6 +101,13 @@
"notNull": false,
"autoincrement": false
},
"fr_affiliations": {
"name": "fr_affiliations",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"devil_fruit_id": {
"name": "devil_fruit_id",
"type": "text",
@@ -372,6 +379,13 @@
"notNull": false,
"autoincrement": false
},
"fr_affiliations": {
"name": "fr_affiliations",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"devil_fruit_id": {
"name": "devil_fruit_id",
"type": "text",
@@ -421,6 +435,13 @@
"notNull": false,
"autoincrement": false
},
"fr_origin": {
"name": "fr_origin",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"first_appearance": {
"name": "first_appearance",
"type": "integer",
@@ -442,6 +463,13 @@
"notNull": false,
"autoincrement": false
},
"fr_epithets": {
"name": "fr_epithets",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
@@ -569,6 +597,13 @@
"notNull": false,
"autoincrement": false
},
"fr_affiliations": {
"name": "fr_affiliations",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"devil_fruit_id": {
"name": "devil_fruit_id",
"type": "text",

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -5,8 +5,29 @@
{
"idx": 0,
"version": "6",
"when": 1773447741334,
"tag": "0000_keen_rockslide",
"when": 1773602933375,
"tag": "0000_huge_doctor_octopus",
"breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1773697753818,
"tag": "0001_fuzzy_talisman",
"breakpoints": true
},
{
"idx": 2,
"version": "6",
"when": 1775950314114,
"tag": "0002_old_earthquake",
"breakpoints": true
},
{
"idx": 3,
"version": "6",
"when": 1776195681488,
"tag": "0003_mixed_ben_grimm",
"breakpoints": true
}
]

View File

@@ -1,144 +1,255 @@
[
"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",
"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"
"absalom_absalom",
"king_king",
"alvida_alvida",
"aramaki_aramaki",
"arlong_arlong",
"ashura_doji_ashura_doji",
"vegapunk/atlas_atlas",
"avalo_pizarro_avalo_pizarro",
"baby_5_baby_5",
"buggy_buggy",
"bartholomew_kuma_bartholomew_kuma",
"bartolomeo_bartolomeo",
"basil_hawkins_basil_hawkins",
"bell-mère_bell-mère",
"bellamy_bellamy",
"belo_betty_belo_betty",
"benn_beckman_ben_beckman",
"bentham_bentham",
"bepo_bepo",
"black_maria_black_maria",
"blueno_blueno",
"boa_hancock_boa_hancock",
"boa_marigold_boa_marigold",
"boa_sandersonia_boa_sandersonia",
"borsalino_borsalino",
"brogy_brogy",
"brook_brook",
"buckingham_stussy_buckingham_stussy",
"buffalo_buffalo",
"camie_camie",
"capone_bege_capone_bege",
"carmel_carmel",
"caribou_caribou",
"carrot_carrot",
"catarina_devon_catarina_devon",
"cavendish_cavendish",
"caesar_clown_caesar_clown",
"charlotte_brûlée_charlotte_brûlée",
"charlotte_cracker_charlotte_cracker",
"charlotte_katakuri_charlotte_katakuri",
"charlotte_linlin_charlotte_linlin",
"charlotte_mont-d'or_charlotte_mont-d'or",
"charlotte_oven_charlotte_oven",
"charlotte_perospero_charlotte_perospero",
"charlotte_pudding_charlotte_pudding",
"charlotte_smoothie_charlotte_smoothie",
"chinjao_chinjao",
"clou_d_clover_clou_d_clover",
"crocodile_crocodile",
"crocus_crocus",
"curly_dadan_curly_dadan",
"dalton_dalton",
"daz_bonez_daz_bonez",
"denjiro_denjiro",
"diamante_diamante",
"doc_q_doc_q",
"donquixote_doflamingo_donquixote_doflamingo",
"donquixote_rosinante_donquixote_rosinante",
"dorry_dorry",
"dracule_mihawk_dracule_mihawk",
"vegapunk/edison_edison",
"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",
"fukaboshi_fukaboshi",
"fukurou_fukurou",
"galdino_galdino",
"gan_fall_gan_fall",
"gecko_moria_gecko_moria",
"gem_gem",
"genzo_genzo",
"gin_gin",
"ginny_ginny",
"gol_d_roger_gol_d_roger",
"guernika_guernika",
"hack_hack",
"jaguar_d_saul_jaguar_d_saul",
"hajrudin_hajrudin",
"hannyabal_hannyabal",
"harald_harald",
"haredas_haredas",
"heracles_heracles",
"helmeppo_helmeppo",
"hibari_hibari",
"hiriluk_hiriluk",
"hina_hina",
"hody_jones_hody_jones",
"hogback_hogback",
"hyougoro_hyougoro",
"iceburg_iceburg",
"igaram_igaram",
"imu_imu",
"inazuma_inazuma",
"inuarashi_inuarashi",
"issho_issho",
"izou_izou",
"jabra_jabra",
"jack_jack",
"jango_jango",
"jesus_burgess_jesus_burgess",
"jewelry_bonney_jewelry_bonney",
"jinbe_jinbe",
"giolla_giolla",
"joy_boy_joy_boy",
"jozu_jozu",
"kaidou_kaidou",
"kaku_kaku",
"kalgara_kalgara",
"kalifa_kalifa",
"karasu_karasu",
"karoo_karoo",
"kawamatsu_kawamatsu",
"kaya_kaya",
"kelly_funk_kelly_funk",
"kikunojo_kikunojo",
"killer_killer",
"kin'emon_kin'emon",
"koala_koala",
"koby_koby",
"kokoro_kokoro",
"kouzuki_hiyori_kouzuki_hiyori",
"kouzuki_momonosuke_kouzuki_momonosuke",
"kouzuki_oden_kouzuki_oden",
"kouzuki_sukiyaki_kouzuki_sukiyaki",
"kouzuki_toki_kouzuki_toki",
"krieg_krieg",
"kumadori_kumadori",
"kureha_kureha",
"kuro_kuro",
"kurozumi_kanjuro_kurozumi_kanjuro",
"kurozumi_orochi_kurozumi_orochi",
"kurozumi_tama_kurozumi_tama",
"kuzan_kuzan",
"kyros_kyros",
"laboon_laboon",
"laffitte_laffitte",
"lao_g_lao_g",
"leo_leo",
"vegapunk/lilith_lilith",
"lindbergh_lindbergh",
"loki_loki",
"lucky_roux_lucky_roux",
"magellan_magellan",
"makino_makino",
"mansherry_mansherry",
"marco_marco",
"marshall_d_teach_marshall_d_teach",
"merry_merry",
"momoo_momoo",
"mocha_mocha",
"monet_monet",
"monkey_d_dragon_monkey_d_dragon",
"monkey_d_garp_monkey_d_garp",
"monkey_d_luffy_monkey_d_luffy",
"mont_blanc_cricket_mont_blanc_cricket",
"mont_blanc_noland_mont_blanc_noland",
"morgans_morgans",
"morgan_morgan",
"morley_morley",
"nami_nami",
"nefertari_cobra_nefertari_cobra",
"nefertari_vivi_nefertari_vivi",
"nekomamushi_nekomamushi",
"neptune_neptune",
"nico_olvia_nico_olvia",
"nico_robin_nico_robin",
"nojiko_nojiko",
"hatchan_hatchan",
"otohime_otohime",
"oars_oars",
"page_one_page_one",
"pandaman_pandaman",
"paulie_paulie",
"pedro_pedro",
"pekoms_pekoms",
"pell_pell",
"perona_perona",
"pica_pica",
"portgas_d_ace_portgas_d_ace",
"vegapunk/pythagoras_pythagoras",
"queen_queen",
"raizo_raizo",
"rebecca_rebecca",
"riku_doldo_iii_riku_doldo_iii",
"rob_lucci_rob_lucci",
"rocks_d_xebec_rocks_d_xebec",
"roronoa_zoro_roronoa_zoro",
"s-bear_s-bear",
"s-hawk_s-hawk",
"s-snake_s-snake",
"sabo_sabo",
"sadi_sadi",
"donquixote_mjosgard_donquixote_mjosgard",
"rimoshifu_killingham_rimoshifu_killingham",
"manmayer_gunko_manmayer_gunko",
"shepherd_sommers_shepherd_sommers",
"sakazuki_sakazuki",
"sanjuan_wolf_sanjuan_wolf",
"sasaki_sasaki",
"scratchmen_apoo_scratchmen_apoo",
"sengoku_sengoku",
"senor_pink_senor_pink",
"sentomaru_sentomaru",
"vegapunk/shaka_shaka",
"shakuyaku_shakuyaku",
"shanks_shanks",
"shiryu_shiryu",
"shimotsuki_kuina_shimotsuki_kuina",
"shimotsuki_yasuie_shimotsuki_yasuie",
"shinobu_shinobu",
"shirahoshi_shirahoshi",
"silvers_rayleigh_silvers_rayleigh",
"smoker_smoker",
"spandam_spandam",
"speed_speed",
"stussy_stussy",
"sugar_sugar",
"tamago_tamago",
"tashigi_tashigi",
"toko_toko",
"tom_tom",
"tony_tony_chopper_tony_tony_chopper",
"trafalgar_d_water_law_trafalgar_d_water_law",
"trebol_trebol",
"tsuru_tsuru",
"ulti_ulti",
"urouge_urouge",
"usopp_usopp",
"uta_uta",
"van_augur_van_augur",
"vander_decken_ix_vander_decken_ix",
"vegapunk_vegapunk",
"vergo_vergo",
"vinsmoke_ichiji_vinsmoke_ichiji",
"vinsmoke_judge_vinsmoke_judge",
"vinsmoke_niji_vinsmoke_niji",
"vinsmoke_reiju_vinsmoke_reiju",
"sanji_sanji",
"vinsmoke_yonji_vinsmoke_yonji",
"viola_viola",
"wadatsumi_wadatsumi",
"wapol_wapol",
"wyper_wyper",
"x_drake_x_drake",
"yamato_yamato",
"yasopp_yasopp",
"vegapunk/york_york",
"zeff_zeff"
]

View File

@@ -1,6 +1,6 @@
import { createClient } from '@libsql/client';
import { drizzle } from 'drizzle-orm/libsql';
import { sql, eq } from 'drizzle-orm';
import { sql, eq, inArray } from 'drizzle-orm';
import fs from 'fs';
import { arc, character, devilFruit, characterScrapeValidation, type DevilFruitType } from '../src/lib/server/db/schema';
@@ -9,6 +9,7 @@ type Status = 'Alive' | 'Dead' | 'Unknown';
type ArcRecord = {
id: string;
name: string;
frName?: string | null;
startChapter: number;
endChapter?: number | null;
url?: string | null;
@@ -27,8 +28,8 @@ type CharacterRecord = {
frName?: string | null;
gender?: string | null;
age?: number | null;
affiliations?: string[] | string | null;
frAffiliations?: string[] | string | null;
affiliation?: string | null;
frAffiliation?: string | null;
devilFruitId?: string | null;
hakiObservation?: boolean;
hakiArmament?: boolean;
@@ -119,10 +120,11 @@ function transformCharacterData(item: CharacterRecord) {
return {
id: item.id,
name: item.name,
frName: toNullable(item.frName),
gender: toNullable(item.gender),
age: toNullable(item.age),
affiliations: toJsonArray(item.affiliations),
frAffiliations: toJsonArray(item.frAffiliations),
affiliation: toNullable(item.affiliation),
frAffiliation: toNullable(item.frAffiliation),
devilFruitId: toNullable(item.devilFruitId),
hakiObservation: !!item.hakiObservation,
hakiArmament: !!item.hakiArmament,
@@ -137,7 +139,9 @@ function transformCharacterData(item: CharacterRecord) {
frEpithets: toJsonArray(item.frEpithets),
status: toNullable(item.status),
arcId: toNullable(item.arcId),
url: toNullable(item.url)
url: toNullable(item.url),
frUrl: toNullable(item.frUrl),
isDeleted: false
};
}
@@ -168,6 +172,7 @@ async function importFromJson(): Promise<void> {
.values({
id: item.id,
name: item.name,
frName: toNullable(item.frName),
startChapter: item.startChapter,
endChapter: toNullable(item.endChapter),
url: toNullable(item.url)
@@ -176,6 +181,7 @@ async function importFromJson(): Promise<void> {
target: arc.id,
set: {
name: item.name,
frName: toNullable(item.frName),
startChapter: item.startChapter,
endChapter: toNullable(item.endChapter),
url: toNullable(item.url)
@@ -302,6 +308,7 @@ async function importFromJson(): Promise<void> {
} else {
// Update scrapeValidation table
console.log('Characters table not empty, updating scrapeValidation table for changes...\n');
const scrapedCharacterIds: string[] = [];
for (let i = 0; i < characters.length; i++) {
const item = characters[i];
@@ -314,6 +321,7 @@ async function importFromJson(): Promise<void> {
lastSql = selectQuery.toSQL();
scrapedCharacterIds.push(item.id);
const jsonData = transformCharacterData(item);
const upsertQuery = db
@@ -336,6 +344,57 @@ async function importFromJson(): Promise<void> {
logSqlOnError(lastSql);
}
}
// Fetch all characters from the character table and mark those absent from the
// scrape as deleted in scrape validation.
const allCharacters = await db.select({ id: character.id }).from(character);
const scrapedSet = new Set(scrapedCharacterIds);
const idsToMarkDeleted = allCharacters
.map((c) => c.id)
.filter((id) => !scrapedSet.has(id));
if (idsToMarkDeleted.length > 0) {
console.log(`\n⚠ Marking ${idsToMarkDeleted.length} character(s) as deleted in scrape validation...`);
const deletedCharacterRows = await db
.select()
.from(character)
.where(inArray(character.id, idsToMarkDeleted));
for (const row of deletedCharacterRows) {
await db
.insert(characterScrapeValidation)
.values({
id: row.id,
name: row.name,
frName: row.frName,
gender: row.gender,
age: row.age,
affiliation: row.affiliation,
frAffiliation: row.frAffiliation,
devilFruitId: row.devilFruitId,
hakiObservation: row.hakiObservation,
hakiArmament: row.hakiArmament,
hakiConqueror: row.hakiConqueror,
bounty: row.bounty,
height: row.height,
origin: row.origin,
frOrigin: row.frOrigin,
firstAppearance: row.firstAppearance,
pictureUrl: row.pictureUrl,
epithets: row.epithets,
frEpithets: row.frEpithets,
status: row.status,
arcId: row.arcId,
url: row.url,
frUrl: row.frUrl,
isDeleted: true
})
.onConflictDoUpdate({
target: characterScrapeValidation.id,
set: { isDeleted: true }
});
}
}
}
console.log(`\n\n✓ Characters imported!`);

View File

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

View File

@@ -23,8 +23,8 @@ interface Character {
frOrigin: string | null;
devilFruitId: string | null;
devilFruitUrl: string | null;
affiliations: string[];
frAffiliations: string[] | null;
affiliation: string | null;
frAffiliation: string | null;
bounty: number | null;
hakiObservation: boolean;
hakiArmament: boolean;
@@ -276,13 +276,12 @@ async function saveArcsToCSV(arcs: Arc[]): Promise<void> {
}
/**
* Fetch all cannon characters from One Piece fandom using API
* Fetch all cannon characters from One Piece fandom, including their full data.
*/
async function fetchAllCharactersUrl(): Promise<CharacterListItem[]> {
async function fetchAllCharacters(arcsList: Arc[]): Promise<Character[]> {
try {
const apiUrl = `${FANDOM_API_BASE}List_of_Canon_Characters`;
console.log('Fetching character list via API...');
const response = await fetchWithRetry(apiUrl);
const response = await fetchWithRetry(`${FANDOM_API_BASE}List_of_Canon_Characters`);
const jsonData = await response.json();
// Extract HTML from API response
@@ -292,7 +291,7 @@ async function fetchAllCharactersUrl(): Promise<CharacterListItem[]> {
}
const $ = cheerio.load(htmlContent);
const characters: CharacterListItem[] = [];
const characterList: CharacterListItem[] = [];
$('table.fandom-table tbody tr').each((index, element) => {
if (index === 0) return; // Skip header row
let charUrl = $(element).find('td:nth-child(2) a').attr('href');
@@ -304,27 +303,103 @@ async function fetchAllCharactersUrl(): Promise<CharacterListItem[]> {
charChapter = charChapter.replace(/\D/g, '');
// If charChapter is empty, skip the character as it means they don't have a proper page and are just mentioned in the list
if (!charChapter) {
if (!charChapter || parseInt(charChapter, 10) === 0) {
return;
}
if (parseInt(charChapter, 10) === 0) {
if (charName.toLowerCase().includes('family') || charName === 'Four Emperors') {
return;
}
if (charUrl) {
charUrl = charUrl.replace('/wiki/', '');
characters.push({
characterList.push({
name: charName,
url: charUrl,
chapter: parseInt(charChapter, 10)
});
}
});
console.log(`Found ${characters.length} characters.`);
if (characterList.length === 0) {
console.error('No characters found.');
return [];
}
console.log(`Found ${characterList.length} characters.`);
// Fetch the french character list to get the picture URLs
console.log('Fetching French character list via API...');
const frResponse = await fetchWithRetry(`${FR_FANDOM_API_BASE}Liste_des_Personnages_Canon`);
const frJsonData = await frResponse.json();
// Create a map of character name to picture URL from the French list
const frHtmlContent = frJsonData.parse?.text?.['*'];
const fr$ = cheerio.load(frHtmlContent);
const frCharacterPictureMap: Record<string, string> = {};
fr$('table.wikitable tbody tr').each((index, element) => {
if (index === 0) return; // Skip header row
const charName = fr$(element).find('td:nth-child(2) a').text().trim();
const pictureUrl = fr$(element).find('td:nth-child(1) img').attr('data-src') || fr$(element).find('td:nth-child(1) img').attr('src') || null;
if (charName && pictureUrl) {
frCharacterPictureMap[charName] = pictureUrl;
}
});
const characters: Character[] = [];
let failedCharacters: CharacterListItem[] = [...characterList];
while (failedCharacters.length > 0) {
const nextFailedCharacters: CharacterListItem[] = [];
console.log(`\nFetching ${failedCharacters.length} characters...`);
for (let i = 0; i < failedCharacters.length; i += FETCH_CONCURRENCY) {
const batch = failedCharacters.slice(i, i + FETCH_CONCURRENCY);
const batchResults = await Promise.all(
batch.map(async (char) => {
const data = await fetchCharacter(char.url, char.name, char.chapter, arcsList, frCharacterPictureMap);
return { char, data };
})
);
for (const { char, data } of batchResults) {
if (data) {
console.table({
ID: data.id,
Name: data.name,
Gender: data.gender,
Age: data.age,
Status: data.status,
Epithets: data.epithets.join(', '),
Affiliation: data.affiliation,
DevilFruitId: data.devilFruitId,
DevilFruitUrl: data.devilFruitUrl,
HakiObservation: data.hakiObservation ? 'Yes' : 'No',
HakiArmament: data.hakiArmament ? 'Yes' : 'No',
HakiConqueror: data.hakiConqueror ? 'Yes' : 'No',
Height: data.height,
Bounty: data.bounty,
Origin: data.origin,
FirstAppearance: data.firstAppearance,
pictureUrl: data.pictureUrl,
FandomURL: data.url
});
characters.push(data);
} else {
nextFailedCharacters.push(char);
}
}
}
failedCharacters = nextFailedCharacters;
if (failedCharacters.length > 0) {
console.log(`⚠️ ${failedCharacters.length} characters failed. Retrying...`);
}
}
console.log(`\n✓ Scraped ${characters.length} characters\n`);
return characters;
} catch (error) {
console.error('Error fetching character list:', (error as Error).message);
console.error('Error fetching characters:', (error as Error).message);
return [];
}
}
@@ -336,7 +411,8 @@ async function fetchCharacter(
characterUrl: string,
characterName: string,
characterChapter: number,
arcsList: Arc[]
arcsList: Arc[],
frCharacterPictureMap: Record<string, string>
): Promise<Character | null> {
try {
console.log(`Fetching: ${characterName}...`);
@@ -365,10 +441,10 @@ async function fetchCharacter(
let gender: string | null = null;
for (const cat of categories) {
const catName = cat['*'] || '';
if (catName === 'Male_Characters') {
if (catName === 'Male_Characters' || catName === 'Kings' || catName === 'Princes' || catName === 'Former_Kings' || catName === 'Former_Princes') {
gender = 'Male';
break;
} else if (catName === 'Female_Characters') {
} else if (catName === 'Female_Characters' || catName === 'Queens' || catName === 'Princesses' || catName === 'Former_Queens' || catName === 'Former_Princesses' || catName === 'Queens_Regnant' || catName === 'Gorgon_Sisters' || catName === 'Kuja') {
gender = 'Female';
break;
}
@@ -377,8 +453,8 @@ async function fetchCharacter(
// Extract age
const age = extractAge($);
// Extract affiliations
const affiliations = await extractAffiliations($, 'en');
// Extract affiliation
const affiliation = await extractAffiliations($, 'en');
// Extract epithets
const epithets = extractEpithets($);
@@ -437,7 +513,7 @@ async function fetchCharacter(
let frName = frjsonData?.parse?.title || null;
const frAffiliations = frjsonData
const frAffiliation = frjsonData
? await extractAffiliations(cheerio.load(frjsonData.parse?.text?.['*'] || ''), 'fr')
: null;
@@ -453,6 +529,8 @@ async function fetchCharacter(
frName = name;
}
const pictureUrl = frCharacterPictureMap[frName || ''] || null;
return {
id: finalCharacterId,
name,
@@ -464,8 +542,8 @@ async function fetchCharacter(
frOrigin,
devilFruitId,
devilFruitUrl,
affiliations,
frAffiliations,
affiliation,
frAffiliation,
bounty,
hakiObservation,
hakiArmament,
@@ -475,7 +553,7 @@ async function fetchCharacter(
firstAppearance,
arcId,
status,
pictureUrl: 'Image_Non_Disponible',
pictureUrl,
url: characterUrl,
frUrl
};
@@ -513,15 +591,15 @@ function extractAge($: cheerio.CheerioAPI): number | null {
/**
* Extract affiliations from infobox
*/
async function extractAffiliations($: cheerio.CheerioAPI, lang: string): Promise<string[]> {
async function extractAffiliations($: cheerio.CheerioAPI, lang: string): Promise<string | null> {
const div = $('[data-source="affiliation"] .pi-data-value');
if (div.length === 0) return [];
if (div.length === 0) return null;
const cleanedDiv = div.clone();
cleanedDiv.find('sup').remove();
const text = cleanedDiv.html();
if (!text) return [];
if (!text) return null;
// Resolve affiliations from linked page titles.
const links = cleanedDiv.find('a').toArray();
@@ -546,14 +624,14 @@ async function extractAffiliations($: cheerio.CheerioAPI, lang: string): Promise
const uniqueLinks = Array.from(new Set(linkValues.filter(Boolean)));
if (uniqueLinks.length > 0) {
return uniqueLinks;
return uniqueLinks[0];
}
}
// Fallback to parsing text
const cleanText = text.replace(/<[^>]*>/g, '').trim();
const parts = cleanText.split(/\s*\n\s*|\s*;\s*|\s*,\s*/).filter(Boolean);
return parts.length > 0 ? parts : [];
return parts.length > 0 ? parts[0] : null;
}
/**
@@ -561,7 +639,9 @@ async function extractAffiliations($: cheerio.CheerioAPI, lang: string): Promise
* Handles both quoted and unquoted epithets, keeping only the main/latest readable values.
*/
function extractEpithets($: cheerio.CheerioAPI): string[] {
const div = $('[data-source="epithet"] .pi-data-value');
const div = $(
'[data-source="epithet"] .pi-data-value, [data-source="épithète"] .pi-data-value'
).first();
if (div.length === 0) return [];
const cleanedDiv = div.clone();
@@ -629,29 +709,22 @@ function extractBounty($: cheerio.CheerioAPI): number | null {
const div = $('[data-source="bounty"] .pi-data-value');
if (div.length === 0) return 0;
let text = div.html();
const cleanedDiv = div.clone();
// Drop references and old crossed-out bounty values.
cleanedDiv.find('sup, s, del, strike').remove();
const text = cleanedDiv.text().replace(/\s+/g, ' ').trim();
if (!text) return 0;
// Remove all sup blocks (citations)
text = text.replace(/<sup[^>]*>.*?<\/sup>/gi, '');
// Parse the first amount token (e.g. "3,189,000,000"), which is the active bounty.
const amountMatch = text.match(/\d{1,3}(?:[\s,.']\d{3})+|\d+/);
if (!amountMatch) return 0;
// Extract the first value before any <br> tag
const firstValue = text.split('<br')[0].trim();
let cleanText = firstValue.replace(/<[^>]*>/g, '').trim();
const digits = amountMatch[0].replace(/\D/g, '');
if (!digits) return 0;
// Check if cleanText contains digits
if (!/\d/.test(cleanText)) {
// If no digits, try second value after <br>
const secondValue = text.split('<br>')[1];
if (secondValue) {
cleanText = secondValue.replace(/<[^>]*>/g, '').trim();
}
}
// Remove all non-digits
cleanText = cleanText.replace(/\D/g, '');
return cleanText ? parseInt(cleanText) : 0;
const value = Number(digits);
return Number.isFinite(value) ? value : 0;
}
/**
@@ -735,11 +808,11 @@ function extractStatus($: cheerio.CheerioAPI): string | null {
const statusText = div.text().trim().toLowerCase();
if (statusText.includes('Alive')) {
if (statusText.includes('alive')) {
return 'Alive';
} else if (statusText.includes('Dead')) {
} else if (statusText.includes('deceased')) {
return 'Dead';
} else if (statusText.includes('Unknown')) {
} else if (statusText.includes('unknown')) {
return 'Unknown';
}
@@ -796,9 +869,8 @@ async function saveToCSV(characters: Character[]): Promise<void> {
status: c.status || '',
epithets: Array.isArray(c.epithets) ? c.epithets.join(', ') : c.epithets || '',
devilFruitId: c.devilFruitId || '',
affiliations: Array.isArray(c.affiliations)
? c.affiliations.join(', ')
: c.affiliations || '',
affiliation: c.affiliation || '',
frAffiliation: c.frAffiliation || '',
bounty: c.bounty ?? 0,
hakiObservation: c.hakiObservation ? 1 : 0,
hakiArmament: c.hakiArmament ? 1 : 0,
@@ -941,72 +1013,17 @@ async function main(): Promise<void> {
}
// Step 2: Scraping Characters
console.log('=== Step 1: Scraping Characters ===\n');
const characterList = await fetchAllCharactersUrl();
console.log('=== Step 2: Scraping Characters ===\n');
const characters = await fetchAllCharacters(arcsList);
if (characterList.length === 0) {
if (characters.length === 0) {
console.error('No characters found. Exiting.');
return;
}
const characters: Character[] = [];
const devilFruitUrls = new Set<string>();
let failedCharacters: CharacterListItem[] = [...characterList];
while (failedCharacters.length > 0) {
const nextFailedCharacters: CharacterListItem[] = [];
console.log(`\nFetching ${failedCharacters.length} characters...`);
for (let i = 0; i < failedCharacters.length; i += FETCH_CONCURRENCY) {
const batch = failedCharacters.slice(i, i + FETCH_CONCURRENCY);
const batchResults = await Promise.all(
batch.map(async (char) => {
const data = await fetchCharacter(char.url, char.name, char.chapter, arcsList);
return { char, data };
})
);
for (const { char, data } of batchResults) {
if (data) {
console.table({
ID: data.id,
Name: data.name,
Gender: data.gender,
Age: data.age,
Status: data.status,
Epithets: data.epithets.join(', '),
Affiliations: data.affiliations.join(', '),
DevilFruitId: data.devilFruitId,
DevilFruitUrl: data.devilFruitUrl,
HakiObservation: data.hakiObservation ? 'Yes' : 'No',
HakiArmament: data.hakiArmament ? 'Yes' : 'No',
HakiConqueror: data.hakiConqueror ? 'Yes' : 'No',
Height: data.height,
Bounty: data.bounty,
Origin: data.origin,
FirstAppearance: data.firstAppearance,
pictureUrl: data.pictureUrl,
FandomURL: data.url
});
if (data.devilFruitUrl) {
devilFruitUrls.add(data.devilFruitUrl);
}
characters.push(data);
} else {
nextFailedCharacters.push(char);
}
}
}
failedCharacters = nextFailedCharacters;
if (failedCharacters.length > 0) {
console.log(`⚠️ ${failedCharacters.length} characters failed. Retrying...`);
}
}
console.log(`\n✓ Scraped ${characters.length} characters\n`);
const devilFruitUrls = new Set<string>(
characters.filter((c) => c.devilFruitUrl).map((c) => c.devilFruitUrl!)
);
console.log(`✓ Found ${devilFruitUrls.size} unique devil fruits\n`);
// Step 3: Scraping Devil Fruits

View File

@@ -1,6 +1,6 @@
import { createClient } from '@libsql/client';
import { drizzle } from 'drizzle-orm/libsql';
import { eq } from 'drizzle-orm';
import { eq, inArray } from 'drizzle-orm';
import fs from 'fs';
import { character, characterHistory } from '../src/lib/server/db/schema';
@@ -24,13 +24,14 @@ function getErrorMessage(error: unknown): string {
async function setDailyCharacters(): Promise<void> {
try {
const dailyCharacterIds = readJsonFile('./scripts/daily-characters.json');
const dailyCharacterIdsRaw = readJsonFile('./scripts/daily-characters.json');
if (!dailyCharacterIds || dailyCharacterIds.length === 0) {
console.error('No daily characters found in daily-characters.json');
process.exit(1);
if (!dailyCharacterIdsRaw || dailyCharacterIdsRaw.length === 0) {
throw new Error('No daily characters found in daily-characters.json');
}
const dailyCharacterIds = dailyCharacterIdsRaw;
console.log(`\n=== Setting Daily Mode Characters ===\n`);
console.log(`Found ${dailyCharacterIds.length} characters to set as daily\n`);
@@ -45,16 +46,36 @@ async function setDailyCharacters(): Promise<void> {
let successCount = 0;
let errorCount = 0;
const existingCharacters = await db
.select({ id: character.id })
.from(character)
.where(inArray(character.id, dailyCharacterIds));
const existingIdSet = new Set(existingCharacters.map((c) => c.id));
const missingIds = dailyCharacterIds.filter((id) => !existingIdSet.has(id));
if (missingIds.length > 0) {
errorCount += missingIds.length;
console.error(`${missingIds.length} character ID(s) were not found in database:`);
for (const missingId of missingIds) {
console.error(` - ${missingId}`);
}
console.error('');
}
for (let i = 0; i < dailyCharacterIds.length; i++) {
const charId = dailyCharacterIds[i];
if (!existingIdSet.has(charId)) {
continue;
}
try {
const result = await db
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}:`);

View File

@@ -1,16 +1,64 @@
<script lang="ts">
import type { CharacterWithRelations } from '$lib/server/daily-character';
import { onMount } from 'svelte';
import { createEventDispatcher } from 'svelte';
import { language, t } from '$lib/i18n';
export let characters: any[];
export let selectedCharacters: any[];
let {
characters,
selectedCharacters,
onSelect
}: {
characters: CharacterWithRelations[];
selectedCharacters: CharacterWithRelations[];
onSelect: (character: CharacterWithRelations) => void;
} = $props();
const dispatch = createEventDispatcher();
const state = $state({
searchInput: '',
highlightedIndex: 0,
dropdownContainer: null as HTMLDivElement | null,
searchContainer: null as HTMLDivElement | null
});
let searchInput = '';
let highlightedIndex = 0;
let dropdownContainer: HTMLDivElement;
let searchContainer: HTMLDivElement;
const isFrench = $derived($language === 'fr');
function parseEpithets(value: unknown): string[] {
if (Array.isArray(value)) {
return value.filter((entry): entry is string => typeof entry === 'string' && entry.length > 0);
}
if (typeof value === 'string') {
try {
const parsed = JSON.parse(value);
if (Array.isArray(parsed)) {
return parsed.filter((entry): entry is string => typeof entry === 'string' && entry.length > 0);
}
} catch {
if (value.length > 0) {
return [value];
}
}
}
return [];
}
function getDisplayName(character: CharacterWithRelations): string {
if (isFrench && typeof character.frName === 'string' && character.frName.length > 0) {
return character.frName;
}
return character.name;
}
function getDisplayEpithets(character: CharacterWithRelations): string[] {
const frenchEpithets = parseEpithets(character.frEpithets);
if (isFrench && frenchEpithets.length > 0) {
return frenchEpithets;
}
return parseEpithets(character.epithets);
}
function normalizeSearchText(value: string): string {
return value
@@ -28,52 +76,54 @@
};
});
$: filteredCharacters = characters.filter(char => {
const searchTerm = normalizeSearchText(searchInput);
const nameMatches = normalizeSearchText(char.name).includes(searchTerm);
const filteredCharacters = $derived.by(() => {
const searchTerm = normalizeSearchText(state.searchInput);
let epithetsMatches = false;
if (char.epithets) {
try {
const parsedEpithets = typeof char.epithets === 'string'
? JSON.parse(char.epithets)
: char.epithets;
return characters.filter((char) => {
const displayName = getDisplayName(char);
const displayEpithets = getDisplayEpithets(char);
const nameMatches = normalizeSearchText(displayName).includes(searchTerm);
const epithetsMatches = displayEpithets.some((epithet) =>
normalizeSearchText(epithet).includes(searchTerm)
);
if (Array.isArray(parsedEpithets)) {
epithetsMatches = parsedEpithets.some((epithet: string) =>
normalizeSearchText(epithet).includes(searchTerm)
);
} else if (typeof parsedEpithets === 'string') {
epithetsMatches = normalizeSearchText(parsedEpithets).includes(searchTerm);
}
} catch {
epithetsMatches = normalizeSearchText(String(char.epithets)).includes(searchTerm);
}
}
return (nameMatches || epithetsMatches) &&
!selectedCharacters.some(selected => selected.id === char.id);
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' });
// Reset highlighted index when filtered list changes.
$effect(() => {
const nextFilteredCharacters = filteredCharacters;
if (!nextFilteredCharacters) {
return;
}
}
state.highlightedIndex = 0;
});
function selectCharacter(character: any) {
dispatch('select', character);
searchInput = '';
highlightedIndex = 0;
// Scroll highlighted item into view.
$effect(() => {
const nextFilteredCharacters = filteredCharacters;
if (!state.dropdownContainer || state.highlightedIndex < 0) {
return;
}
if (state.highlightedIndex >= nextFilteredCharacters.length) {
return;
}
const highlightedButton = state.dropdownContainer.querySelector(
`button:nth-child(${state.highlightedIndex + 1})`
) as HTMLElement | null;
highlightedButton?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
});
function selectCharacter(character: CharacterWithRelations) {
onSelect(character);
state.searchInput = '';
state.highlightedIndex = 0;
}
function handleKeydown(event: KeyboardEvent) {
@@ -82,16 +132,19 @@
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
highlightedIndex = Math.min(highlightedIndex + 1, filteredCharacters.length - 1);
state.highlightedIndex = Math.min(
state.highlightedIndex + 1,
filteredCharacters.length - 1
);
break;
case 'ArrowUp':
event.preventDefault();
highlightedIndex = Math.max(highlightedIndex - 1, 0);
state.highlightedIndex = Math.max(state.highlightedIndex - 1, 0);
break;
case 'Enter':
event.preventDefault();
if (filteredCharacters[highlightedIndex]) {
selectCharacter(filteredCharacters[highlightedIndex]);
if (filteredCharacters[state.highlightedIndex]) {
selectCharacter(filteredCharacters[state.highlightedIndex]);
}
break;
}
@@ -99,44 +152,43 @@
function submitGuess() {
if (filteredCharacters.length === 0) return;
const characterToSelect =
filteredCharacters[highlightedIndex] ?? filteredCharacters[0];
const characterToSelect = filteredCharacters[state.highlightedIndex] ?? filteredCharacters[0];
if (characterToSelect) {
selectCharacter(characterToSelect);
}
}
function handleClickOutside(event: MouseEvent) {
if (searchContainer && !searchContainer.contains(event.target as Node)) {
searchInput = '';
if (state.searchContainer && !state.searchContainer.contains(event.target as Node)) {
state.searchInput = '';
}
}
</script>
<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>
<h2 class="text-sm font-semibold uppercase tracking-[0.2em] text-amber-100">{$t.game.components.searchInput.title}</h2>
<div class="mt-4 flex flex-col gap-3 sm:flex-row">
<div bind:this={searchContainer} class="relative w-full">
<div bind:this={state.searchContainer} class="relative w-full">
<input
bind:value={searchInput}
bind:value={state.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"
placeholder={$t.game.components.searchInput.placeholder}
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">
{#if state.searchInput.length > 0 && filteredCharacters.length > 0}
<div bind:this={state.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'}"
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 === state.highlightedIndex ? 'bg-slate-700' : 'hover:bg-slate-800/70'}"
type="button"
onmouseenter={() => highlightedIndex = index}
onmouseenter={() => (state.highlightedIndex = index)}
onclick={() => selectCharacter(character)}
>
{#if character.pictureUrl}
<img
src={character.pictureUrl}
alt={character.name}
alt={getDisplayName(character)}
loading="lazy"
class="w-12 h-12 rounded-full object-cover border border-amber-200/30"
/>
@@ -146,16 +198,11 @@
</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="font-semibold text-amber-100">{getDisplayName(character)}</span>
{#if getDisplayEpithets(character).length > 0}
<span class="ml-2 text-xs text-slate-400">
{parsedEpithets.join(', ')}
{getDisplayEpithets(character).join(', ')}
</span>
{/if}
{/if}
</div>
</button>
@@ -166,10 +213,10 @@
<button
type="button"
onclick={submitGuess}
disabled={searchInput.length === 0 || filteredCharacters.length === 0}
disabled={state.searchInput.length === 0 || 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
{$t.game.components.searchInput.submit}
</button>
</div>
</div>

View File

@@ -0,0 +1,73 @@
<script lang="ts">
import { t } from '$lib/i18n';
type TriedCharacter = {
id: string;
name: string;
pictureUrl: string | null;
};
type FriendTodayResult = {
userId: string;
name: string;
image: string | null;
tryCount: number;
triedCharacters: TriedCharacter[];
};
export let friendsTodayResults: FriendTodayResult[] = [];
</script>
{#if friendsTodayResults.length > 0}
<section class="mt-6 rounded-3xl border border-white/10 bg-white/5 p-6 shadow-[0_24px_60px_rgba(0,0,0,0.45)] backdrop-blur">
<p class="text-xs font-semibold uppercase tracking-[0.28em] text-amber-100 text-center">{$t.game.daily.friendsToday}</p>
<div class="mt-4 space-y-2">
{#each friendsTodayResults as friendResult (friendResult.userId)}
<div class="rounded-lg border border-white/10 bg-slate-950/50 px-4 py-3">
<div class="flex items-center justify-between gap-4">
<div class="flex items-center gap-3">
{#if friendResult.image}
<img
src={friendResult.image}
alt={friendResult.name}
class="h-8 w-8 rounded-full border border-white/20 object-cover"
/>
{:else}
<div class="flex h-8 w-8 items-center justify-center rounded-full bg-amber-300/20 text-xs font-semibold text-amber-100">
{friendResult.name?.charAt(0).toUpperCase() || 'U'}
</div>
{/if}
<p class="text-sm font-semibold text-slate-100">{friendResult.name}</p>
</div>
<p class="text-sm text-amber-300">
{friendResult.tryCount} {friendResult.tryCount > 1 ? $t.game.daily.friendTryPlural : $t.game.daily.friendTrySingular}
</p>
</div>
<div class="mt-3 border-t border-white/10 pt-2">
<p class="text-[11px] font-semibold uppercase tracking-[0.2em] text-amber-100">
{$t.game.daily.friendsTriedCharacters}
</p>
{#if friendResult.triedCharacters && friendResult.triedCharacters.length > 0}
<div class="mt-2 flex flex-wrap gap-2">
{#each friendResult.triedCharacters as triedCharacter (triedCharacter.id)}
<span class="inline-flex items-center gap-2 rounded-full border border-white/15 bg-white/5 px-2.5 py-1 text-xs text-slate-200">
{#if triedCharacter.pictureUrl}
<img
src={triedCharacter.pictureUrl}
alt={triedCharacter.name}
class="h-4 w-4 rounded-full object-cover"
/>
{/if}
{triedCharacter.name}
</span>
{/each}
</div>
{:else}
<p class="mt-1 text-xs text-slate-500">{$t.game.daily.friendsNoTriedCharacters}</p>
{/if}
</div>
</div>
{/each}
</div>
</section>
{/if}

View File

@@ -1,257 +1,493 @@
<script lang="ts">
import { formatBounty } from '$lib';
import type { CharacterWithRelations } from '$lib/server/daily-character';
import { language, t } from '$lib/i18n';
export let selectedCharacters: any[];
export let dailyCharacter: any;
export let columnVisibility: any;
export let selectedCharacters: CharacterWithRelations[];
export let dailyCharacter: CharacterWithRelations;
export let columnVisibility: {
status?: boolean;
gender?: boolean;
affiliation?: boolean;
devilFruitType?: boolean;
haki?: boolean;
bounty?: boolean;
height?: boolean;
age?: boolean;
origin?: boolean;
arc?: boolean;
};
$: isFrench = $language === 'fr';
function getDisplayName(character: CharacterWithRelations): string {
if (isFrench && typeof character.frName === 'string' && character.frName.length > 0) {
return character.frName;
}
return character.name;
}
function getWikiUrl(character: CharacterWithRelations): string {
if (isFrench && typeof character.frUrl === 'string' && character.frUrl.length > 0) {
return character.frUrl;
}
return character.url || '';
}
function getWikiBaseUrl(): string {
return isFrench ? 'https://onepiece.fandom.com/fr/wiki/' : 'https://onepiece.fandom.com/wiki/';
}
function getDisplayOrigin(character: CharacterWithRelations): string | null {
if (isFrench && typeof character.frOrigin === 'string' && character.frOrigin.length > 0) {
return character.frOrigin;
}
return character.origin;
}
function hasMatchingOrigin(characterEntry: CharacterWithRelations, dailyEntry: CharacterWithRelations): boolean {
return getDisplayOrigin(characterEntry) === getDisplayOrigin(dailyEntry);
}
function getDisplayArcName(character: CharacterWithRelations): string | null {
if (isFrench && typeof character.frArcName === 'string' && character.frArcName.length > 0) {
return character.frArcName;
}
return character.arcName;
}
function getDislayAffiliation(character: CharacterWithRelations): string | null {
if (isFrench && typeof character.frAffiliation === 'string' && character.frAffiliation.length > 0) {
return character.frAffiliation;
}
return character.affiliation;
}
function hasMatchingArc(characterEntry: CharacterWithRelations, dailyEntry: CharacterWithRelations): boolean {
return getDisplayArcName(characterEntry) === getDisplayArcName(dailyEntry);
}
</script>
<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">
<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">
<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="text-xs font-semibold tracking-[0.28em] text-amber-100 uppercase">{$t.game.components.guessHistory.title}</p>
</div>
{#if selectedCharacters.length === 0}
<p class="text-sm text-slate-200 text-center">Aucune tentative pour le moment.</p>
<p class="text-center text-sm text-slate-200">{$t.game.components.guessHistory.empty}</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">
<div class="-mx-6 overflow-x-auto px-6 pb-2 sm:mx-0 sm:px-0">
<div class="mx-auto w-max min-w-max">
<!-- Header -->
<div class="flex gap-1 sm:gap-2 mb-2">
<div class="w-16 sm:w-20 md:w-24 shrink-0 rounded-lg border border-amber-200/30 bg-amber-900/30 p-1 sm:p-2 text-center flex items-center justify-center">
<p class="text-[9px] sm:text-xs font-semibold uppercase tracking-wider text-amber-100">Personnage</p>
<div class="mb-2 flex gap-1 sm:gap-2">
<div
class="flex w-16 shrink-0 items-center justify-center rounded-lg border border-amber-200/30 bg-amber-900/30 p-1 text-center sm:w-20 sm:p-2 md:w-24"
>
<p
class="text-[9px] font-semibold tracking-wider text-amber-100 uppercase sm:text-xs"
>
{$t.game.components.guessHistory.character}
</p>
</div>
{#if columnVisibility.status !== false}
<div class="w-16 sm:w-20 md:w-24 shrink-0 rounded-lg border border-amber-200/30 bg-amber-900/30 p-1 sm:p-2 text-center flex items-center justify-center">
<p class="text-[9px] sm:text-xs font-semibold uppercase tracking-wider text-amber-100">Statut</p>
</div>
<div
class="flex w-16 shrink-0 items-center justify-center rounded-lg border border-amber-200/30 bg-amber-900/30 p-1 text-center sm:w-20 sm:p-2 md:w-24"
>
<p
class="text-[9px] font-semibold tracking-wider text-amber-100 uppercase sm:text-xs"
>
{$t.game.components.guessHistory.status}
</p>
</div>
{/if}
{#if columnVisibility.gender !== false}
<div class="w-16 sm:w-20 md:w-24 shrink-0 rounded-lg border border-amber-200/30 bg-amber-900/30 p-1 sm:p-2 text-center flex items-center justify-center">
<p class="text-[9px] sm:text-xs font-semibold uppercase tracking-wider text-amber-100">Genre</p>
</div>
<div
class="flex w-16 shrink-0 items-center justify-center rounded-lg border border-amber-200/30 bg-amber-900/30 p-1 text-center sm:w-20 sm:p-2 md:w-24"
>
<p
class="text-[9px] font-semibold tracking-wider text-amber-100 uppercase sm:text-xs"
>
{$t.game.components.guessHistory.gender}
</p>
</div>
{/if}
{#if columnVisibility.affiliations !== false}
<div class="w-16 sm:w-20 md:w-24 shrink-0 rounded-lg border border-amber-200/30 bg-amber-900/30 p-1 sm:p-2 text-center flex items-center justify-center">
<p class="text-[9px] sm:text-xs font-semibold uppercase tracking-wider text-amber-100">Affiliations</p>
</div>
{#if columnVisibility.affiliation !== false}
<div
class="flex w-16 shrink-0 items-center justify-center rounded-lg border border-amber-200/30 bg-amber-900/30 p-1 text-center sm:w-20 sm:p-2 md:w-24"
>
<p
class="text-[9px] font-semibold tracking-wider text-amber-100 uppercase sm:text-xs"
>
{$t.game.components.guessHistory.affiliations}
</p>
</div>
{/if}
{#if columnVisibility.devilFruitType !== false}
<div class="w-16 sm:w-20 md:w-24 shrink-0 rounded-lg border border-amber-200/30 bg-amber-900/30 p-1 sm:p-2 text-center flex items-center justify-center">
<p class="text-[9px] sm:text-xs font-semibold uppercase tracking-wider text-amber-100">Fruit</p>
</div>
<div
class="flex w-16 shrink-0 items-center justify-center rounded-lg border border-amber-200/30 bg-amber-900/30 p-1 text-center sm:w-20 sm:p-2 md:w-24"
>
<p
class="text-[9px] font-semibold tracking-wider text-amber-100 uppercase sm:text-xs"
>
{$t.game.components.guessHistory.fruit}
</p>
</div>
{/if}
{#if columnVisibility.haki !== false}
<div class="w-16 sm:w-20 md:w-24 shrink-0 rounded-lg border border-amber-200/30 bg-amber-900/30 p-1 sm:p-2 text-center flex items-center justify-center">
<p class="text-[9px] sm:text-xs font-semibold uppercase tracking-wider text-amber-100">Haki</p>
</div>
<div
class="flex w-16 shrink-0 items-center justify-center rounded-lg border border-amber-200/30 bg-amber-900/30 p-1 text-center sm:w-20 sm:p-2 md:w-24"
>
<p
class="text-[9px] font-semibold tracking-wider text-amber-100 uppercase sm:text-xs"
>
{$t.game.components.guessHistory.haki}
</p>
</div>
{/if}
{#if columnVisibility.bounty !== false}
<div class="w-16 sm:w-20 md:w-24 shrink-0 rounded-lg border border-amber-200/30 bg-amber-900/30 p-1 sm:p-2 text-center flex items-center justify-center">
<p class="text-[9px] sm:text-xs font-semibold uppercase tracking-wider text-amber-100">Prime</p>
</div>
<div
class="flex w-16 shrink-0 items-center justify-center rounded-lg border border-amber-200/30 bg-amber-900/30 p-1 text-center sm:w-20 sm:p-2 md:w-24"
>
<p
class="text-[9px] font-semibold tracking-wider text-amber-100 uppercase sm:text-xs"
>
{$t.game.components.guessHistory.bounty}
</p>
</div>
{/if}
{#if columnVisibility.height !== false}
<div class="w-16 sm:w-20 md:w-24 shrink-0 rounded-lg border border-amber-200/30 bg-amber-900/30 p-1 sm:p-2 text-center flex items-center justify-center">
<p class="text-[9px] sm:text-xs font-semibold uppercase tracking-wider text-amber-100">Taille</p>
</div>
<div
class="flex w-16 shrink-0 items-center justify-center rounded-lg border border-amber-200/30 bg-amber-900/30 p-1 text-center sm:w-20 sm:p-2 md:w-24"
>
<p
class="text-[9px] font-semibold tracking-wider text-amber-100 uppercase sm:text-xs"
>
{$t.game.components.guessHistory.height}
</p>
</div>
{/if}
{#if columnVisibility.age !== false}
<div
class="flex w-16 shrink-0 items-center justify-center rounded-lg border border-amber-200/30 bg-amber-900/30 p-1 text-center sm:w-20 sm:p-2 md:w-24"
>
<p
class="text-[9px] font-semibold tracking-wider text-amber-100 uppercase sm:text-xs"
>
{$t.game.components.guessHistory.age}
</p>
</div>
{/if}
{#if columnVisibility.origin !== false}
<div class="w-16 sm:w-20 md:w-24 shrink-0 rounded-lg border border-amber-200/30 bg-amber-900/30 p-1 sm:p-2 text-center flex items-center justify-center">
<p class="text-[9px] sm:text-xs font-semibold uppercase tracking-wider text-amber-100">Origine</p>
</div>
<div
class="flex w-16 shrink-0 items-center justify-center rounded-lg border border-amber-200/30 bg-amber-900/30 p-1 text-center sm:w-20 sm:p-2 md:w-24"
>
<p
class="text-[9px] font-semibold tracking-wider text-amber-100 uppercase sm:text-xs"
>
{$t.game.components.guessHistory.origin}
</p>
</div>
{/if}
{#if columnVisibility.arc !== false}
<div class="w-16 sm:w-20 md:w-24 shrink-0 rounded-lg border border-amber-200/30 bg-amber-900/30 p-1 sm:p-2 text-center flex items-center justify-center">
<p class="text-[9px] sm:text-xs font-semibold uppercase tracking-wider text-amber-100">Arc</p>
</div>
<div
class="flex w-16 shrink-0 items-center justify-center rounded-lg border border-amber-200/30 bg-amber-900/30 p-1 text-center sm:w-20 sm:p-2 md:w-24"
>
<p
class="text-[9px] font-semibold tracking-wider text-amber-100 uppercase sm:text-xs"
>
{$t.game.components.guessHistory.arc}
</p>
</div>
{/if}
</div>
<!-- Rows -->
{#each selectedCharacters as character (character.id)}
<div class="flex gap-1 sm:gap-2 mb-2">
<div class="mb-2 flex gap-1 sm:gap-2">
<!-- Personnage -->
<div class="w-16 h-16 sm:w-20 sm:h-20 md:w-24 md:h-24 shrink-0 rounded-lg border border-white/10 bg-slate-950/60 overflow-hidden">
<div
class="h-16 w-16 shrink-0 overflow-hidden rounded-lg border border-white/10 bg-slate-950/60 sm:h-20 sm:w-20 md:h-24 md:w-24"
>
{#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>
<a
href={getWikiBaseUrl() + getWikiUrl(character)}
target="_blank"
rel="noopener noreferrer"
class="block h-full w-full"
>
<img
src={character.pictureUrl}
alt={getDisplayName(character)}
class="h-full w-full cursor-pointer object-cover transition-opacity hover:opacity-80"
/>
</a>
{:else}
<div class="w-full h-full bg-slate-800 flex items-center justify-center p-1 sm:p-2">
<span class="text-xs sm:text-sm md:text-xl text-center font-semibold line-clamp-3">{character.name}</span>
<div
class="flex h-full w-full items-center justify-center bg-slate-800 p-1 sm:p-2"
>
<span
class="line-clamp-3 text-center text-xs font-semibold sm:text-sm md:text-xl"
>{getDisplayName(character)}</span
>
</div>
{/if}
</div>
<!-- Vivant / Mort -->
{#if columnVisibility.status !== false}
<div class="w-16 h-16 sm:w-20 sm:h-20 md:w-24 md:h-24 shrink-0 rounded-lg border border-white/10 {character.status === dailyCharacter.status ? 'bg-emerald-600/90' : 'bg-red-900/60'} p-1 sm:p-2 flex items-center justify-center">
<p class="text-[10px] sm:text-xs md:text-sm font-bold text-white text-center">
{character.status === 'Alive'
? 'Vivant'
: character.status === 'Deceased' || character.status === 'Dead'
? 'Mort'
: character.status === 'Unknown'
? 'Inconnu'
: character.status === null
? '-'
: character.status || 'Inconnu'}
</p>
</div>
<div
class="h-16 w-16 shrink-0 rounded-lg border border-white/10 sm:h-20 sm:w-20 md:h-24 md:w-24 {character.status ===
dailyCharacter.status
? 'bg-emerald-600/90'
: 'bg-red-900/60'} flex items-center justify-center p-1 sm:p-2"
>
<p class="text-center text-[10px] font-bold text-white sm:text-xs md:text-sm">
{character.status === 'Alive'
? $t.game.components.guessHistory.alive
: character.status === 'Dead'
? $t.game.components.guessHistory.dead
: character.status === 'Unknown'
? $t.game.components.guessHistory.unknown
: character.status === null
? '-'
: character.status || $t.game.components.guessHistory.unknown}
</p>
</div>
{/if}
<!-- Genre -->
{#if columnVisibility.gender !== false}
<div class="w-16 h-16 sm:w-20 sm:h-20 md:w-24 md:h-24 shrink-0 rounded-lg border border-white/10 {character.gender === dailyCharacter.gender ? 'bg-emerald-600/90' : 'bg-red-900/60'} p-1 sm:p-2 flex items-center justify-center">
<p class="text-xs sm:text-sm md:text-base font-bold text-white text-center">
{character.gender === 'Male' ? 'Homme' : character.gender === 'Female' ? 'Femme' : character.gender || 'Inconnu'}
</p>
</div>
<div
class="h-16 w-16 shrink-0 rounded-lg border border-white/10 sm:h-20 sm:w-20 md:h-24 md:w-24 {character.gender ===
dailyCharacter.gender
? 'bg-emerald-600/90'
: 'bg-red-900/60'} flex items-center justify-center p-1 sm:p-2"
>
<p class="text-center text-xs font-bold text-white sm:text-sm md:text-base">
{character.gender === 'Male'
? $t.game.components.guessHistory.male
: character.gender === 'Female'
? $t.game.components.guessHistory.female
: character.gender || $t.game.components.guessHistory.unknown}
</p>
</div>
{/if}
<!-- Affiliations -->
{#if columnVisibility.affiliations !== false}
<div class="w-16 h-16 sm:w-20 sm:h-20 md:w-24 md: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;
const charHasAff = charFirstAff && charFirstAff.trim() !== '';
const dailyHasAff = dailyFirstAff && dailyFirstAff.trim() !== '';
// If both have the same affiliation status and value
if (charHasAff === dailyHasAff && ((!charHasAff && !dailyHasAff) || charFirstAff === dailyFirstAff)) {
return 'bg-emerald-600/90';
}
return 'bg-red-900/60';
} catch (e) {
return 'bg-slate-950/60';
}
})()} p-1 sm: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-[10px] sm:text-xs md:text-sm font-bold text-white text-center whitespace-normal break-words leading-tight">{parsedAffiliations[0]}</p>
{:else}
<p class="w-full text-[10px] sm:text-xs md:text-sm font-bold text-white text-center whitespace-normal break-words leading-tight">{parsedAffiliations}</p>
{/if}
{:else}
<p class="text-xs sm:text-sm md:text-base font-bold text-slate-400 text-center">-</p>
{/if}
</div>
{#if columnVisibility.affiliation !== false}
<div
class="h-16 w-16 shrink-0 rounded-lg border border-white/10 sm:h-20 sm:w-20 md:h-24 md:w-24 {getDislayAffiliation(character) === getDislayAffiliation(dailyCharacter)
? 'bg-emerald-600/90'
: 'bg-red-900/60'} flex items-center justify-center p-1 sm:p-2"
>
<p class="text-center text-[10px] font-bold text-white sm:text-xs md:text-sm">
{getDislayAffiliation(character) || $t.game.components.guessHistory.unknown}
</p>
</div>
{/if}
<!-- Fruit -->
{#if columnVisibility.devilFruitType !== false}
<div class="w-16 h-16 sm:w-20 sm:h-20 md:w-24 md:h-24 shrink-0 rounded-lg border border-white/10 {character.devilFruitType === dailyCharacter.devilFruitType ? 'bg-emerald-600/90' : 'bg-red-900/60'} p-1 sm:p-2 flex items-center justify-center">
{#if character.devilFruitType}
<p class="text-[10px] sm:text-xs md:text-sm font-bold text-white text-center">{character.devilFruitType}</p>
{:else}
<p class="text-2xl sm:text-3xl md:text-5xl font-bold text-white text-center"></p>
{/if}
</div>
<div
class="h-16 w-16 shrink-0 rounded-lg border border-white/10 sm:h-20 sm:w-20 md:h-24 md:w-24 {character.devilFruitType ===
dailyCharacter.devilFruitType
? 'bg-emerald-600/90'
: 'bg-red-900/60'} flex items-center justify-center p-1 sm:p-2"
>
{#if character.devilFruitType}
<p class="text-center text-[10px] font-bold text-white sm:text-xs md:text-sm">
{character.devilFruitType}
</p>
{:else}
<p class="text-center text-2xl font-bold text-white sm:text-3xl md:text-5xl">
</p>
{/if}
</div>
{/if}
<!-- Haki -->
{#if columnVisibility.haki !== false}
<div class="w-16 h-16 sm:w-20 sm:h-20 md:w-24 md: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-1 sm:p-2 flex items-center justify-center">
<p class="text-sm sm:text-lg md: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-2xl sm:text-3xl md:text-5xl"></span>
{/if}
</p>
</div>
<div
class="h-16 w-16 shrink-0 rounded-lg border border-white/10 sm:h-20 sm:w-20 md:h-24 md:w-24 {(() => {
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';
}
})()} flex items-center justify-center p-1 sm:p-2"
>
<p class="text-center text-sm font-bold text-white sm:text-lg md:text-2xl">
{#if character.hakiObservation}<span title={$t.game.components.guessHistory.obsHakiTitle}>👁️</span
>{/if}
{#if character.hakiArmament}<span title={$t.game.components.guessHistory.armHakiTitle}>🦾</span>{/if}
{#if character.hakiConqueror}<span title={$t.game.components.guessHistory.kingHakiTitle}>👑</span>{/if}
{#if !character.hakiObservation && !character.hakiArmament && !character.hakiConqueror}
<span class="text-2xl sm:text-3xl md:text-5xl"></span>
{/if}
</p>
</div>
{/if}
<!-- Prime -->
{#if columnVisibility.bounty !== false}
<div class="w-16 h-16 sm:w-20 sm:h-20 md:w-24 md:h-24 shrink-0 rounded-lg border border-white/10 {character.bounty === dailyCharacter.bounty ? 'bg-emerald-600/90' : 'bg-red-900/60'} p-1 sm: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="
<div
class="h-16 w-16 shrink-0 rounded-lg border border-white/10 sm:h-20 sm:w-20 md:h-24 md:w-24 {character.bounty ===
dailyCharacter.bounty
? 'bg-emerald-600/90'
: 'bg-red-900/60'} relative flex items-center justify-center overflow-hidden p-1 sm:p-2"
>
{#if character.bounty != null && dailyCharacter.bounty != null && character.bounty !== dailyCharacter.bounty}
<div
class="pointer-events-none absolute h-full w-full opacity-30"
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-[10px] sm:text-xs md:text-sm font-bold text-white text-center relative z-10">{formatBounty(character.bounty)} ฿</p>
{:else}
<p class="text-[10px] sm:text-xs md:text-sm font-bold text-white text-center relative z-10">Inconnue</p>
{/if}
</div>
? '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="relative z-10 text-center text-[10px] font-bold text-white sm:text-xs md:text-sm"
>
{formatBounty(character.bounty)} ฿
</p>
{:else}
<p
class="relative z-10 text-center text-[10px] font-bold text-white sm:text-xs md:text-sm"
>
{$t.game.components.guessHistory.unknown}
</p>
{/if}
</div>
{/if}
<!-- Taille -->
{#if columnVisibility.height !== false}
<div class="w-16 h-16 sm:w-20 sm:h-20 md:w-24 md:h-24 shrink-0 rounded-lg border border-white/10 {character.height === dailyCharacter.height ? 'bg-emerald-600/90' : 'bg-red-900/60'} p-1 sm: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="
<div
class="h-16 w-16 shrink-0 rounded-lg border border-white/10 sm:h-20 sm:w-20 md:h-24 md:w-24 {character.height ===
dailyCharacter.height
? 'bg-emerald-600/90'
: 'bg-red-900/60'} relative flex items-center justify-center overflow-hidden p-1 sm:p-2"
>
{#if character.height && dailyCharacter.height && character.height !== dailyCharacter.height}
<div
class="pointer-events-none absolute h-full w-full opacity-30"
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-[10px] sm:text-xs md:text-sm font-bold text-white text-center relative z-10">{character.height} m</p>
? '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="relative z-10 text-center text-[10px] font-bold text-white sm:text-xs md:text-sm"
>
{character.height} m
</p>
{:else}
<p class="text-[10px] sm:text-xs md:text-sm font-bold text-white text-center relative z-10">Inconnue</p>
{/if}
</div>
<p
class="relative z-10 text-center text-[10px] font-bold text-white sm:text-xs md:text-sm"
>
{$t.game.components.guessHistory.unknown}
</p>
{/if}
</div>
{/if}
<!-- Age -->
{#if columnVisibility.age !== false}
<div
class="h-16 w-16 shrink-0 rounded-lg border border-white/10 sm:h-20 sm:w-20 md:h-24 md:w-24 {character.age ===
dailyCharacter.age
? 'bg-emerald-600/90'
: 'bg-red-900/60'} relative flex items-center justify-center overflow-hidden p-1 sm:p-2"
>
{#if character.age != null && dailyCharacter.age != null && character.age !== dailyCharacter.age}
<div
class="pointer-events-none absolute h-full w-full opacity-30"
style="
background-color: rgb(203, 213, 225);
clip-path: {character.age > dailyCharacter.age
? 'polygon(97% 60%,80% 60%,80% 5%,20% 5%,20% 60%,3% 60%,50% 95%)'
: 'polygon(97% 40%,80% 40%,80% 95%,20% 95%,20% 40%,3% 40%,50% 5%)'};
"
></div>
{/if}
{#if character.age != null}
<p
class="relative z-10 text-center text-[10px] font-bold text-white sm:text-xs md:text-sm"
>
{character.age}
</p>
{:else}
<p
class="relative z-10 text-center text-[10px] font-bold text-white sm:text-xs md:text-sm"
>
{$t.game.components.guessHistory.unknown}
</p>
{/if}
</div>
{/if}
<!-- Origine -->
{#if columnVisibility.origin !== false}
<div class="w-16 h-16 sm:w-20 sm:h-20 md:w-24 md:h-24 shrink-0 rounded-lg border border-white/10 {character.origin === dailyCharacter.origin ? 'bg-emerald-600/90' : 'bg-red-900/60'} p-1 sm:p-2 flex items-center justify-center">
<p class="text-[10px] sm:text-xs md:text-sm font-bold text-white text-center">{character.origin || 'Inconnue'}</p>
</div>
<div
class="h-16 w-16 shrink-0 rounded-lg border border-white/10 sm:h-20 sm:w-20 md:h-24 md:w-24 {hasMatchingOrigin(character, dailyCharacter)
? 'bg-emerald-600/90'
: 'bg-red-900/60'} flex items-center justify-center p-1 sm:p-2"
>
<p class="text-center text-[10px] font-bold text-white sm:text-xs md:text-sm">
{getDisplayOrigin(character) || $t.game.components.guessHistory.unknown}
</p>
</div>
{/if}
<!-- Arc -->
{#if columnVisibility.arc !== false}
<div class="w-16 h-16 sm:w-20 sm:h-20 md:w-24 md:h-24 shrink-0 rounded-lg border border-white/10 {character.arcName === dailyCharacter.arcName ? 'bg-emerald-600/90' : 'bg-red-900/60'} p-1 sm: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="
<div
class="h-16 w-16 shrink-0 rounded-lg border border-white/10 sm:h-20 sm:w-20 md:h-24 md:w-24 {hasMatchingArc(character, dailyCharacter)
? 'bg-emerald-600/90'
: 'bg-red-900/60'} relative flex items-center justify-center overflow-hidden p-1 sm:p-2"
>
{#if !hasMatchingArc(character, dailyCharacter) && character.firstAppearance && dailyCharacter.firstAppearance && character.firstAppearance !== dailyCharacter.firstAppearance}
<div
class="pointer-events-none absolute h-full w-full opacity-30"
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-[10px] sm:text-xs md:text-sm font-bold text-white text-center relative z-10">{character.arcName || 'Inconnu'}</p>
</div>
? '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="relative z-10 text-center text-[10px] font-bold text-white sm:text-xs md:text-sm"
>
{getDisplayArcName(character) || $t.game.components.guessHistory.unknown}
</p>
</div>
{/if}
</div>
{/each}

View File

@@ -1,6 +1,9 @@
<script lang="ts">
export let dailyCharacter: any;
export let selectedCharacters: any[];
import type { CharacterWithRelations } from "$lib/server/daily-character";
import { language, t } from '$lib/i18n';
export let dailyCharacter: CharacterWithRelations;
export let selectedCharacters: CharacterWithRelations[];
export let showOriginUnlock: boolean = false;
export let showFruitUnlock: boolean = false;
export let showAffiliationUnlock: boolean = false;
@@ -13,6 +16,15 @@
$: isOriginAvailable = selectedCharacters.length >= 5;
$: isFruitAvailable = selectedCharacters.length >= 10;
$: isAffiliationAvailable = selectedCharacters.length >= 15;
$: isFrench = $language === 'fr';
function getDisplayOrigin(character: CharacterWithRelations): string | null {
if (isFrench && typeof character.frOrigin === 'string' && character.frOrigin.length > 0) {
return character.frOrigin;
}
return character.origin;
}
</script>
<svelte:head>
@@ -42,13 +54,13 @@
disabled={!isOriginAvailable}
onclick={() => showHintOrigin = !showHintOrigin}
>
<p class="text-sm font-medium text-amber-100">Origine</p>
<p class="text-sm font-medium text-amber-100">{$t.game.components.hints.origin}</p>
{#if showHintOrigin}
<p class="mt-2 text-xs text-white font-semibold">{dailyCharacter.origin || 'Inconnue'}</p>
<p class="mt-2 text-xs text-white font-semibold">{getDisplayOrigin(dailyCharacter) || $t.game.components.hints.unknown}</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>
<p class="mt-2 text-xs text-slate-400">{Math.max(0, 5 - selectedCharacters.length)} {$t.game.components.hints.beforeUnlock}</p>
{:else}
<p class="mt-2 text-xs text-slate-400">Indice disponible !</p>
<p class="mt-2 text-xs text-slate-400">{$t.game.components.hints.available}</p>
{/if}
</button>
<button
@@ -57,13 +69,13 @@
disabled={!isFruitAvailable}
onclick={() => showHintFruit = !showHintFruit}
>
<p class="text-sm font-medium text-amber-100">Fruit du démon</p>
<p class="text-sm font-medium text-amber-100">{$t.game.components.hints.devilFruit}</p>
{#if showHintFruit}
<p class="mt-2 text-xs text-white font-semibold">{dailyCharacter.devilFruitName || 'Aucun'}</p>
<p class="mt-2 text-xs text-white font-semibold">{dailyCharacter.devilFruitName || $t.game.components.hints.none}</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>
<p class="mt-2 text-xs text-slate-400">{Math.max(0, 10 - selectedCharacters.length)} {$t.game.components.hints.beforeUnlock}</p>
{:else}
<p class="mt-2 text-xs text-slate-400">Indice disponible !</p>
<p class="mt-2 text-xs text-slate-400">{$t.game.components.hints.available}</p>
{/if}
</button>
<button
@@ -72,16 +84,13 @@
disabled={!isAffiliationAvailable}
onclick={() => showHintAffiliation = !showHintAffiliation}
>
<p class="text-sm font-medium text-amber-100">Affiliation</p>
<p class="text-sm font-medium text-amber-100">{$t.game.components.hints.affiliation}</p>
{#if showHintAffiliation}
{@const affiliations = typeof dailyCharacter.affiliations === 'string'
? ((dailyCharacter.affiliations as string).includes('[') ? JSON.parse(dailyCharacter.affiliations) : (dailyCharacter.affiliations as string).split(',').map((a: string) => a.trim()))
: dailyCharacter.affiliations}
<p class="mt-2 text-xs text-white font-semibold">{Array.isArray(affiliations) ? affiliations[0] : affiliations || 'Inconnue'}</p>
<p class="mt-2 text-xs text-white font-semibold">{isFrench && dailyCharacter.frAffiliation ? dailyCharacter.frAffiliation : dailyCharacter.affiliation || $t.game.components.hints.unknown}</p>
{:else if Math.max(0, 15 - selectedCharacters.length) > 0}
<p class="mt-2 text-xs text-slate-400">{Math.max(0, 15 - selectedCharacters.length)} essais avant déblocage</p>
<p class="mt-2 text-xs text-slate-400">{Math.max(0, 15 - selectedCharacters.length)} {$t.game.components.hints.beforeUnlock}</p>
{:else}
<p class="mt-2 text-xs text-slate-400">Indice disponible !</p>
<p class="mt-2 text-xs text-slate-400">{$t.game.components.hints.available}</p>
{/if}
</button>
</div>

View File

@@ -0,0 +1,104 @@
<script lang="ts">
import { onMount } from 'svelte';
import { availableLanguages, language, setLanguage } from '$lib/i18n';
let isOpen = false;
let rootElement: HTMLDivElement | undefined;
const languageLabels: Record<string, string> = {
en: 'English',
fr: 'Francais'
};
const languageFlags: Record<string, string> = {
en: 'GB',
fr: 'FR'
};
function getLanguageLabel(lang: string): string {
return languageLabels[lang] || lang.toUpperCase();
}
function getFlagCode(lang: string): string {
return languageFlags[lang] || 'UN';
}
function toFlagEmoji(code: string): string {
const normalized = code.toUpperCase();
if (normalized.length !== 2) {
return 'UN';
}
const first = normalized.codePointAt(0);
const second = normalized.codePointAt(1);
if (!first || !second) {
return 'UN';
}
return String.fromCodePoint(127397 + first, 127397 + second);
}
function toggleMenu() {
isOpen = !isOpen;
}
function selectLanguage(lang: string) {
setLanguage(lang);
isOpen = false;
}
onMount(() => {
const onDocumentClick = (event: MouseEvent) => {
if (!rootElement) {
return;
}
if (!rootElement.contains(event.target as Node)) {
isOpen = false;
}
};
document.addEventListener('click', onDocumentClick);
return () => document.removeEventListener('click', onDocumentClick);
});
</script>
<div bind:this={rootElement} class="relative">
<button
type="button"
onclick={toggleMenu}
class="flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-3 py-2 text-sm font-semibold text-slate-100 transition hover:border-amber-300/50 hover:bg-white/10"
aria-haspopup="true"
aria-expanded={isOpen}
aria-label="Change language"
>
<span class="text-base" aria-hidden="true">{toFlagEmoji(getFlagCode($language))}</span>
<span class="uppercase text-xs tracking-wider">{$language}</span>
<svg
class="h-3.5 w-3.5 transition-transform {isOpen ? 'rotate-180' : ''}"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
{#if isOpen}
<div class="absolute right-0 top-full z-20 mt-2 w-44 rounded-xl border border-white/10 bg-slate-900/95 p-1 shadow-[0_24px_60px_rgba(0,0,0,0.45)] backdrop-blur">
{#each availableLanguages as lang (lang)}
<button
type="button"
onclick={() => selectLanguage(lang)}
class="flex w-full items-center justify-between rounded-lg px-3 py-2 text-left text-sm transition {lang === $language ? 'bg-amber-300 text-slate-900' : 'text-slate-100 hover:bg-white/5'}"
>
<span class="flex items-center gap-2">
<span class="text-base" aria-hidden="true">{toFlagEmoji(getFlagCode(lang))}</span>
<span>{getLanguageLabel(lang)}</span>
</span>
<span class="text-xs uppercase tracking-wide opacity-70">{lang}</span>
</button>
{/each}
</div>
{/if}
</div>

View File

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

View File

@@ -1,26 +1,42 @@
<script lang="ts">
export let selectedCharacter: any;
export let selectedCharacters: any[];
import type { CharacterWithRelations } from "$lib/server/daily-character";
import { language, t } from '$lib/i18n';
export let selectedCharacter: CharacterWithRelations;
export let selectedCharacters: CharacterWithRelations[];
export let isGeckoMoriaWin: boolean = false;
const oneTryMessages = ['Tricheur 👀', '1 essai ? Avoue, tu avais la réponse 😏', 'Premier coup direct... suspect 🤨'];
const twoTryMessages = ['Bien joué ! ⚡', 'Deux essais, propre ! 👏', 'Tu chauffes vite, bien joué 🔥'];
const tenPlusMessages = [
'${attempts} essais... même un escargophone aurait trouvé plus vite 📞',
'${attempts} tentatives ? Le Grand Line est moins long que ça 😵',
'${attempts} essais : performance légendaire... dans le mauvais sens 🫠'
];
const fivePlusMessages = [
"${attempts} essais ? On va dire que c'était pour le suspense 😅",
'Ça en fait des essais... mais au moins tu y es arrivé 😬',
'Tu ne lâches rien, même après plusieurs essais 😂'
];
const defaultMessages = ['Pas mal du tout !', 'Bien tenté, bon rythme 👍', 'Ça se passe bien, continue comme ça ✨'];
$: isFrench = $language === 'fr';
const pickMessage = (messages: string[]) => messages[Math.floor(Math.random() * messages.length)];
function getDisplayName(character: CharacterWithRelations): string {
if (isFrench && typeof character.frName === 'string' && character.frName.length > 0) {
return character.frName;
}
return character.name;
}
function getWikiUrl(character: CharacterWithRelations): string {
if (isFrench && typeof character.frUrl === 'string' && character.frUrl.length > 0) {
return character.frUrl;
}
return character.url || '';
}
function getWikiBaseUrl(): string {
return isFrench ? 'https://onepiece.fandom.com/fr/wiki/' : 'https://onepiece.fandom.com/wiki/';
}
const pickMessage = (messages: readonly string[]) => messages[Math.floor(Math.random() * messages.length)];
const getAttemptMessage = (attempts: number): string => {
if (attempts <= 0) return '';
const oneTryMessages = $t.game.components.winPanel.oneTryMessages;
const twoTryMessages = $t.game.components.winPanel.twoTryMessages;
const tenPlusMessages = $t.game.components.winPanel.tenPlusMessages;
const fivePlusMessages = $t.game.components.winPanel.fivePlusMessages;
const defaultMessages = $t.game.components.winPanel.defaultMessages;
if (attempts === 1) {
return pickMessage(oneTryMessages);
}
@@ -39,31 +55,34 @@
$: attempts = selectedCharacters.length;
$: attemptMessage = getAttemptMessage(attempts);
$: attemptWord = selectedCharacters.length > 1
? $t.game.components.winPanel.attemptPlural
: $t.game.components.winPanel.attemptSingular;
</script>
{#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>
<h2 class="text-xl font-bold text-slate-300 mb-1">{$t.game.components.winPanel.moriaTitle}</h2>
<p class="text-sm text-slate-400">{$t.game.components.winPanel.moriaPrefix} {selectedCharacters.length} {attemptWord} !</p>
<p class="text-xs text-slate-300 mt-1">{attemptMessage}</p>
<div class="mt-3">
{#if selectedCharacter.pictureUrl}
<a
href={"https://onepiece.fandom.com/fr/wiki/" + selectedCharacter.url}
href={getWikiBaseUrl() + getWikiUrl(selectedCharacter)}
target="_blank"
rel="noopener noreferrer"
class="inline-block"
>
<img
src={selectedCharacter.pictureUrl}
alt={selectedCharacter.name}
alt={getDisplayName(selectedCharacter)}
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">{selectedCharacter.name}</p>
<p class="mt-2 text-lg font-bold text-slate-200">{getDisplayName(selectedCharacter)}</p>
</div>
</div>
</div>
@@ -71,25 +90,25 @@
<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>
<h2 class="text-xl font-bold text-emerald-400 mb-1">{$t.game.components.winPanel.winTitle}</h2>
<p class="text-sm text-emerald-300">{$t.game.components.winPanel.winPrefix} {selectedCharacters.length} {attemptWord} !</p>
<p class="text-xs text-emerald-200 mt-1">{attemptMessage}</p>
<div class="mt-3">
{#if selectedCharacter.pictureUrl}
<a
href={"https://onepiece.fandom.com/fr/wiki/" + selectedCharacter.url}
href={getWikiBaseUrl() + getWikiUrl(selectedCharacter)}
target="_blank"
rel="noopener noreferrer"
class="inline-block"
>
<img
src={selectedCharacter.pictureUrl}
alt={selectedCharacter.name}
alt={getDisplayName(selectedCharacter)}
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">{selectedCharacter.name}</p>
<p class="mt-2 text-lg font-bold text-white">{getDisplayName(selectedCharacter)}</p>
</div>
</div>
</div>

View File

@@ -1,5 +1,57 @@
<script lang="ts">
export let yesterdayCharacter: any;
import type { CharacterWithRelations } from "$lib/server/daily-character";
import { language, t } from '$lib/i18n';
export let yesterdayCharacter: CharacterWithRelations | null;
$: isFrench = $language === 'fr';
function parseEpithets(value: unknown): string[] {
if (Array.isArray(value)) {
return value.filter((entry): entry is string => typeof entry === 'string' && entry.length > 0);
}
if (typeof value === 'string') {
try {
const parsed = JSON.parse(value);
if (Array.isArray(parsed)) {
return parsed.filter((entry): entry is string => typeof entry === 'string' && entry.length > 0);
}
} catch {
if (value.length > 0) {
return [value];
}
}
}
return [];
}
function getDisplayName(character: CharacterWithRelations): string {
if (isFrench && typeof character.frName === 'string' && character.frName.length > 0) {
return character.frName;
}
return character.name;
}
function getDisplayEpithets(character: CharacterWithRelations): string[] {
const frenchEpithets = parseEpithets(character.frEpithets);
if (isFrench && frenchEpithets.length > 0) {
return frenchEpithets;
}
return parseEpithets(character.epithets);
}
function getWikiUrl(character: CharacterWithRelations): string {
if (isFrench && typeof character.frUrl === 'string' && character.frUrl.length > 0) {
return character.frUrl;
}
return character.url || '';
}
</script>
<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">
@@ -8,43 +60,52 @@
{#if yesterdayCharacter.pictureUrl}
<img
src={yesterdayCharacter.pictureUrl}
alt={yesterdayCharacter.name}
alt={getDisplayName(yesterdayCharacter)}
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
{$t.game.components.yesterdayCharacter.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="text-xs font-semibold uppercase tracking-[0.28em] text-amber-100">{$t.game.components.yesterdayCharacter.title}</p>
<p class="mt-2 text-lg font-semibold text-white">{getDisplayName(yesterdayCharacter)}</p>
{#if getDisplayEpithets(yesterdayCharacter).length > 0}
<p class="mt-1 text-sm text-slate-400">
{typeof yesterdayCharacter.epithets === 'string'
? JSON.parse(yesterdayCharacter.epithets).join(', ')
: (yesterdayCharacter.epithets as string[]).join(', ')}
{getDisplayEpithets(yesterdayCharacter).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>
{#if isFrench}
<a
href="https://onepiece.fandom.com/fr/wiki/{getWikiUrl(yesterdayCharacter)}"
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"
>
{$t.game.components.yesterdayCharacter.openPage}
</a>
{:else}
<a
href="https://onepiece.fandom.com/wiki/{getWikiUrl(yesterdayCharacter)}"
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"
>
{$t.game.components.yesterdayCharacter.openPage}
</a>
{/if}
</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
{$t.game.components.yesterdayCharacter.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">Aucun personnage</p>
<p class="mt-1 text-sm text-slate-200">Aucun personnage d'hier disponible</p>
<p class="text-xs font-semibold uppercase tracking-[0.28em] text-amber-100">{$t.game.components.yesterdayCharacter.title}</p>
<p class="mt-2 text-lg font-semibold text-white">{$t.game.components.yesterdayCharacter.none}</p>
<p class="mt-1 text-sm text-slate-200">{$t.game.components.yesterdayCharacter.noneAvailable}</p>
</div>
</div>
{/if}

233
src/lib/i18n/en.json Normal file
View File

@@ -0,0 +1,233 @@
{
"common": {
"language": "Language",
"selectLanguage": "Select Language",
"english": "English",
"french": "Français",
"german": "Deutsch",
"spanish": "Español"
},
"game": {
"home": {
"heroDescription": "Guess the character from pirate crews, marines, or the wider world. Every hint brings you closer to the treasure.",
"dailyTitle": "Daily Character",
"dailySubtitle": "A new mystery every 24 hours",
"dailyDescription": "Compare your guesses, unlock hints, and keep your streak alive.",
"dailyCta": "Start",
"infiniteTitle": "Infinite Mode",
"infiniteSubtitle": "Endless challenges",
"infiniteDescription": "Chain characters and chase your score. No limits, only fun.",
"infiniteCta": "Play",
"photoFallback": "Photo",
"yesterdayCharacter": "Yesterday's character",
"openPage": "Open page",
"noCharacter": "No character",
"noYesterdayCharacter": "No character from yesterday available"
},
"login": {
"titleSignUp": "Sign Up",
"titleSignIn": "Sign In",
"headerSignUp": "Create your account",
"headerSignIn": "Welcome, pirate",
"nameLabel": "Name",
"namePlaceholder": "Your name",
"usernameLabel": "Username",
"usernamePlaceholder": "e.g. luffy_gear5",
"identifierLabelSignUp": "Email",
"identifierLabelSignIn": "Email or username",
"identifierPlaceholderSignUp": "yourmail@email.com",
"identifierPlaceholderSignIn": "yourmail@email.com or luffy_gear5",
"passwordLabel": "Password",
"confirmPasswordLabel": "Confirm password",
"loading": "Loading...",
"submitSignUp": "Create an account",
"submitSignIn": "Log in",
"togglePromptSignUp": "Already have an account?",
"togglePromptSignIn": "Don't have an account?",
"toggleActionSignUp": "Log in",
"toggleActionSignIn": "Sign up",
"backHome": "Back to home"
},
"profile": {
"pageTitle": "My Profile",
"headerTitle": "My Profile",
"headerSubtitle": "Edit your profile information",
"tabProfile": "Profile",
"tabPassword": "Password",
"tabDaily": "Daily History",
"tabSessions": "Sessions",
"tabFriends": "Friends",
"avatarFallbackAlt": "Profile",
"email": "Email",
"displayName": "Display name",
"displayNamePlaceholder": "Your name",
"profileUpdateSuccess": "Profile updated successfully!",
"updating": "Updating...",
"saveChanges": "Save changes",
"friendsTitle": "Friends System",
"addFriendByUsername": "Add a friend by username",
"friendUsernamePlaceholder": "e.g. luffy_gear5",
"sending": "Sending...",
"send": "Send",
"incomingRequests": "Incoming requests",
"noIncomingRequests": "No incoming requests.",
"accept": "Accept",
"decline": "Decline",
"outgoingRequests": "Outgoing requests",
"noOutgoingRequests": "No outgoing requests.",
"cancel": "Cancel",
"myFriends": "My friends",
"noFriends": "You don't have any friends yet.",
"remove": "Remove",
"changePasswordTitle": "Change password",
"currentPassword": "Current password",
"newPassword": "New password",
"confirmPassword": "Confirm password",
"passwordChangeSuccess": "Password changed successfully!",
"changing": "Changing...",
"changePassword": "Change password",
"dailyHistoryTitle": "Daily history",
"noDailyHistory": "No history available",
"triedCharactersTitle": "Tried characters",
"noTriedCharacters": "No characters recorded",
"noImage": "N/A",
"trySingular": "try",
"tryPlural": "tries",
"activeSessionsTitle": "Active sessions",
"noActiveSessions": "No active session",
"unknownDevice": "Unknown device",
"unknown": "Unknown",
"ip": "IP",
"created": "Created",
"terminate": "Terminate",
"backHome": "Back to home"
},
"daily": {
"metaTitle": "OnePieceDle - Daily Mode",
"title": "Daily Character",
"winsPeopleSingular": "person",
"winsPeoplePlural": "people",
"winsVerbSingular": "has",
"winsVerbPlural": "have",
"winsSuffix": "found it today 🎉",
"reset": "Play again",
"description": "Guess the character. Each hint unlocks after a certain number of guesses. Good luck!",
"friendsToday": "Your friends today",
"friendsTriedCharacters": "Tried characters",
"friendsNoTriedCharacters": "No characters recorded",
"friendTrySingular": "try",
"friendTryPlural": "tries"
},
"infinite": {
"metaTitle": "OnePieceDle - Infinite Mode",
"title": "Infinite Mode",
"score": "Score",
"resetScore": "Reset",
"description": "Guess characters endlessly. Each hint unlocks after a certain number of guesses. Good luck!",
"nextCharacter": "Play again",
"revealAnswer": "Reveal answer",
"loadingCharacter": "Loading character...",
"filtersTitle": "Character filters",
"clearFilters": "Reset",
"filterGender": "Gender",
"filterStatus": "Status",
"filterAbilities": "Abilities",
"filterInformation": "Information",
"filterArcs": "Arcs",
"male": "Male",
"female": "Female",
"alive": "Alive",
"dead": "Dead",
"unknown": "Unknown",
"hasHaki": "Has Haki",
"fruitAll": "Fruit (All)",
"withFruit": "With Fruit",
"withoutFruit": "Without Fruit",
"heightDefined": "Height defined",
"ageDefined": "Age defined",
"originDefined": "Origin defined",
"availableCharactersSingular": "character available",
"availableCharactersPlural": "characters available",
"columnsTitle": "Columns"
},
"components": {
"searchInput": {
"title": "Enter a guess",
"placeholder": "Character name",
"submit": "Submit"
},
"hints": {
"origin": "Origin",
"devilFruit": "Devil fruit",
"affiliation": "Affiliation",
"unknown": "Unknown",
"none": "None",
"beforeUnlock": "guesses before unlock",
"available": "Hint available!"
},
"guessHistory": {
"title": "History",
"empty": "No guesses yet.",
"character": "Character",
"status": "Status",
"gender": "Gender",
"affiliations": "Affiliations",
"fruit": "Fruit",
"haki": "Haki",
"bounty": "Bounty",
"height": "Height",
"age": "Age",
"origin": "Origin",
"arc": "Arc",
"alive": "Alive",
"dead": "Dead",
"unknown": "Unknown",
"male": "Male",
"female": "Female",
"obsHakiTitle": "Observation Haki",
"armHakiTitle": "Armament Haki",
"kingHakiTitle": "Conqueror's Haki"
},
"winPanel": {
"attemptSingular": "attempt",
"attemptPlural": "attempts",
"moriaTitle": "Moria controls you...",
"moriaPrefix": "You succumbed to the shadows in",
"winTitle": "Congratulations!",
"winPrefix": "You found the character in",
"oneTryMessages": [
"Cheater 👀",
"1 guess? Admit it, you already knew 😏",
"First try... suspicious 🤨"
],
"twoTryMessages": [
"Well played! ⚡",
"Two guesses, clean! 👏",
"You warmed up fast, nice 🔥"
],
"tenPlusMessages": [
"${attempts} guesses... even a transponder snail would be faster 📞",
"${attempts} attempts? The Grand Line is shorter than that 😵",
"${attempts} guesses: legendary performance... in the wrong direction 🫠"
],
"fivePlusMessages": [
"${attempts} guesses? Let's say it was for suspense 😅",
"That is a lot of guesses... but you made it 😬",
"You never give up, even after several guesses 😂"
],
"defaultMessages": [
"Not bad at all!",
"Nice try, good pace 👍",
"Things are going well, keep it up ✨"
]
},
"yesterdayCharacter": {
"photo": "Photo",
"title": "Yesterday's character",
"openPage": "Open page",
"none": "No character",
"noneAvailable": "No character from yesterday available"
}
}
}
}

233
src/lib/i18n/fr.json Normal file
View File

@@ -0,0 +1,233 @@
{
"common": {
"language": "Langue",
"selectLanguage": "Sélectionnez la Langue",
"english": "English",
"french": "Français",
"german": "Deutsch",
"spanish": "Español"
},
"game": {
"home": {
"heroDescription": "Devine le personnage de l'equipage, des marines ou du vaste monde. Chaque indice te rapproche du tresor.",
"dailyTitle": "Personnage du jour",
"dailySubtitle": "Nouveau mystere toutes les 24 heures",
"dailyDescription": "Compare tes essais, debloque des indices et garde ta serie.",
"dailyCta": "Commencer",
"infiniteTitle": "Mode Infini",
"infiniteSubtitle": "Des defis sans fin",
"infiniteDescription": "Enchaine les personnages et croise ton score. Pas de limite, que du plaisir.",
"infiniteCta": "Jouer",
"photoFallback": "Photo",
"yesterdayCharacter": "Personnage d'hier",
"openPage": "Voir la page",
"noCharacter": "Aucun personnage",
"noYesterdayCharacter": "Aucun personnage d'hier disponible"
},
"login": {
"titleSignUp": "Inscription",
"titleSignIn": "Connexion",
"headerSignUp": "Creer votre compte",
"headerSignIn": "Bienvenue, pirate",
"nameLabel": "Nom",
"namePlaceholder": "Votre nom",
"usernameLabel": "Nom d'utilisateur",
"usernamePlaceholder": "ex: luffy_gear5",
"identifierLabelSignUp": "E-mail",
"identifierLabelSignIn": "E-mail ou nom d'utilisateur",
"identifierPlaceholderSignUp": "votremail@email.com",
"identifierPlaceholderSignIn": "votremail@email.com ou luffy_gear5",
"passwordLabel": "Mot de passe",
"confirmPasswordLabel": "Confirmer le mot de passe",
"loading": "Chargement...",
"submitSignUp": "Creer un compte",
"submitSignIn": "Se connecter",
"togglePromptSignUp": "Vous avez deja un compte ?",
"togglePromptSignIn": "Vous n'avez pas de compte ?",
"toggleActionSignUp": "Se connecter",
"toggleActionSignIn": "S'inscrire",
"backHome": "Retour a l'accueil"
},
"profile": {
"pageTitle": "Mon Profil",
"headerTitle": "Mon Profil",
"headerSubtitle": "Modifie les informations de ton profil",
"tabProfile": "Profil",
"tabPassword": "Mot de passe",
"tabDaily": "Historique Daily",
"tabSessions": "Sessions",
"tabFriends": "Amis",
"avatarFallbackAlt": "Profil",
"email": "Email",
"displayName": "Nom d'affichage",
"displayNamePlaceholder": "Ton nom",
"profileUpdateSuccess": "Profil mis a jour avec succes !",
"updating": "Mise a jour...",
"saveChanges": "Enregistrer les modifications",
"friendsTitle": "Systeme d'amis",
"addFriendByUsername": "Ajouter un ami par nom d'utilisateur",
"friendUsernamePlaceholder": "ex: luffy_gear5",
"sending": "Envoi...",
"send": "Envoyer",
"incomingRequests": "Demandes recues",
"noIncomingRequests": "Aucune demande recue.",
"accept": "Accepter",
"decline": "Refuser",
"outgoingRequests": "Demandes envoyees",
"noOutgoingRequests": "Aucune demande envoyee.",
"cancel": "Annuler",
"myFriends": "Mes amis",
"noFriends": "Tu n'as pas encore d'amis.",
"remove": "Supprimer",
"changePasswordTitle": "Changer le mot de passe",
"currentPassword": "Mot de passe actuel",
"newPassword": "Nouveau mot de passe",
"confirmPassword": "Confirmer le mot de passe",
"passwordChangeSuccess": "Mot de passe change avec succes !",
"changing": "Changement en cours...",
"changePassword": "Changer le mot de passe",
"dailyHistoryTitle": "Historique des Daily",
"noDailyHistory": "Aucun historique disponible",
"triedCharactersTitle": "Personnages essayes",
"noTriedCharacters": "Aucun personnage enregistre",
"noImage": "N/A",
"trySingular": "tentative",
"tryPlural": "tentatives",
"activeSessionsTitle": "Sessions actives",
"noActiveSessions": "Aucune session active",
"unknownDevice": "Appareil inconnu",
"unknown": "Inconnue",
"ip": "IP",
"created": "Creee",
"terminate": "Terminer",
"backHome": "Retour a l'accueil"
},
"daily": {
"metaTitle": "OnePieceDle - Mode du jour",
"title": "Personnage du jour",
"winsPeopleSingular": "personne",
"winsPeoplePlural": "personnes",
"winsVerbSingular": "a",
"winsVerbPlural": "ont",
"winsSuffix": "trouve aujourd'hui 🎉",
"reset": "Recommencer",
"description": "Devine le personnage. Chaque indice se debloque apres un certain nombre de tentatives. Bonne chance !",
"friendsToday": "Tes amis aujourd'hui",
"friendsTriedCharacters": "Personnages essayes",
"friendsNoTriedCharacters": "Aucun personnage enregistre",
"friendTrySingular": "coup",
"friendTryPlural": "coups"
},
"infinite": {
"metaTitle": "OnePieceDle - Mode Infini",
"title": "Mode Infini",
"score": "Score",
"resetScore": "Reinitialiser",
"description": "Devine des personnages a l'infini ! Chaque indice se debloque apres un certain nombre de tentatives. Bonne chance !",
"nextCharacter": "Recommencer",
"revealAnswer": "Reveler la reponse",
"loadingCharacter": "Chargement du personnage...",
"filtersTitle": "Filtres de personnages",
"clearFilters": "Reinitialiser",
"filterGender": "Genre",
"filterStatus": "Statut",
"filterAbilities": "Capacites",
"filterInformation": "Informations",
"filterArcs": "Arcs",
"male": "Homme",
"female": "Femme",
"alive": "Vivant",
"dead": "Mort",
"unknown": "Inconnu",
"hasHaki": "A du Haki",
"fruitAll": "Fruit (Tous)",
"withFruit": "Avec Fruit",
"withoutFruit": "Sans Fruit",
"heightDefined": "Taille definie",
"ageDefined": "Age defini",
"originDefined": "Origine definie",
"availableCharactersSingular": "personnage disponible",
"availableCharactersPlural": "personnages disponibles",
"columnsTitle": "Colonnes"
},
"components": {
"searchInput": {
"title": "Entrer une supposition",
"placeholder": "Nom du personnage",
"submit": "Valider"
},
"hints": {
"origin": "Origine",
"devilFruit": "Fruit du demon",
"affiliation": "Affiliation",
"unknown": "Inconnue",
"none": "Aucun",
"beforeUnlock": "essais avant deblocage",
"available": "Indice disponible !"
},
"guessHistory": {
"title": "Historique",
"empty": "Aucune tentative pour le moment.",
"character": "Personnage",
"status": "Statut",
"gender": "Genre",
"affiliations": "Affiliations",
"fruit": "Fruit",
"haki": "Haki",
"bounty": "Prime",
"height": "Taille",
"age": "Age",
"origin": "Origine",
"arc": "Arc",
"alive": "Vivant",
"dead": "Mort",
"unknown": "Inconnu",
"male": "Homme",
"female": "Femme",
"obsHakiTitle": "Haki de l'Observation",
"armHakiTitle": "Haki de l'Armement",
"kingHakiTitle": "Haki des Rois"
},
"winPanel": {
"attemptSingular": "tentative",
"attemptPlural": "tentatives",
"moriaTitle": "Moria vous controle...",
"moriaPrefix": "Vous avez succombe a l'ombre en",
"winTitle": "Felicitations !",
"winPrefix": "Vous avez trouve le personnage en",
"oneTryMessages": [
"Tricheur 👀",
"1 essai ? Avoue, tu avais la reponse 😏",
"Premier coup direct... suspect 🤨"
],
"twoTryMessages": [
"Bien joue ! ⚡",
"Deux essais, propre ! 👏",
"Tu chauffes vite, bien joue 🔥"
],
"tenPlusMessages": [
"${attempts} essais... meme un escargophone aurait trouve plus vite 📞",
"${attempts} tentatives ? Le Grand Line est moins long que ca 😵",
"${attempts} essais : performance legendaire... dans le mauvais sens 🫠"
],
"fivePlusMessages": [
"${attempts} essais ? On va dire que c'etait pour le suspense 😅",
"Ca en fait des essais... mais au moins tu y es arrive 😬",
"Tu ne laches rien, meme apres plusieurs essais 😂"
],
"defaultMessages": [
"Pas mal du tout !",
"Bien tente, bon rythme 👍",
"Ca se passe bien, continue comme ca ✨"
]
},
"yesterdayCharacter": {
"photo": "Photo",
"title": "Personnage d'hier",
"openPage": "Voir la page",
"none": "Aucun personnage",
"noneAvailable": "Aucun personnage d'hier disponible"
}
}
}
}

51
src/lib/i18n/index.ts Normal file
View File

@@ -0,0 +1,51 @@
import { writable, derived } from 'svelte/store';
import type { Writable, Readable } from 'svelte/store';
import en from './en.json';
import fr from './fr.json';
type Messages = typeof en;
const translations: Record<string, Messages> = { en, fr };
// Get initial language
function getInitialLanguage(): string {
if (typeof window !== 'undefined') {
const stored = localStorage.getItem('language');
if (stored && stored in translations) {
return stored;
}
const browserLang = navigator.language.split('-')[0];
if (browserLang in translations) {
return browserLang;
}
}
return 'en';
}
// Create writable store for the current language
export const language: Writable<string> = writable(getInitialLanguage());
// Create derived store for the current messages
export const t: Readable<Messages> = derived(language, ($language) => {
return translations[$language] || translations['en'];
});
export function setLanguage(lang: string) {
if (lang in translations) {
if (typeof window !== 'undefined') {
localStorage.setItem('language', lang);
}
language.set(lang);
}
}
export function getLanguage(): string {
let currentLang = 'en';
language.subscribe((lang) => {
currentLang = lang;
})();
return currentLang;
}
export const availableLanguages = Object.keys(translations);

View File

@@ -1,6 +1,6 @@
import { db } from '$lib/server/db';
import { arc, character, characterHistory, characterOverride, devilFruit } from '$lib/server/db/schema';
import { desc, eq, inArray, and } from 'drizzle-orm';
import { arc, character, characterHistory, devilFruit, type Character } from '$lib/server/db/schema';
import { desc, eq, and } from 'drizzle-orm';
// Generate or get random seed for daily character selection
const RANDOM_SEED = Math.random();
@@ -8,9 +8,11 @@ const RANDOM_SEED = Math.random();
const characterWithRelationsSelect = {
id: character.id,
name: character.name,
frName: character.frName,
gender: character.gender,
age: character.age,
affiliations: character.affiliations,
affiliation: character.affiliation,
frAffiliation: character.frAffiliation,
devilFruitId: character.devilFruitId,
devilFruitName: devilFruit.name,
devilFruitType: devilFruit.type,
@@ -20,23 +22,26 @@ const characterWithRelationsSelect = {
bounty: character.bounty,
height: character.height,
origin: character.origin,
frOrigin: character.frOrigin,
firstAppearance: character.firstAppearance,
pictureUrl: character.pictureUrl,
epithets: character.epithets,
frEpithets: character.frEpithets,
status: character.status,
url: character.url,
frUrl: character.frUrl,
arcId: character.arcId,
arcName: arc.name
arcName: arc.name,
frArcName: arc.frName,
};
export type CharacterWithRelations = typeof character.$inferSelect & {
export type CharacterWithRelations = Character & {
devilFruitName: string | null;
devilFruitType: string | null;
arcName: string | null;
frArcName: string | null;
};
type CharacterOverrideRow = typeof characterOverride.$inferSelect;
type RelationMaps = {
arcNameById: Map<string, string | null>;
devilFruitById: Map<string, { name: string | null; type: string | null }>;
@@ -46,102 +51,6 @@ function isNotNullish<T>(value: T | null | undefined): value is T {
return value !== null && value !== undefined;
}
function mergeCharacterWithOverride(
baseCharacter: CharacterWithRelations,
overrideRow?: CharacterOverrideRow,
relationMaps?: RelationMaps
): CharacterWithRelations {
if (!overrideRow) {
return baseCharacter;
}
const mergedCharacter = { ...baseCharacter } as CharacterWithRelations;
for (const [key, value] of Object.entries(overrideRow)) {
if (key === 'characterId' || key === 'notes') {
continue;
}
if (isNotNullish(value)) {
(mergedCharacter as Record<string, unknown>)[key] = value;
}
}
if (relationMaps) {
if (mergedCharacter.arcId) {
mergedCharacter.arcName = relationMaps.arcNameById.get(mergedCharacter.arcId) ?? null;
} else {
mergedCharacter.arcName = null;
}
if (mergedCharacter.devilFruitId) {
const devilFruitData = relationMaps.devilFruitById.get(mergedCharacter.devilFruitId);
mergedCharacter.devilFruitName = devilFruitData?.name ?? null;
mergedCharacter.devilFruitType = devilFruitData?.type ?? null;
} else {
mergedCharacter.devilFruitName = null;
mergedCharacter.devilFruitType = null;
}
}
return mergedCharacter;
}
async function applyCharacterOverrides(
characters: CharacterWithRelations[]
): Promise<CharacterWithRelations[]> {
if (characters.length === 0) {
return characters;
}
const characterIds = characters.map((currentCharacter) => currentCharacter.id);
const overrideRows = await db
.select()
.from(characterOverride)
.where(inArray(characterOverride.characterId, characterIds));
if (overrideRows.length === 0) {
return characters;
}
const overrideByCharacterId = new Map<string, CharacterOverrideRow>(
overrideRows.map((overrideRow) => [overrideRow.characterId, overrideRow])
);
const shouldRefreshRelations = overrideRows.some(
(overrideRow) => isNotNullish(overrideRow.arcId) || isNotNullish(overrideRow.devilFruitId)
);
let relationMaps: RelationMaps | undefined;
if (shouldRefreshRelations) {
const [allArcs, allDevilFruits] = await Promise.all([
db.select({ id: arc.id, name: arc.name }).from(arc),
db
.select({ id: devilFruit.id, name: devilFruit.name, type: devilFruit.type })
.from(devilFruit)
]);
relationMaps = {
arcNameById: new Map(allArcs.map((currentArc) => [currentArc.id, currentArc.name])),
devilFruitById: new Map(
allDevilFruits.map((currentDevilFruit) => [
currentDevilFruit.id,
{ name: currentDevilFruit.name, type: currentDevilFruit.type }
])
)
};
}
return characters.map((currentCharacter) =>
mergeCharacterWithOverride(
currentCharacter,
overrideByCharacterId.get(currentCharacter.id),
relationMaps
)
);
}
export function getDateKey(date: Date): number {
return normalizeDay(date).getTime();
}
@@ -161,26 +70,22 @@ function pickDailyCharacter(characters: CharacterWithRelations[], date: Date): C
}
export async function getDailyModeCharacters(): Promise<CharacterWithRelations[]> {
const characters = (await db
return (await db
.select(characterWithRelationsSelect)
.from(character)
.leftJoin(arc, eq(character.arcId, arc.id))
.leftJoin(devilFruit, eq(character.devilFruitId, devilFruit.id))
.where(eq(character.isInDailyMode, true))
.all()) as CharacterWithRelations[];
return applyCharacterOverrides(characters);
}
export async function getAllCharacters(): Promise<CharacterWithRelations[]> {
const characters = (await db
return (await db
.select(characterWithRelationsSelect)
.from(character)
.leftJoin(arc, eq(character.arcId, arc.id))
.leftJoin(devilFruit, eq(character.devilFruitId, devilFruit.id))
.all()) as CharacterWithRelations[];
return applyCharacterOverrides(characters);
}
export async function getCharacterById(characterId: string): Promise<CharacterWithRelations | null> {
@@ -196,8 +101,7 @@ export async function getCharacterById(characterId: string): Promise<CharacterWi
return null;
}
const [overriddenCharacter] = await applyCharacterOverrides([found as CharacterWithRelations]);
return overriddenCharacter ?? null;
return found as CharacterWithRelations
}
export async function getOrCreateTodayCharacter(

View File

@@ -1,5 +1,6 @@
import { integer, sqliteTable, text, real, unique } from 'drizzle-orm/sqlite-core';
import { user } from './auth.schema';
import type { InferSelectModel } from 'drizzle-orm';
// Define devil fruit types
export type DevilFruitType = 'Paramecia' | 'Zoan' | 'Logia' | 'Smile' | 'Unknown';
@@ -23,6 +24,8 @@ export const arc = sqliteTable('arc', {
url: text('url')
});
export type Arc = InferSelectModel<typeof arc>;
// Define the devil fruit table schema
export const devilFruit = sqliteTable('devil_fruit', {
id: text('id').primaryKey(),
@@ -31,6 +34,8 @@ export const devilFruit = sqliteTable('devil_fruit', {
url: text('url')
});
export type DevilFruit = InferSelectModel<typeof devilFruit>;
// Define the character table schema
export const character = sqliteTable('character', {
id: text('id').primaryKey(),
@@ -38,7 +43,8 @@ export const character = sqliteTable('character', {
frName: text('fr_name'),
gender: text('gender'),
age: integer('age'),
affiliations: text('affiliations', { mode: 'json' }).$type<string[]>(),
affiliation: text('affiliation'),
frAffiliation: text('fr_affiliation'),
devilFruitId: text('devil_fruit_id').references(() => devilFruit.id),
hakiObservation: integer('haki_observation', { mode: 'boolean' }).default(false),
hakiArmament: integer('haki_armament', { mode: 'boolean' }).default(false),
@@ -58,29 +64,7 @@ export const character = sqliteTable('character', {
isInDailyMode: integer('is_in_daily_mode', { mode: 'boolean' }).default(false)
});
// Define the character override table schema
export const characterOverride = sqliteTable('character_override', {
characterId: text('character_id').primaryKey().references(() => character.id, { onDelete: 'cascade' }),
name: text('name'),
gender: text('gender'),
age: integer('age'),
affiliations: text('affiliations', { mode: 'json' }).$type<string[]>(),
devilFruitId: text('devil_fruit_id').references(() => devilFruit.id, { onDelete: 'set null' }),
hakiObservation: integer('haki_observation', { mode: 'boolean' }),
hakiArmament: integer('haki_armament', { mode: 'boolean' }),
hakiConqueror: integer('haki_conqueror', { mode: 'boolean' }),
bounty: integer('bounty'),
height: real('height'),
origin: text('origin'),
firstAppearance: integer('first_appearance'),
pictureUrl: text('picture_url'),
epithets: text('epithets', { mode: 'json' }).$type<string[]>(),
status: text('status').$type<Status | null>(),
arcId: text('arc_id').references(() => arc.id, { onDelete: 'set null' }),
url: text('url'),
frUrl: text('fr_url'),
notes: text('notes')
});
export type Character = InferSelectModel<typeof character>;
// Define the character scrape validation table schema
export const characterScrapeValidation = sqliteTable('character_scrape_validation', {
@@ -89,7 +73,8 @@ export const characterScrapeValidation = sqliteTable('character_scrape_validatio
frName: text('fr_name'),
gender: text('gender'),
age: integer('age'),
affiliations: text('affiliations', { mode: 'json' }).$type<string[]>(),
affiliation: text('affiliation'),
frAffiliation: text('fr_affiliation'),
devilFruitId: text('devil_fruit_id').references(() => devilFruit.id, { onDelete: 'set null' }),
hakiObservation: integer('haki_observation', { mode: 'boolean' }).default(false),
hakiArmament: integer('haki_armament', { mode: 'boolean' }).default(false),
@@ -105,9 +90,12 @@ export const characterScrapeValidation = sqliteTable('character_scrape_validatio
status: text('status').$type<Status | null>(),
arcId: text('arc_id').references(() => arc.id, { onDelete: 'set null' }),
url: text('url'),
frUrl: text('fr_url')
frUrl: text('fr_url'),
isDeleted: integer('is_deleted', { mode: 'boolean' }).default(false),
});
export type CharacterScrapeValidation = InferSelectModel<typeof characterScrapeValidation>;
// Define the character history table schema
export const characterHistory = sqliteTable('character_history', {
id: text('id')
@@ -120,6 +108,8 @@ export const characterHistory = sqliteTable('character_history', {
updatedAt: integer('updated_at').notNull().$default(() => Date.now()),
});
export type CharacterHistory = InferSelectModel<typeof characterHistory>;
// Define the user character history table schema
export const userCharacterHistory = sqliteTable('user_character_history', {
id: text('id')
@@ -134,6 +124,8 @@ export const userCharacterHistory = sqliteTable('user_character_history', {
unique().on(table.userId, table.characterHistoryId)
]);
export type UserCharacterHistory = InferSelectModel<typeof userCharacterHistory>;
// Define the friendship table schema (friend requests + accepted friends)
export const friendship = sqliteTable('friendship', {
id: text('id')
@@ -152,5 +144,6 @@ export const friendship = sqliteTable('friendship', {
unique().on(table.requesterId, table.addresseeId)
]);
export type Friendship = InferSelectModel<typeof friendship>;
export * from './auth.schema';

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import { page } from '$app/stores';
import ProfileButton from '$lib/components/ProfileButton.svelte';
import { resolve } from '$app/paths';
let { children, data } = $props();
@@ -29,9 +30,9 @@
<h2 class="text-lg font-black uppercase tracking-[0.15em] text-amber-50">Admin</h2>
</div>
<nav class="flex-1 space-y-2 px-3">
{#each navItems as item}
{#each navItems as item (item.label)}
<a
href={item.href}
href={resolve(item.href)}
class={`flex items-center gap-3 rounded-lg px-4 py-3 text-sm font-medium transition-colors ${
isActive(item.href, $page.url.pathname)
? 'bg-amber-600 text-white'
@@ -45,7 +46,7 @@
</nav>
<div class="border-t border-white/5 p-3">
<a
href="/"
href={resolve('/')}
class="flex items-center gap-2 rounded-lg px-4 py-3 text-sm font-medium text-gray-300 transition-colors hover:bg-slate-800 hover:text-white"
title="Return to site"
>

View File

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

View File

@@ -11,7 +11,7 @@ const EXEC_OPTIONS = {
maxBuffer: 50 * 1024 * 1024
};
async function upsertCharacterFromScrapeValidation(characterId: string): Promise<boolean> {
async function applyCharacterChangeFromScrapeValidation(characterId: string): Promise<boolean> {
const [scraped] = await db
.select()
.from(characterScrapeValidation)
@@ -21,14 +21,21 @@ async function upsertCharacterFromScrapeValidation(characterId: string): Promise
return false;
}
if (scraped.isDeleted) {
await db.delete(character).where(eq(character.id, characterId));
return true;
}
await db
.insert(character)
.values({
id: scraped.id,
name: scraped.name,
frName: scraped.frName,
gender: scraped.gender,
age: scraped.age,
affiliations: scraped.affiliations,
affiliation: scraped.affiliation,
frAffiliation: scraped.frAffiliation,
devilFruitId: scraped.devilFruitId,
hakiObservation: scraped.hakiObservation,
hakiArmament: scraped.hakiArmament,
@@ -36,20 +43,25 @@ async function upsertCharacterFromScrapeValidation(characterId: string): Promise
bounty: scraped.bounty,
height: scraped.height,
origin: scraped.origin,
frOrigin: scraped.frOrigin,
firstAppearance: scraped.firstAppearance,
pictureUrl: scraped.pictureUrl,
epithets: scraped.epithets,
frEpithets: scraped.frEpithets,
status: scraped.status,
arcId: scraped.arcId,
url: scraped.url
url: scraped.url,
frUrl: scraped.frUrl,
})
.onConflictDoUpdate({
target: character.id,
set: {
name: scraped.name,
frName: scraped.frName,
gender: scraped.gender,
age: scraped.age,
affiliations: scraped.affiliations,
affiliation: scraped.affiliation,
frAffiliation: scraped.frAffiliation,
devilFruitId: scraped.devilFruitId,
hakiObservation: scraped.hakiObservation,
hakiArmament: scraped.hakiArmament,
@@ -57,12 +69,15 @@ async function upsertCharacterFromScrapeValidation(characterId: string): Promise
bounty: scraped.bounty,
height: scraped.height,
origin: scraped.origin,
frOrigin: scraped.frOrigin,
firstAppearance: scraped.firstAppearance,
pictureUrl: scraped.pictureUrl,
epithets: scraped.epithets,
frEpithets: scraped.frEpithets,
status: scraped.status,
arcId: scraped.arcId,
url: scraped.url
url: scraped.url,
frUrl: scraped.frUrl
}
});
@@ -79,7 +94,7 @@ export async function load() {
// Compare and categorize changes
const changes: {
type: 'new' | 'modified';
type: 'new' | 'modified' | 'deleted';
id: string;
scraped: (typeof scrapedCharacters)[0];
current?: (typeof currentCharacters)[0];
@@ -89,6 +104,18 @@ export async function load() {
for (const scraped of scrapedCharacters) {
const current = currentCharMap.get(scraped.id);
if (scraped.isDeleted) {
if (current) {
changes.push({
type: 'deleted',
id: scraped.id,
scraped,
current
});
}
continue;
}
if (!current) {
// New character
changes.push({
@@ -101,9 +128,11 @@ export async function load() {
const differences: Record<string, { current: any; scraped: any }> = {};
const fieldsToCompare = [
'name',
'frName',
'gender',
'age',
'affiliations',
'affiliation',
'frAffiliation',
'devilFruitId',
'hakiObservation',
'hakiArmament',
@@ -111,12 +140,15 @@ export async function load() {
'bounty',
'height',
'origin',
'frOrigin',
'firstAppearance',
'pictureUrl',
'epithets',
'frEpithets',
'status',
'arcId',
'url'
'url',
'frUrl'
];
for (const field of fieldsToCompare) {
@@ -144,11 +176,16 @@ export async function load() {
}
}
const typeOrder: Record<'new' | 'modified' | 'deleted', number> = {
new: 0,
modified: 1,
deleted: 2
};
return {
changes: changes.sort((a, b) => {
// Show 'new' first, then 'modified'
if (a.type !== b.type) {
return a.type === 'new' ? -1 : 1;
return typeOrder[a.type] - typeOrder[b.type];
}
return a.id.localeCompare(b.id);
})
@@ -209,10 +246,10 @@ export const actions = {
return { success: false, message: 'characterId is required' };
}
const applied = await upsertCharacterFromScrapeValidation(characterId);
const applied = await applyCharacterChangeFromScrapeValidation(characterId);
return {
success: applied,
message: applied ? 'Character applied successfully' : 'Character not found in scrape validation table'
message: applied ? 'Character change applied successfully' : 'Character not found in scrape validation table'
};
},
@@ -221,7 +258,7 @@ export const actions = {
let appliedCount = 0;
for (const scraped of scrapedCharacters) {
const applied = await upsertCharacterFromScrapeValidation(scraped.id);
const applied = await applyCharacterChangeFromScrapeValidation(scraped.id);
if (applied) {
appliedCount++;
}

View File

@@ -1,17 +1,30 @@
<script lang="ts">
import { page } from '$app/stores';
type CharacterLike = {
name: string;
pictureUrl?: string | null;
url?: string | null;
status?: string | null;
gender?: string | null;
age?: number | null;
bounty?: number | null;
[key: string]: unknown;
};
type CharacterChange = {
type: 'new' | 'modified' | 'deleted';
id: string;
scraped: CharacterLike;
current?: CharacterLike;
differences?: Record<string, { current: unknown; scraped: unknown }>;
};
let { data, form } = $props();
const newCharacters = $derived(data.changes.filter((c: any) => c.type === 'new'));
const modifiedCharacters = $derived(data.changes.filter((c: any) => c.type === 'modified'));
const newCharacters = $derived((data.changes as CharacterChange[]).filter((c) => c.type === 'new'));
const modifiedCharacters = $derived((data.changes as CharacterChange[]).filter((c) => c.type === 'modified'));
const deletedCharacters = $derived((data.changes as CharacterChange[]).filter((c) => c.type === 'deleted'));
function fandomUrl(path: string | null | undefined): string {
if (!path) return 'https://onepiece.fandom.com/fr/wiki';
return `https://onepiece.fandom.com/fr/wiki/${path}`;
}
function formatValue(value: any): string {
function formatValue(value: unknown): string {
if (value === null || value === undefined) {
return '—';
}
@@ -23,13 +36,6 @@
}
return String(value);
}
function getDifferenceColor(current: any, scraped: any): string {
if (JSON.stringify(current) === JSON.stringify(scraped)) {
return 'text-gray-400';
}
return 'text-amber-300';
}
</script>
<svelte:head>
@@ -39,7 +45,7 @@
<div class="space-y-8">
<div>
<h1 class="text-3xl font-black uppercase tracking-[0.25em] text-amber-50 mb-2">Character Changes</h1>
<p class="text-gray-400">Total changes: {newCharacters.length} new, {modifiedCharacters.length} modified</p>
<p class="text-gray-400">Total changes: {newCharacters.length} new, {modifiedCharacters.length} modified, {deletedCharacters.length} deleted</p>
<form method="POST" action="?/runScrapeImport" class="mt-4">
<button
type="submit"
@@ -56,7 +62,7 @@
{#if form?.logs}
<pre class="mt-3 max-h-72 overflow-auto rounded-lg border border-white/10 bg-slate-900/70 p-3 text-xs text-slate-200 whitespace-pre-wrap">{form.logs}</pre>
{/if}
{#if newCharacters.length + modifiedCharacters.length > 0}
{#if newCharacters.length + modifiedCharacters.length + deletedCharacters.length > 0}
<form method="POST" action="?/acceptAll" class="mt-4">
<button
type="submit"
@@ -80,9 +86,9 @@
<div class="flex items-center justify-between gap-3">
<div class="flex items-center gap-3">
{#if change.scraped.pictureUrl}
<a href={fandomUrl(change.scraped.url)} target="_blank" rel="noopener noreferrer">
<a href="https://onepiece.fandom.com/fr/wiki/{change.scraped.url}" target="_blank" rel="noopener noreferrer">
<img
src={change.scraped.pictureUrl}
src={change.scraped.pictureUrl ?? undefined}
alt={change.scraped.name}
class="w-12 h-12 rounded object-cover hover:opacity-80 transition"
/>
@@ -139,10 +145,10 @@
<div class="flex items-center justify-between gap-3 pb-4 border-b border-amber-500/20">
<div class="flex items-center gap-3">
{#if change.current?.pictureUrl}
<a href={fandomUrl(change.current?.url ?? change.scraped.url)} target="_blank" rel="noopener noreferrer">
<a href="https://onepiece.fandom.com/fr/wiki/{change.current?.url ?? change.scraped.url}" target="_blank" rel="noopener noreferrer">
<img
src={change.current.pictureUrl}
alt={change.current.name}
src={change.current?.pictureUrl ?? undefined}
alt={change.current?.name ?? change.scraped.name}
class="w-12 h-12 rounded object-cover hover:opacity-80 transition"
/>
</a>
@@ -165,7 +171,7 @@
{#if change.differences}
<div class="space-y-3">
{#each Object.entries(change.differences) as [field, diff]}
{#each Object.entries(change.differences) as [field, diff] (field)}
<div class="bg-slate-900/50 rounded p-3 space-y-1">
<h4 class="text-sm font-semibold text-amber-100 uppercase tracking-widest">{field}</h4>
<div class="grid grid-cols-2 gap-4 text-sm">
@@ -188,7 +194,49 @@
</section>
{/if}
{#if newCharacters.length === 0 && modifiedCharacters.length === 0}
<!-- Deleted Characters Section -->
{#if deletedCharacters.length > 0}
<section class="space-y-4">
<h2 class="text-xl font-bold text-rose-400 uppercase tracking-[0.15em]">
🗑️ Deleted Characters ({deletedCharacters.length})
</h2>
<div class="grid gap-4">
{#each deletedCharacters as change (change.id)}
<div class="rounded-lg border border-rose-500/30 bg-rose-500/5 p-4 space-y-3">
<div class="flex items-center justify-between gap-3">
<div class="flex items-center gap-3">
{#if change.current?.pictureUrl}
<a href="https://onepiece.fandom.com/fr/wiki/{change.current?.url ?? change.scraped.url}" target="_blank" rel="noopener noreferrer">
<img
src={change.current?.pictureUrl ?? undefined}
alt={change.current?.name ?? change.scraped.name}
class="w-12 h-12 rounded object-cover hover:opacity-80 transition"
/>
</a>
{/if}
<div>
<h3 class="font-bold text-rose-300">{change.current?.name ?? change.scraped.name}</h3>
<p class="text-sm text-gray-500">{change.id}</p>
</div>
</div>
<form method="POST" action="?/acceptOne">
<input type="hidden" name="characterId" value={change.id} />
<button
type="submit"
class="rounded-full border border-rose-300/40 bg-rose-500/20 px-3 py-1 text-xs font-semibold text-rose-100 transition hover:bg-rose-500/30"
>
Supprimer
</button>
</form>
</div>
<p class="text-sm text-rose-200/80">This character is no longer present in the latest scrape and will be removed if accepted.</p>
</div>
{/each}
</div>
</section>
{/if}
{#if newCharacters.length === 0 && modifiedCharacters.length === 0 && deletedCharacters.length === 0}
<div class="rounded-lg border border-white/10 bg-white/5 p-8 text-center">
<p class="text-gray-400">Aucun changement détecté. Les tables character et characterScrapeValidation sont synchronisées.</p>
</div>

View File

@@ -1,22 +1,32 @@
import { db } from '$lib/server/db';
import { character, devilFruit, arc, characterOverride } from '$lib/server/db/schema';
import { character, devilFruit, arc, type Status } from '$lib/server/db/schema';
import { eq, sql } from 'drizzle-orm';
import { fail } from '@sveltejs/kit';
import type { PageServerLoad, Actions } from './$types';
import { writeFile } from 'fs/promises';
import { join } from 'path';
import { existsSync, mkdirSync } from 'fs';
import { env } from '$env/dynamic/private';
// Helper function to normalize data (parse JSON arrays)
const normalizeArray = (value: any): any => {
if (!value) return value;
if (Array.isArray(value)) return value;
if (typeof value === 'string' && value.includes('[')) {
try {
return JSON.parse(value);
} catch {
return value;
}
}
return value;
};
export const load: PageServerLoad = async () => {
const [charactersData, devilFruits, arcs, overrides, statusesData, gendersData] = await Promise.all([
let [characters, devilFruits, arcs, statusesData, gendersData] = await Promise.all([
db
.select({
id: character.id,
name: character.name,
gender: character.gender,
age: character.age,
affiliations: character.affiliations,
affiliation: character.affiliation,
devilFruitId: character.devilFruitId,
hakiObservation: character.hakiObservation,
hakiArmament: character.hakiArmament,
@@ -26,7 +36,7 @@ export const load: PageServerLoad = async () => {
origin: character.origin,
firstAppearance: character.firstAppearance,
pictureUrl: character.pictureUrl,
epithets: character.epithets,
epithets: normalizeArray(character.epithets),
status: character.status,
url: character.url,
arcId: character.arcId,
@@ -41,7 +51,6 @@ export const load: PageServerLoad = async () => {
.orderBy(character.name),
db.select().from(devilFruit).orderBy(devilFruit.name),
db.select().from(arc).orderBy(arc.name),
db.select().from(characterOverride),
db.selectDistinct({ status: character.status })
.from(character)
.where(sql`${character.status} IS NOT NULL AND ${character.status} != ''`),
@@ -50,76 +59,13 @@ export const load: PageServerLoad = async () => {
.where(sql`${character.gender} IS NOT NULL AND ${character.gender} != ''`)
]);
// Create a map of overrides by characterId for easy lookup
const overridesMap = new Map(overrides.map((o) => [o.characterId, o]));
// Create maps for arcs and devil fruits to lookup names by ID
const arcMap = new Map(arcs.map((a) => [a.id, a.name]));
const devilFruitMap = new Map(devilFruits.map((f) => [f.id, { name: f.name, type: f.type }]));
// Helper function to normalize data (parse JSON arrays)
const normalizeArray = (value: any): any => {
if (!value) return value;
if (Array.isArray(value)) return value;
if (typeof value === 'string' && value.includes('[')) {
try {
return JSON.parse(value);
} catch {
return value;
}
}
return value;
};
// Merge character data with overrides
const charactersWithOverrides = charactersData.map((char) => {
const override = overridesMap.get(char.id);
// Build displayValues by only applying non-null override fields
const displayValues = { ...char } as any;
if (override) {
Object.keys(override).forEach((key) => {
if (override[key as keyof typeof override] !== null && key !== 'characterId') {
displayValues[key as keyof typeof displayValues] = override[key as keyof typeof override];
}
});
// Update arcName if arcId was overridden
if (override.arcId !== null && override.arcId !== undefined) {
displayValues.arcName = arcMap.get(override.arcId) || null;
}
// Update devilFruitName and devilFruitType if devilFruitId was overridden
if (override.devilFruitId !== null && override.devilFruitId !== undefined) {
const fruit = devilFruitMap.get(override.devilFruitId);
displayValues.devilFruitName = fruit?.name || null;
displayValues.devilFruitType = fruit?.type || null;
}
}
// Pre-normalize arrays (epithets, affiliations) for performance
displayValues.epithets = normalizeArray(displayValues.epithets);
displayValues.affiliations = normalizeArray(displayValues.affiliations);
// Create search text for epithets
displayValues.epithetsSearchText = Array.isArray(displayValues.epithets)
? displayValues.epithets.join(' ').toLowerCase()
: (displayValues.epithets || '').toLowerCase();
return {
...char,
override,
displayValues
};
});
return {
characters: charactersWithOverrides,
characters,
devilFruits,
arcs,
availableStatuses: statusesData
.map(s => s.status)
.filter((s): s is string => !!s)
.filter((s): s is Status => !!s)
.sort((a, b) => a.localeCompare(b)),
availableGenders: gendersData
.map(g => g.gender)
@@ -129,112 +75,6 @@ export const load: PageServerLoad = async () => {
};
export const actions: Actions = {
update: async ({ request, locals }) => {
if (!locals.user?.isAdmin) {
return fail(401, { error: 'Unauthorized' });
}
const formData = await request.formData();
const id = formData.get('id') as string;
if (!id) {
return fail(400, { error: 'Character ID is required' });
}
try {
const [originalCharacter] = await db
.select({
hakiObservation: character.hakiObservation,
hakiArmament: character.hakiArmament,
hakiConqueror: character.hakiConqueror
})
.from(character)
.where(eq(character.id, id))
.limit(1);
if (!originalCharacter) {
return fail(404, { error: 'Character not found' });
}
const updates: Record<string, any> = {};
// Handle file upload
const pictureFile = formData.get('pictureFile') as File;
const hasUploadedPicture = !!pictureFile && pictureFile.size > 0;
if (hasUploadedPicture) {
try {
const uploadsDir = env.UPLOADS_DIR || join(process.cwd(),'uploads');
if (!existsSync(uploadsDir)) {
mkdirSync(uploadsDir, { recursive: true });
}
// Get file extension
const extension = pictureFile.name.split('.').pop();
const filename = `${id}.${extension}`;
const filepath = join(uploadsDir, filename);
// Convert file to buffer and save
const buffer = Buffer.from(await pictureFile.arrayBuffer());
await writeFile(filepath, buffer);
// Update pictureUrl to point to the handler route
updates.pictureUrl = `/uploads/${filename}`;
} catch (error) {
console.error('File upload error:', error);
return fail(500, { error: 'Failed to upload file' });
}
}
formData.forEach((value, key) => {
if (key !== 'id' && key !== 'pictureFile') {
if (hasUploadedPicture && key === 'pictureUrl') {
return;
}
// Handle integers (age, bounty, height)
if (key === 'age' || key === 'bounty' || key === 'height') {
const strValue = value as string;
updates[key] = strValue && strValue !== '' ? parseInt(strValue) : null;
}
// Handle text IDs (devilFruitId, arcId)
else if (key === 'devilFruitId' || key === 'arcId') {
const strValue = value as string;
updates[key] = strValue && strValue !== '' ? strValue : null;
}
// Handle checkboxes (haki fields) after parsing all form data
else if (key === 'hakiObservation' || key === 'hakiArmament' || key === 'hakiConqueror') {
return;
}
// Handle strings (name, gender, status, origin, affiliations, epithets, pictureUrl, url, firstAppearance)
else {
updates[key] = value || null;
}
}
});
const submittedHakiObservation = formData.has('hakiObservation');
const submittedHakiArmament = formData.has('hakiArmament');
const submittedHakiConqueror = formData.has('hakiConqueror');
updates.hakiObservation =
submittedHakiObservation === originalCharacter.hakiObservation ? null : submittedHakiObservation;
updates.hakiArmament =
submittedHakiArmament === originalCharacter.hakiArmament ? null : submittedHakiArmament;
updates.hakiConqueror =
submittedHakiConqueror === originalCharacter.hakiConqueror ? null : submittedHakiConqueror;
// Update or insert into characterOverride table
await db
.insert(characterOverride)
.values({ characterId: id, ...updates })
.onConflictDoUpdate({ target: characterOverride.characterId, set: updates });
return { success: true };
} catch (error) {
console.error('Character update error:', error);
return fail(500, { error: 'Failed to update character' });
}
},
delete: async ({ request, locals }) => {
if (!locals.user?.isAdmin) {
return fail(401, { error: 'Unauthorized' });

View File

@@ -15,13 +15,11 @@
let filterGender = $state('all');
let filterArc = $state('all');
let filterHaki = $state<'all' | 'observation' | 'armament' | 'conqueror' | 'none'>('all');
let selectedCharacterId = $state<string | null>(null);
let isEditModalOpen = $state(false);
let isSaving = $state(false);
let saveMessage = $state<{ type: 'success' | 'error'; text: string } | null>(null);
let dailyModeToast = $state<{ type: 'success' | 'error'; text: string } | null>(null);
let selectedChar = $state<any>(null);
let showOriginalValue = $state<Record<string, boolean>>({});
const showDailyModeToast = (type: 'success' | 'error', text: string) => {
dailyModeToast = { type, text };
@@ -38,12 +36,6 @@
}
};
const getFandomUrl = (url: string | null | undefined) => {
if (!url) return null;
if (url.startsWith('http://') || url.startsWith('https://')) return url;
return `https://onepiece.fandom.com/fr/wiki/${url}`;
};
let editForm = $state<any>({
id: '',
name: '',
@@ -52,7 +44,7 @@
bounty: 0,
height: 0,
origin: '',
affiliations: '',
affiliation: '',
epithets: '',
pictureUrl: '',
url: '',
@@ -71,23 +63,22 @@
const matchesSearch =
normalizedQuery === '' ||
char.displayValues.name.toLowerCase().includes(normalizedQuery) ||
char.displayValues.epithetsSearchText.includes(normalizedQuery);
char.name.toLowerCase().includes(normalizedQuery);
const matchesDaily =
filterDaily === 'all' ||
(filterDaily === 'daily' && char.displayValues.isInDailyMode) ||
(filterDaily === 'not-daily' && !char.displayValues.isInDailyMode);
const matchesStatus = filterStatus === 'all' || (char.displayValues.status || '') === filterStatus;
const matchesGender = filterGender === 'all' || (char.displayValues.gender || '') === filterGender;
(filterDaily === 'daily' && char.isInDailyMode) ||
(filterDaily === 'not-daily' && !char.isInDailyMode);
const matchesStatus = filterStatus === 'all' || (char.status || '') === filterStatus;
const matchesGender = filterGender === 'all' || (char.gender || '') === filterGender;
const matchesArc =
filterArc === 'all' ||
String(char.displayValues.arcId ?? '') === filterArc;
String(char.arcId ?? '') === filterArc;
const matchesHaki =
filterHaki === 'all' ||
(filterHaki === 'observation' && !!char.displayValues.hakiObservation) ||
(filterHaki === 'armament' && !!char.displayValues.hakiArmament) ||
(filterHaki === 'conqueror' && !!char.displayValues.hakiConqueror) ||
(filterHaki === 'none' && !char.displayValues.hakiObservation && !char.displayValues.hakiArmament && !char.displayValues.hakiConqueror);
(filterHaki === 'observation' && !!char.hakiObservation) ||
(filterHaki === 'armament' && !!char.hakiArmament) ||
(filterHaki === 'conqueror' && !!char.hakiConqueror) ||
(filterHaki === 'none' && !char.hakiObservation && !char.hakiArmament && !char.hakiConqueror);
return matchesSearch && matchesDaily && matchesStatus && matchesGender && matchesArc && matchesHaki;
});
@@ -98,7 +89,6 @@
};
const openEditModal = (char: any) => {
selectedCharacterId = char.id;
selectedChar = char;
const override = char.override || {};
@@ -111,7 +101,7 @@
bounty: override.bounty ?? null,
height: override.height ?? null,
origin: override.origin ?? '',
affiliations: override.affiliations ?? '',
affiliation: override.affiliation ?? '',
epithets: override.epithets ?? '',
pictureUrl: override.pictureUrl ?? '',
url: override.url ?? '',
@@ -123,13 +113,11 @@
arcId: override.arcId !== null && override.arcId !== undefined ? override.arcId : (char.arcId || ''),
status: override.status ?? ''
};
showOriginalValue = {};
isEditModalOpen = true;
};
const closeModal = () => {
isEditModalOpen = false;
selectedCharacterId = null;
selectedChar = null;
editForm = {
id: '',
@@ -139,7 +127,7 @@
bounty: 0,
height: 0,
origin: '',
affiliations: '',
affiliation: '',
epithets: '',
pictureUrl: '',
url: '',
@@ -179,6 +167,7 @@
}, 3000);
}
} catch (error) {
console.error('Error deleting character:', error);
saveMessage = {
type: 'error',
text: 'Error deleting character'
@@ -221,7 +210,7 @@
class="rounded-lg bg-slate-700 px-4 py-2 text-sm text-white outline-none transition focus:ring-2 focus:ring-amber-600"
>
<option value="all">All Statuses</option>
{#each data.availableStatuses as status}
{#each data.availableStatuses as status (status)}
<option value={status}>{status}</option>
{/each}
</select>
@@ -230,7 +219,7 @@
class="rounded-lg bg-slate-700 px-4 py-2 text-sm text-white outline-none transition focus:ring-2 focus:ring-amber-600"
>
<option value="all">All Genders</option>
{#each data.availableGenders as gender}
{#each data.availableGenders as gender (gender)}
<option value={gender}>{gender}</option>
{/each}
</select>
@@ -239,8 +228,8 @@
class="rounded-lg bg-slate-700 px-4 py-2 text-sm text-white outline-none transition focus:ring-2 focus:ring-amber-600"
>
<option value="all">All Arcs</option>
{#each data.arcs as arc}
<option value={String(arc.id)}>{arc.name}</option>
{#each data.arcs as arc (arc.id)}
<option value={arc.id}>{arc.name}</option>
{/each}
</select>
<select
@@ -284,119 +273,115 @@
</tr>
</thead>
<tbody>
{#each filteredCharacters as char}
{#each filteredCharacters as char (char.id)}
<tr class="border-b border-white/5 hover:bg-slate-800/50">
<!-- Character -->
<td class="px-4 py-4 text-sm text-white w-64 max-w-64 {isFieldOverridden(char, 'name') || isFieldOverridden(char, 'pictureUrl') ? 'bg-amber-500/10' : ''}">
<td class="px-4 py-4 text-sm text-white w-64 max-w-64">
<div class="flex items-center gap-3 min-w-0">
{#if getFandomUrl(char.displayValues.url)}
{#if char.url}
<a
href={getFandomUrl(char.displayValues.url)}
href={"https://onepiece.fandom.com/wiki/" + char.url}
target="_blank"
rel="noopener noreferrer"
class="flex-shrink-0 transition-opacity hover:opacity-80"
class="shrink-0 transition-opacity hover:opacity-80"
>
{#if char.displayValues.pictureUrl}
{#if char.pictureUrl}
<img
src={char.displayValues.pictureUrl}
alt={char.displayValues.name}
src={char.pictureUrl}
alt={char.name}
loading="lazy"
class="h-10 w-10 rounded-full object-cover"
/>
{:else}
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-slate-700 text-gray-400">
{char.displayValues.name?.charAt(0).toUpperCase() || '?'}
{char.name?.charAt(0).toUpperCase() || '?'}
</div>
{/if}
</a>
{:else}
{#if char.displayValues.pictureUrl}
{#if char.pictureUrl}
<img
src={char.displayValues.pictureUrl}
alt={char.displayValues.name}
src={char.pictureUrl}
alt={char.name}
loading="lazy"
class="h-10 w-10 flex-shrink-0 rounded-full object-cover"
class="h-10 w-10 shrink-0 rounded-full object-cover"
/>
{:else}
<div class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full bg-slate-700 text-gray-400">
{char.displayValues.name?.charAt(0).toUpperCase() || '?'}
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-slate-700 text-gray-400">
{char.name?.charAt(0).toUpperCase() || '?'}
</div>
{/if}
{/if}
<div class="flex flex-col min-w-0">
{#if getFandomUrl(char.displayValues.url)}
{#if char.url}
<a
href={getFandomUrl(char.displayValues.url)}
href="https://onepiece.fandom.com/wiki/{char.url}"
target="_blank"
rel="noopener noreferrer"
class="font-medium truncate text-white hover:text-amber-200 hover:underline"
>
{char.displayValues.name}
{char.name}
</a>
{:else}
<span class="font-medium truncate">{char.displayValues.name}</span>
<span class="font-medium truncate">{char.name}</span>
{/if}
{#if char.displayValues.epithets}
{#if char.epithets}
<span class="text-xs text-gray-500 truncate">
{Array.isArray(char.displayValues.epithets)
? char.displayValues.epithets.join(', ')
: char.displayValues.epithets}
{Array.isArray(char.epithets)
? char.epithets.join(', ')
: char.epithets}
</span>
{/if}
</div>
</div>
</td>
<!-- Status -->
<td class="px-4 py-4 text-sm text-gray-400 {isFieldOverridden(char, 'status') ? 'bg-amber-500/10' : ''}">{char.displayValues.status || '-'}</td>
<td class="px-4 py-4 text-sm text-gray-400">{char.status || '-'}</td>
<!-- Gender -->
<td class="px-4 py-4 text-sm text-gray-400 {isFieldOverridden(char, 'gender') ? 'bg-amber-500/10' : ''}">{char.displayValues.gender || '-'}</td>
<td class="px-4 py-4 text-sm text-gray-400">{char.gender || '-'}</td>
<!-- Affiliations -->
<td class="px-4 py-4 text-sm text-gray-400 {isFieldOverridden(char, 'affiliations') ? 'bg-amber-500/10' : ''}">
{#if char.displayValues.affiliations}
{#if Array.isArray(char.displayValues.affiliations) && char.displayValues.affiliations.length > 0}
<span class="inline-block" title={char.displayValues.affiliations.join(', ')}>{char.displayValues.affiliations[0]}</span>
{:else}
{char.displayValues.affiliations}
{/if}
<td class="px-4 py-4 text-sm text-gray-400">
{#if char.affiliation}
{char.affiliation}
{:else}
-
{/if}
</td>
<!-- Fruit -->
<td class="px-4 py-4 text-sm text-gray-400 {isFieldOverridden(char, 'devilFruitId') ? 'bg-amber-500/10' : ''}">{char.displayValues.devilFruitName || '-'}</td>
<td class="px-4 py-4 text-sm text-gray-400">{char.devilFruitName || '-'}</td>
<!-- Haki -->
<td class="px-4 py-4 text-sm {isFieldOverridden(char, 'hakiObservation') || isFieldOverridden(char, 'hakiArmament') || isFieldOverridden(char, 'hakiConqueror') ? 'bg-amber-500/10' : ''}">
<td class="px-4 py-4 text-sm">
<div class="flex gap-1">
{#if char.displayValues.hakiObservation}<span title="Haki de l'Observation">👁️</span>{/if}
{#if char.displayValues.hakiArmament}<span title="Haki de l'Armement">🦾</span>{/if}
{#if char.displayValues.hakiConqueror}<span title="Haki des Rois">👑</span>{/if}
{#if !char.displayValues.hakiObservation && !char.displayValues.hakiArmament && !char.displayValues.hakiConqueror}
{#if char.hakiObservation}<span title="Haki de l'Observation">👁️</span>{/if}
{#if char.hakiArmament}<span title="Haki de l'Armement">🦾</span>{/if}
{#if char.hakiConqueror}<span title="Haki des Rois">👑</span>{/if}
{#if !char.hakiObservation && !char.hakiArmament && !char.hakiConqueror}
<span class="text-gray-400">-</span>
{/if}
</div>
</td>
<!-- Bounty -->
<td class="px-4 py-4 text-sm text-gray-400 {isFieldOverridden(char, 'bounty') ? 'bg-amber-500/10' : ''}">
{#if char.displayValues.bounty != null}
{formatBounty(char.displayValues.bounty)} ฿
<td class="px-4 py-4 text-sm text-gray-400">
{#if char.bounty != null}
{formatBounty(char.bounty)} ฿
{:else}
-
{/if}
</td>
<!-- Height -->
<td class="px-4 py-4 text-sm text-gray-400 {isFieldOverridden(char, 'height') ? 'bg-amber-500/10' : ''}">
{#if char.displayValues.height}
{char.displayValues.height} m
<td class="px-4 py-4 text-sm text-gray-400">
{#if char.height}
{char.height} m
{:else}
-
{/if}
</td>
<!-- Origin -->
<td class="px-4 py-4 text-sm text-gray-400 {isFieldOverridden(char, 'origin') ? 'bg-amber-500/10' : ''}">{char.displayValues.origin || '-'}</td>
<td class="px-4 py-4 text-sm text-gray-400">{char.origin || '-'}</td>
<!-- Arc -->
<td class="px-4 py-4 text-sm text-gray-400 {isFieldOverridden(char, 'arcId') || isFieldOverridden(char, 'arcName') ? 'bg-amber-500/10' : ''}">{char.displayValues.arcName || '-'}</td>
<td class="px-4 py-4 text-sm text-gray-400">{char.arcName || '-'}</td>
<!-- Daily Mode -->
<td class="px-4 py-4 text-sm {isFieldOverridden(char, 'isInDailyMode') ? 'bg-amber-500/10' : ''}">
<td class="px-4 py-4 text-sm">
<form
method="POST"
action="?/toggleDailyMode"
@@ -414,11 +399,11 @@
}}
>
<input type="hidden" name="id" value={char.id} />
<input type="hidden" name="isInDailyMode" value={(!char.displayValues.isInDailyMode).toString()} />
<input type="hidden" name="isInDailyMode" value={(!char.isInDailyMode).toString()} />
<label class="flex items-center justify-center cursor-pointer">
<input
type="checkbox"
checked={char.displayValues.isInDailyMode}
checked={char.isInDailyMode}
onchange={(e) => {
const form = e.currentTarget.closest('form');
if (form) form.requestSubmit();
@@ -461,7 +446,7 @@
{/if}
{#if dailyModeToast}
<div class="fixed right-6 top-6 z-[60]">
<div class="fixed right-6 top-6 z-60">
<div
class={`rounded-lg border px-4 py-3 text-sm font-medium shadow-lg backdrop-blur ${
dailyModeToast.type === 'success'
@@ -628,7 +613,7 @@
class="w-full rounded-lg border border-gray-500 bg-slate-800 px-3 py-2 text-white"
>
<option value="">None</option>
{#each data.arcs as arc}
{#each data.arcs as arc (arc.id)}
<option value={arc.id}>{arc.name}</option>
{/each}
</select>
@@ -651,7 +636,7 @@
class="w-full rounded-lg border border-gray-500 bg-slate-800 px-3 py-2 text-white"
>
<option value="">None</option>
{#each data.devilFruits as fruit}
{#each data.devilFruits as fruit (fruit.id)}
<option value={fruit.id}>{fruit.name}</option>
{/each}
</select>

View File

@@ -13,7 +13,10 @@
let { data }: Props = $props();
let configItems = $state<ConfigItem[]>([]);
let configItems = $derived(data.config.map((item) => ({
key: item.key,
value: item.value ?? ''
})));
let newKey = $state('');
let newValue = $state('');
let editingKey = $state<string | null>(null);
@@ -21,12 +24,7 @@
let isSaving = $state(false);
let saveMessage = $state<{ type: 'success' | 'error'; text: string } | null>(null);
$effect(() => {
configItems = data.config.map((item) => ({
key: item.key,
value: item.value ?? ''
}));
});
;
const startEdit = (item: ConfigItem) => {
editingKey = item.key;
@@ -70,6 +68,7 @@
saveMessage = { type: 'error', text: 'Failed to add config' };
}
} catch (error) {
console.error('Error adding config:', error);
saveMessage = { type: 'error', text: 'Error adding config' };
} finally {
isSaving = false;
@@ -99,6 +98,7 @@
saveMessage = { type: 'error', text: 'Failed to delete config' };
}
} catch (error) {
console.error('Error deleting config:', error);
saveMessage = { type: 'error', text: 'Error deleting config' };
} finally {
isSaving = false;
@@ -155,7 +155,7 @@
</tr>
</thead>
<tbody>
{#each configItems as item}
{#each configItems as item (item.key)}
{#if editingKey === item.key}
<tr class="border-b border-white/5 bg-slate-800/50">
<td class="px-6 py-4 text-sm text-white">{item.key}</td>

View File

@@ -11,7 +11,6 @@
let searchQuery = $state('');
let filterType = $state<'all' | 'Paramecia' | 'Zoan' | 'Logia' | 'Unknown'>('all');
let isEditModalOpen = $state(false);
let selectedFruitId = $state<string | null>(null);
let isSaving = $state(false);
let saveMessage = $state<{ type: 'success' | 'error'; text: string } | null>(null);
@@ -33,14 +32,12 @@
});
const openEditModal = (fruit: any) => {
selectedFruitId = fruit.id;
editForm = { ...fruit };
isEditModalOpen = true;
};
const closeModal = () => {
isEditModalOpen = false;
selectedFruitId = null;
editForm = {
id: '',
name: '',
@@ -88,6 +85,7 @@
}, 3000);
}
} catch (error) {
console.error('Error deleting devil fruit:', error);
saveMessage = {
type: 'error',
text: 'Error deleting devil fruit'
@@ -150,7 +148,7 @@
</tr>
</thead>
<tbody>
{#each filteredFruits as fruit}
{#each filteredFruits as fruit (fruit.id)}
<tr class="border-b border-white/5 hover:bg-slate-800/50">
<td class="px-6 py-4 text-sm text-white">{fruit.name}</td>
<td class="px-6 py-4 text-sm">
@@ -233,7 +231,7 @@
bind:value={editForm.type}
class="mt-1 w-full rounded-lg bg-slate-700 px-4 py-2 text-sm text-white outline-none transition focus:ring-2 focus:ring-amber-600"
>
{#each fruitTypes as type}
{#each fruitTypes as type (type)}
<option value={type}>{type}</option>
{/each}
</select>

View File

@@ -13,7 +13,6 @@
let isEditModalOpen = $state(false);
let isSaving = $state(false);
let saveMessage = $state<{ type: 'success' | 'error'; message: string } | null>(null);
let selectedUserId = $state<string | null>(null);
let editForm = $state<any>({
id: '',
@@ -35,7 +34,6 @@
});
const openEditModal = (usr: any) => {
selectedUserId = usr.id;
editForm = { ...usr };
isEditModalOpen = true;
saveMessage = null;
@@ -43,7 +41,6 @@
const closeModal = () => {
isEditModalOpen = false;
selectedUserId = null;
editForm = {
id: '',
name: '',
@@ -120,7 +117,7 @@
</tr>
</thead>
<tbody>
{#each filteredUsers as usr}
{#each filteredUsers as usr (usr.id)}
<tr class="border-b border-white/5 hover:bg-slate-800/50">
<td class="px-6 py-4 text-sm text-white">{usr.name}</td>
<td class="px-6 py-4 text-sm text-gray-400">{usr.email}</td>

View File

@@ -1,5 +1,7 @@
<script lang="ts">
import ProfileButton from '$lib/components/ProfileButton.svelte';
import LanguageSwitcher from '$lib/components/LanguageSwitcher.svelte';
import { resolve } from '$app/paths';
let { children, data } = $props();
</script>
@@ -7,10 +9,13 @@
<div class="min-h-screen bg-slate-950">
<header class="fixed top-0 right-0 left-0 z-50 border-b border-white/5 bg-slate-950/95 backdrop-blur">
<div class="mx-auto flex max-w-7xl items-center justify-between px-6 py-4">
<a href="/" class="text-lg font-black uppercase tracking-[0.15em] text-amber-50 transition hover:text-amber-100">
<a href={resolve("/")} class="text-lg font-black uppercase tracking-[0.15em] text-amber-50 transition hover:text-amber-100">
OnePieceDle
</a>
<ProfileButton user={data.user} />
<div class="flex items-center gap-3">
<LanguageSwitcher />
<ProfileButton user={data.user} />
</div>
</div>
</header>
<main class="pt-20">

View File

@@ -1,7 +1,59 @@
<script lang="ts">
export let data;
import { resolve } from '$app/paths';
import { language, t } from '$lib/i18n';
import type { CharacterWithRelations } from '$lib/server/daily-character';
$: yesterdayCharacter = data.yesterdayCharacter;
$: isFrench = $language === 'fr';
function parseEpithets(value: unknown): string[] {
if (Array.isArray(value)) {
return value.filter((entry): entry is string => typeof entry === 'string' && entry.length > 0);
}
if (typeof value === 'string') {
try {
const parsed = JSON.parse(value);
if (Array.isArray(parsed)) {
return parsed.filter((entry): entry is string => typeof entry === 'string' && entry.length > 0);
}
} catch {
if (value.length > 0) {
return [value];
}
}
}
return [];
}
function getDisplayName(character: CharacterWithRelations | null): string {
if (isFrench && typeof character?.frName === 'string' && character.frName.length > 0) {
return character.frName;
}
return character?.name || '';
}
function getDisplayEpithets(character: CharacterWithRelations | null): string[] {
const frenchEpithets = parseEpithets(character?.frEpithets);
if (isFrench && frenchEpithets.length > 0) {
return frenchEpithets;
}
return parseEpithets(character?.epithets);
}
function getWikiUrl(character: CharacterWithRelations | null): string {
if (isFrench && typeof character?.frUrl === 'string' && character.frUrl.length > 0) {
return character.frUrl;
}
return character?.url || '';
}
</script>
<svelte:head>
@@ -11,7 +63,7 @@
<main
class="relative min-h-[calc(100vh-5rem)] bg-slate-950 text-slate-100"
>
<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 bg-linear-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 w-full max-w-6xl flex-col items-center justify-center px-6 py-10">
@@ -21,30 +73,30 @@
OnePieceDle
</h1>
<p class="mt-4 max-w-2xl text-base text-slate-200 sm:text-lg">
Devine le personnage de l'equipage, des marines ou du vaste monde. Chaque indice te rapproche du tresor.
{$t.game.home.heroDescription}
</p>
</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">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>
<h2 class="text-sm font-semibold uppercase tracking-[0.2em] text-amber-100">{$t.game.home.dailyTitle}</h2>
<p class="mt-3 text-lg font-semibold text-white">{$t.game.home.dailySubtitle}</p>
<p class="mt-2 text-sm text-slate-200">{$t.game.home.dailyDescription}</p>
<a
href="/daily"
href={resolve("/daily")}
class="mt-5 inline-flex w-full items-center justify-center rounded-full bg-amber-300 px-5 py-3 text-sm font-semibold text-slate-900 transition hover:bg-amber-200"
>
Commencer
{$t.game.home.dailyCta}
</a>
</div>
<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">Mode Infini</h2>
<p class="mt-3 text-lg font-semibold text-white">Des defis sans fin</p>
<p class="mt-2 text-sm text-slate-200">Enchaine les personnages et croise ton score. Pas de limite, que du plaisir.</p>
<h2 class="text-sm font-semibold uppercase tracking-[0.2em] text-amber-100">{$t.game.home.infiniteTitle}</h2>
<p class="mt-3 text-lg font-semibold text-white">{$t.game.home.infiniteSubtitle}</p>
<p class="mt-2 text-sm text-slate-200">{$t.game.home.infiniteDescription}</p>
<a
href="/infinite"
href={resolve("/infinite")}
class="mt-5 inline-flex w-full items-center justify-center 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"
>
Jouer
{$t.game.home.infiniteCta}
</a>
</div>
</div>
@@ -54,43 +106,52 @@
{#if yesterdayCharacter.pictureUrl}
<img
src={yesterdayCharacter.pictureUrl}
alt={yesterdayCharacter.name}
alt={getDisplayName(yesterdayCharacter)}
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
{$t.game.home.photoFallback}
</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="text-xs font-semibold uppercase tracking-[0.28em] text-amber-100">{$t.game.home.yesterdayCharacter}</p>
<p class="mt-2 text-lg font-semibold text-white">{getDisplayName(yesterdayCharacter)}</p>
{#if getDisplayEpithets(yesterdayCharacter).length > 0}
<p class="mt-1 text-sm text-slate-400">
{typeof yesterdayCharacter.epithets === 'string'
? JSON.parse(yesterdayCharacter.epithets).join(', ')
: (yesterdayCharacter.epithets as string[]).join(', ')}
{getDisplayEpithets(yesterdayCharacter).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>
{#if isFrench}
<a
href="https://onepiece.fandom.com/fr/wiki/{getWikiUrl(yesterdayCharacter)}"
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"
>
{$t.game.home.openPage}
</a>
{:else}
<a
href="https://onepiece.fandom.com/wiki/{getWikiUrl(yesterdayCharacter)}"
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"
>
{$t.game.home.openPage}
</a>
{/if}
</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
{$t.game.home.photoFallback}
</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">Aucun personnage</p>
<p class="mt-1 text-sm text-slate-200">Aucun personnage d'hier disponible</p>
<p class="text-xs font-semibold uppercase tracking-[0.28em] text-amber-100">{$t.game.home.yesterdayCharacter}</p>
<p class="mt-2 text-lg font-semibold text-white">{$t.game.home.noCharacter}</p>
<p class="mt-1 text-sm text-slate-200">{$t.game.home.noYesterdayCharacter}</p>
</div>
</div>
{/if}

View File

@@ -1,6 +1,6 @@
import { error } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { characterHistory, config, friendship, user, userCharacterHistory } from '$lib/server/db/schema';
import { character, characterHistory, config, friendship, user, userCharacterHistory } from '$lib/server/db/schema';
import { getDailyModeCharacters, getOrCreateTodayCharacter, getYesterdayCharacter, getTodayCharacterWinsCount, getDateKey } from '$lib/server/daily-character';
import { and, eq, inArray, like, or } from 'drizzle-orm';
@@ -17,7 +17,13 @@ export async function load(event) {
// Load the win count for today
const winCount = await getTodayCharacterWinsCount(dailyCharacter.id);
let friendsTodayResults: Array<{ userId: string; name: string; image: string | null; tryCount: number }> = [];
let friendsTodayResults: Array<{
userId: string;
name: string;
image: string | null;
tryCount: number;
triedCharacters: Array<{ id: string; name: string; pictureUrl: string | null }>;
}> = [];
if (event.locals.user) {
const currentUserId = event.locals.user.id;
@@ -51,12 +57,13 @@ export async function load(event) {
const todayCharacterHistoryId = todayHistoryEntry?.id;
if (todayCharacterHistoryId) {
friendsTodayResults = await db
const friendResultsRaw = await db
.select({
userId: user.id,
name: user.name,
image: user.image,
tryCount: userCharacterHistory.tryCount
tryCount: userCharacterHistory.tryCount,
triedCharacterIds: userCharacterHistory.triedCharacterIds
})
.from(userCharacterHistory)
.innerJoin(user, eq(userCharacterHistory.userId, user.id))
@@ -67,6 +74,33 @@ export async function load(event) {
)
)
.orderBy(userCharacterHistory.tryCount);
const uniqueTriedCharacterIds = Array.from(new Set(
friendResultsRaw.flatMap((entry) => entry.triedCharacterIds ?? [])
));
const triedCharacters = uniqueTriedCharacterIds.length > 0
? await db
.select({
id: character.id,
name: character.name,
pictureUrl: character.pictureUrl
})
.from(character)
.where(inArray(character.id, uniqueTriedCharacterIds))
: [];
const triedCharactersById = new Map(triedCharacters.map((entry) => [entry.id, entry]));
friendsTodayResults = friendResultsRaw.map((entry) => ({
userId: entry.userId,
name: entry.name,
image: entry.image,
tryCount: entry.tryCount,
triedCharacters: (entry.triedCharacterIds ?? [])
.map((characterId) => triedCharactersById.get(characterId))
.filter((triedEntry): triedEntry is (typeof triedCharacters)[number] => !!triedEntry)
}));
}
}
}

View File

@@ -1,23 +1,98 @@
<script lang="ts">
import { onMount } from 'svelte';
import { onDestroy, onMount } from 'svelte';
import YesterdayCharacter from '$lib/components/YesterdayCharacter.svelte';
import HintsPanel from '$lib/components/HintsPanel.svelte';
import CharacterSearchInput from '$lib/components/CharacterSearchInput.svelte';
import GuessHistoryTable from '$lib/components/GuessHistoryTable.svelte';
import WinPanel from '$lib/components/WinPanel.svelte';
import FriendsTodaySection from '$lib/components/FriendsTodaySection.svelte';
import type { CharacterWithRelations } from '$lib/server/daily-character.js';
import { t } from '$lib/i18n';
export let data;
let selectedCharacters: any[] = [];
let selectedCharacters: CharacterWithRelations[] = [];
let isLoaded = false;
let isGeckoMoriaWin = false;
let wasOriginAvailable = false;
let wasFruitAvailable = false;
let wasAffiliationAvailable = false;
let showOriginUnlock = false;
let showFruitUnlock = false;
let showAffiliationUnlock = false;
let originUnlockTimeout: ReturnType<typeof setTimeout> | null = null;
let fruitUnlockTimeout: ReturnType<typeof setTimeout> | null = null;
let affiliationUnlockTimeout: ReturnType<typeof setTimeout> | null = null;
function clearUnlockTimeout(timeout: ReturnType<typeof setTimeout> | null) {
if (timeout) {
clearTimeout(timeout);
}
}
function pulseUnlock(type: 'origin' | 'fruit' | 'affiliation') {
if (type === 'origin') {
clearUnlockTimeout(originUnlockTimeout);
showOriginUnlock = true;
originUnlockTimeout = setTimeout(() => {
showOriginUnlock = false;
originUnlockTimeout = null;
}, 600);
return;
}
if (type === 'fruit') {
clearUnlockTimeout(fruitUnlockTimeout);
showFruitUnlock = true;
fruitUnlockTimeout = setTimeout(() => {
showFruitUnlock = false;
fruitUnlockTimeout = null;
}, 600);
return;
}
clearUnlockTimeout(affiliationUnlockTimeout);
showAffiliationUnlock = true;
affiliationUnlockTimeout = setTimeout(() => {
showAffiliationUnlock = false;
affiliationUnlockTimeout = null;
}, 600);
}
function syncHintAvailability(previousGuessCount: number, nextGuessCount: number, animateUnlocks = false) {
const nextOriginAvailable = nextGuessCount >= 5;
const nextFruitAvailable = nextGuessCount >= 10;
const nextAffiliationAvailable = nextGuessCount >= 15;
if (animateUnlocks && nextOriginAvailable && previousGuessCount < 5) {
pulseUnlock('origin');
}
if (animateUnlocks && nextFruitAvailable && previousGuessCount < 10) {
pulseUnlock('fruit');
}
if (animateUnlocks && nextAffiliationAvailable && previousGuessCount < 15) {
pulseUnlock('affiliation');
}
if (!nextOriginAvailable) {
showOriginUnlock = false;
clearUnlockTimeout(originUnlockTimeout);
originUnlockTimeout = null;
}
if (!nextFruitAvailable) {
showFruitUnlock = false;
clearUnlockTimeout(fruitUnlockTimeout);
fruitUnlockTimeout = null;
}
if (!nextAffiliationAvailable) {
showAffiliationUnlock = false;
clearUnlockTimeout(affiliationUnlockTimeout);
affiliationUnlockTimeout = null;
}
}
// Load from localStorage on mount
onMount(() => {
@@ -37,8 +112,8 @@
// 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);
.map((id: string) => data.characters.find((c: CharacterWithRelations) => c.id === id))
.filter((c: CharacterWithRelations | undefined): c is CharacterWithRelations => !!c);
}
} catch (e) {
console.error('Failed to parse stored history', e);
@@ -51,9 +126,17 @@
localStorage.setItem('dailyCurrentCharacterId', dailyCurrentCharacterId);
}
syncHintAvailability(0, selectedCharacters.length);
isLoaded = true;
});
onDestroy(() => {
clearUnlockTimeout(originUnlockTimeout);
clearUnlockTimeout(fruitUnlockTimeout);
clearUnlockTimeout(affiliationUnlockTimeout);
});
// Save to localStorage whenever selectedCharacters changes (only store IDs)
$: if (isLoaded && selectedCharacters) {
const ids = selectedCharacters.map(char => char.id);
@@ -66,42 +149,19 @@
$: columnVisibility = data.columnVisibility || {};
$: hasWon = selectedCharacters.some(char => char.id === dailyCharacter.id);
// Hint availability tracking for unlock animations
$: isOriginAvailable = selectedCharacters.length >= 5;
$: isFruitAvailable = selectedCharacters.length >= 10;
$: isAffiliationAvailable = selectedCharacters.length >= 15;
// Track hint unlocks
$: if (isLoaded) {
if (isOriginAvailable && !wasOriginAvailable) {
showOriginUnlock = true;
setTimeout(() => showOriginUnlock = false, 600);
}
wasOriginAvailable = isOriginAvailable;
if (isFruitAvailable && !wasFruitAvailable) {
showFruitUnlock = true;
setTimeout(() => showFruitUnlock = false, 600);
}
wasFruitAvailable = isFruitAvailable;
if (isAffiliationAvailable && !wasAffiliationAvailable) {
showAffiliationUnlock = true;
setTimeout(() => showAffiliationUnlock = false, 600);
}
wasAffiliationAvailable = isAffiliationAvailable;
}
function handleCharacterSelect(event: CustomEvent) {
const character = event.detail;
function handleCharacterSelect(character: CharacterWithRelations) {
selectCharacter(character);
}
function selectCharacter(character: any) {
function selectCharacter(character: CharacterWithRelations) {
const previousGuessCount = selectedCharacters.length;
selectedCharacters = [character, ...selectedCharacters];
syncHintAvailability(previousGuessCount, selectedCharacters.length, isLoaded);
// Check if player won
if (character.id === dailyCharacter.id) {
const triedCharacterIds = selectedCharacters.map(selected => selected.id);
// Send request to record win in database
fetch('/daily', {
method: 'POST',
@@ -110,7 +170,8 @@
},
body: JSON.stringify({
characterId: dailyCharacter.id,
tryCount: selectedCharacters.length
tryCount: selectedCharacters.length,
triedCharacterIds
})
}).catch(err => console.error('Failed to record win:', err));
@@ -122,13 +183,15 @@
}
function resetHistory() {
const previousGuessCount = selectedCharacters.length;
selectedCharacters = [];
syncHintAvailability(previousGuessCount, 0);
localStorage.removeItem('dailyCharacterHistory');
}
</script>
<svelte:head>
<title>OnePieceDle - Mode du jour</title>
<title>{$t.game.daily.metaTitle}</title>
<style>
@keyframes shadow-pulse {
0% {
@@ -198,7 +261,7 @@
<main
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 bg-linear-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-8 sm:py-10">
@@ -206,10 +269,10 @@
<div class="flex w-full items-center justify-between gap-4">
<div>
<h1 class="text-3xl font-black uppercase tracking-[0.25em] text-amber-50 sm:text-5xl">
Personnage du jour
{$t.game.daily.title}
</h1>
<p class="mt-2 text-sm text-amber-300">
{data.winCount} {data.winCount > 1 ? 'personnes' : 'personne'} {data.winCount > 1 ? 'ont' : 'a'} trouvé aujourd'hui 🎉
{data.winCount} {data.winCount > 1 ? $t.game.daily.winsPeoplePlural : $t.game.daily.winsPeopleSingular} {data.winCount > 1 ? $t.game.daily.winsVerbPlural : $t.game.daily.winsVerbSingular} {$t.game.daily.winsSuffix}
</p>
</div>
{#if hasWon}
@@ -217,12 +280,12 @@
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
{$t.game.daily.reset}
</button>
{/if}
</div>
<p class="max-w-2xl text-base text-slate-200 sm:text-lg">
Devine le personnage. Chaque indice se débloque après un certain nombre de tentatives. Bonne chance !
{$t.game.daily.description}
</p>
</header>
@@ -247,39 +310,14 @@
<CharacterSearchInput
{characters}
{selectedCharacters}
on:select={handleCharacterSelect}
onSelect={handleCharacterSelect}
/>
{/if}
</section>
{#if hasWon && data.friendsTodayResults && data.friendsTodayResults.length > 0}
<section class="mt-6 rounded-3xl border border-white/10 bg-white/5 p-6 shadow-[0_24px_60px_rgba(0,0,0,0.45)] backdrop-blur">
<p class="text-xs font-semibold uppercase tracking-[0.28em] text-amber-100 text-center">Tes amis aujourd'hui</p>
<div class="mt-4 space-y-2">
{#each data.friendsTodayResults as friendResult}
<div class="flex items-center justify-between rounded-lg border border-white/10 bg-slate-950/50 px-4 py-2">
<div class="flex items-center gap-3">
{#if friendResult.image}
<img
src={friendResult.image}
alt={friendResult.name}
class="h-8 w-8 rounded-full border border-white/20 object-cover"
/>
{:else}
<div class="flex h-8 w-8 items-center justify-center rounded-full bg-amber-300/20 text-xs font-semibold text-amber-100">
{friendResult.name?.charAt(0).toUpperCase() || 'U'}
</div>
{/if}
<p class="text-sm font-semibold text-slate-100">{friendResult.name}</p>
</div>
<p class="text-sm text-amber-300">
{friendResult.tryCount} {friendResult.tryCount > 1 ? 'coups' : 'coup'}
</p>
</div>
{/each}
</div>
</section>
<FriendsTodaySection friendsTodayResults={data.friendsTodayResults} />
{/if}
<GuessHistoryTable

View File

@@ -7,7 +7,10 @@ import { getDateKey } from '$lib/server/daily-character';
export async function POST({ request, locals }) {
try {
const { characterId, tryCount } = await request.json();
const { characterId, tryCount, triedCharacterIds } = await request.json();
const normalizedTriedCharacterIds = Array.isArray(triedCharacterIds)
? triedCharacterIds.filter((id): id is string => typeof id === 'string')
: [];
if (!characterId) {
return json({ error: 'Missing characterId' }, { status: 400 });
@@ -51,7 +54,8 @@ export async function POST({ request, locals }) {
await db.insert(userCharacterHistory).values({
userId: locals.user.id,
characterHistoryId: todayHistoryEntry.id,
tryCount: tryCount
tryCount: tryCount,
triedCharacterIds: normalizedTriedCharacterIds
});
}
} else {

View File

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

View File

@@ -1,28 +1,25 @@
<script lang="ts">
import { onMount } from 'svelte';
import { onDestroy, onMount } from 'svelte';
import CharacterSearchInput from '$lib/components/CharacterSearchInput.svelte';
import GuessHistoryTable from '$lib/components/GuessHistoryTable.svelte';
import WinPanel from '$lib/components/WinPanel.svelte';
import HintsPanel from '$lib/components/HintsPanel.svelte';
import type { CharacterWithRelations } from '$lib/server/daily-character.js';
import { language, t } from '$lib/i18n';
export let data;
let selectedCharacters: any[] = [];
let currentCharacter: any = null;
let selectedCharacters: CharacterWithRelations[] = [];
let currentCharacter: CharacterWithRelations | null = null;
let isLoaded = false;
let score = 0;
type ArcFilterOption = { id: string; name: string };
let allCharacters: CharacterWithRelations[] = [];
let characters: CharacterWithRelations[] = [];
let availableArcs: ArcFilterOption[] = [];
let hasWon = false;
let columnVisibility: Record<string, boolean> = {};
const columnDisplayNames: Record<string, string> = {
status: 'Statut',
gender: 'Genre',
affiliations: 'Affiliations',
devilFruitType: 'Fruit',
haki: 'Haki',
bounty: 'Prime',
height: 'Taille',
origin: 'Origine',
arc: 'Arc'
};
let columnDisplayNames: Record<string, string> = {};
// Character filters
let characterFilters = {
@@ -31,17 +28,90 @@
hasDevilFruit: null as boolean | null, // null = all, true = with fruit, false = without fruit
status: [] as string[],
hasHeight: false,
hasAge: false,
hasOrigin: false,
arcs: [] as string[]
};
let wasOriginAvailable = false;
let wasFruitAvailable = false;
let wasAffiliationAvailable = false;
let showOriginUnlock = false;
let showFruitUnlock = false;
let showAffiliationUnlock = false;
let isGeckoMoriaWin = false;
let originUnlockTimeout: ReturnType<typeof setTimeout> | null = null;
let fruitUnlockTimeout: ReturnType<typeof setTimeout> | null = null;
let affiliationUnlockTimeout: ReturnType<typeof setTimeout> | null = null;
function clearUnlockTimeout(timeout: ReturnType<typeof setTimeout> | null) {
if (timeout) {
clearTimeout(timeout);
}
}
function pulseUnlock(type: 'origin' | 'fruit' | 'affiliation') {
if (type === 'origin') {
clearUnlockTimeout(originUnlockTimeout);
showOriginUnlock = true;
originUnlockTimeout = setTimeout(() => {
showOriginUnlock = false;
originUnlockTimeout = null;
}, 600);
return;
}
if (type === 'fruit') {
clearUnlockTimeout(fruitUnlockTimeout);
showFruitUnlock = true;
fruitUnlockTimeout = setTimeout(() => {
showFruitUnlock = false;
fruitUnlockTimeout = null;
}, 600);
return;
}
clearUnlockTimeout(affiliationUnlockTimeout);
showAffiliationUnlock = true;
affiliationUnlockTimeout = setTimeout(() => {
showAffiliationUnlock = false;
affiliationUnlockTimeout = null;
}, 600);
}
function syncHintAvailability(previousGuessCount: number, nextGuessCount: number, animateUnlocks = false) {
const nextOriginAvailable = nextGuessCount >= 5;
const nextFruitAvailable = nextGuessCount >= 10;
const nextAffiliationAvailable = nextGuessCount >= 15;
if (animateUnlocks && nextOriginAvailable && previousGuessCount < 5) {
pulseUnlock('origin');
}
if (animateUnlocks && nextFruitAvailable && previousGuessCount < 10) {
pulseUnlock('fruit');
}
if (animateUnlocks && nextAffiliationAvailable && previousGuessCount < 15) {
pulseUnlock('affiliation');
}
if (!nextOriginAvailable) {
showOriginUnlock = false;
clearUnlockTimeout(originUnlockTimeout);
originUnlockTimeout = null;
}
if (!nextFruitAvailable) {
showFruitUnlock = false;
clearUnlockTimeout(fruitUnlockTimeout);
fruitUnlockTimeout = null;
}
if (!nextAffiliationAvailable) {
showAffiliationUnlock = false;
clearUnlockTimeout(affiliationUnlockTimeout);
affiliationUnlockTimeout = null;
}
}
// Load from localStorage on mount
onMount(() => {
@@ -56,6 +126,7 @@
try {
columnVisibility = JSON.parse(storedColumnVisibility);
} catch (e) {
console.error('Failed to parse column visibility', e);
columnVisibility = data.columnVisibility || {};
}
} else {
@@ -71,6 +142,9 @@
if (!characterFilters.arcs) {
characterFilters.arcs = [];
}
if (typeof characterFilters.hasAge !== 'boolean') {
characterFilters.hasAge = false;
}
} catch (e) {
console.error('Failed to parse filters', e);
}
@@ -86,18 +160,19 @@
const historyIds = JSON.parse(storedHistoryIds);
// Find the character object by ID
currentCharacter = characters.find((c: any) => c.id === charId);
currentCharacter = characters.find((c: CharacterWithRelations) => c.id === charId) || null;
// Find all character objects by their IDs
selectedCharacters = historyIds
.map((id: string) => characters.find((c: any) => c.id === id))
.filter((c: any) => c !== undefined);
.map((id: string) => characters.find((c: CharacterWithRelations) => c.id === id))
.filter((c: CharacterWithRelations | undefined) => !!c) as CharacterWithRelations[];
// If character not found, generate a new one
if (!currentCharacter) {
generateNewCharacter();
}
} catch (e) {
console.error('Failed to parse character data', e);
// If parsing fails, generate a new character
generateNewCharacter();
}
@@ -105,9 +180,16 @@
generateNewCharacter();
}
syncHintAvailability(0, selectedCharacters.length);
isLoaded = true;
});
onDestroy(() => {
clearUnlockTimeout(originUnlockTimeout);
clearUnlockTimeout(fruitUnlockTimeout);
clearUnlockTimeout(affiliationUnlockTimeout);
});
// Save score to localStorage whenever it changes
$: if (isLoaded) {
localStorage.setItem('infiniteScore', score.toString());
@@ -130,26 +212,46 @@
// Save selected character IDs to localStorage whenever it changes
$: if (isLoaded) {
const selectedIds = selectedCharacters.map((c: any) => c.id);
const selectedIds = selectedCharacters.map((c: CharacterWithRelations) => c.id);
localStorage.setItem('infiniteSelectedCharacterIds', JSON.stringify(selectedIds));
}
$: allCharacters = data.characters || [];
$: isFrench = $language === 'fr';
function getDisplayArcName(character: CharacterWithRelations, useFrench: boolean): string | null {
if (useFrench && typeof character.frArcName === 'string' && character.frArcName.length > 0) {
return character.frArcName;
}
return character.arcName;
}
// Extract unique arcs from all characters
$: availableArcs = [
...new Map(
$: {
const useFrench = isFrench;
const arcMap = new Map<string, ArcFilterOption>(
allCharacters
.filter((char: any) => char.arcId && char.arcName)
.map((char: any) => [char.arcId, { id: char.arcId, name: char.arcName }])
).values()
]
.sort((a: any, b: any) => (a.name || '').localeCompare(b.name || ''));
.filter(
(char: CharacterWithRelations): char is CharacterWithRelations & { arcId: string } =>
typeof char.arcId === 'string' &&
char.arcId.length > 0 &&
typeof getDisplayArcName(char, useFrench) === 'string' &&
getDisplayArcName(char, useFrench)!.length > 0
)
.map((char: CharacterWithRelations & { arcId: string }) => [
char.arcId,
{ id: char.arcId, name: getDisplayArcName(char, useFrench) as string }
])
);
availableArcs = [...arcMap.values()].sort((a, b) => a.name.localeCompare(b.name));
}
// Filter characters based on selected filters
$: characters = allCharacters.filter((char: any) => {
$: characters = allCharacters.filter((char: CharacterWithRelations) => {
// Gender filter
if (characterFilters.gender.length > 0 && !characterFilters.gender.includes(char.gender)) {
if (characterFilters.gender.length > 0 && (char.gender == null || !characterFilters.gender.includes(char.gender))) {
return false;
}
@@ -179,67 +281,68 @@
return false;
}
// Age filter
if (characterFilters.hasAge && (char.age === null || char.age === undefined)) {
return false;
}
// Origin filter
if (characterFilters.hasOrigin && (char.origin === null || char.origin === undefined || char.origin === '')) {
return false;
}
// Arc filter
if (characterFilters.arcs.length > 0 && !characterFilters.arcs.includes(char.arcId)) {
if (characterFilters.arcs.length > 0 && (char.arcId == null || !characterFilters.arcs.includes(char.arcId))) {
return false;
}
return true;
});
$: hasWon = currentCharacter && selectedCharacters.some(char => char.id === currentCharacter.id);
$: {
const currentCharacterId = currentCharacter?.id;
hasWon = currentCharacterId != null && selectedCharacters.some(char => char.id === currentCharacterId);
}
$: if (hasWon && currentCharacter?.id === 'gecko_moria_gecko_moria') {
isGeckoMoriaWin = true;
} else if (!hasWon) {
isGeckoMoriaWin = false;
}
// Hint availability tracking for unlock animations
$: isOriginAvailable = selectedCharacters.length >= 5;
$: isFruitAvailable = selectedCharacters.length >= 10;
$: isAffiliationAvailable = selectedCharacters.length >= 15;
// Track hint unlocks
$: if (isLoaded) {
if (isOriginAvailable && !wasOriginAvailable) {
showOriginUnlock = true;
setTimeout(() => (showOriginUnlock = false), 600);
}
wasOriginAvailable = isOriginAvailable;
if (isFruitAvailable && !wasFruitAvailable) {
showFruitUnlock = true;
setTimeout(() => (showFruitUnlock = false), 600);
}
wasFruitAvailable = isFruitAvailable;
if (isAffiliationAvailable && !wasAffiliationAvailable) {
showAffiliationUnlock = true;
setTimeout(() => (showAffiliationUnlock = false), 600);
}
wasAffiliationAvailable = isAffiliationAvailable;
}
$: columnDisplayNames = {
status: $t.game.components.guessHistory.status,
gender: $t.game.components.guessHistory.gender,
affiliations: $t.game.components.guessHistory.affiliations,
devilFruitType: $t.game.components.guessHistory.fruit,
haki: $t.game.components.guessHistory.haki,
bounty: $t.game.components.guessHistory.bounty,
height: $t.game.components.guessHistory.height,
age: $t.game.components.guessHistory.age,
origin: $t.game.components.guessHistory.origin,
arc: $t.game.components.guessHistory.arc
};
function generateNewCharacter() {
if (characters.length === 0) return;
currentCharacter = characters[Math.floor(Math.random() * characters.length)];
syncHintAvailability(selectedCharacters.length, 0);
selectedCharacters = [];
}
function handleCharacterSelect(event: CustomEvent) {
const character = event.detail;
function handleCharacterSelect(character: CharacterWithRelations) {
selectCharacter(character);
}
function selectCharacter(character: any) {
function selectCharacter(character: CharacterWithRelations) {
const current = currentCharacter;
if (!current) {
return;
}
const previousGuessCount = selectedCharacters.length;
selectedCharacters = [character, ...selectedCharacters];
syncHintAvailability(previousGuessCount, selectedCharacters.length, isLoaded);
// Check if player won
if (character.id === currentCharacter.id) {
if (character.id === current.id) {
// Increment score (saved to localStorage via reactive statement)
score++;
// Don't auto-generate next character - wait for user to click "Recommencer"
@@ -265,10 +368,16 @@
}
function revealAnswer() {
if (!currentCharacter) {
return;
}
// Reset score (strike)
score = 0;
// Add the current character as the correct answer
const previousGuessCount = selectedCharacters.length;
selectedCharacters = [currentCharacter, ...selectedCharacters];
syncHintAvailability(previousGuessCount, selectedCharacters.length, isLoaded);
}
function toggleGenderFilter(gender: string) {
@@ -325,6 +434,13 @@
}
}
function toggleAgeFilter() {
characterFilters.hasAge = !characterFilters.hasAge;
if (!hasWon) {
generateNewCharacter();
}
}
function toggleOriginFilter() {
characterFilters.hasOrigin = !characterFilters.hasOrigin;
// Regenerate character with new filters
@@ -352,6 +468,7 @@
hasDevilFruit: null,
status: [],
hasHeight: false,
hasAge: false,
hasOrigin: false,
arcs: []
};
@@ -392,20 +509,26 @@
</script>
<svelte:head>
<title>OnePieceDle - Mode Infini</title>
<title>{$t.game.infinite.metaTitle}</title>
<style>
@keyframes shadow-pulse {
0% {
text-shadow: 0 0 20px rgba(0, 0, 0, 0.5), 0 0 40px rgba(50, 50, 50, 0.8);
text-shadow:
0 0 20px rgba(0, 0, 0, 0.5),
0 0 40px rgba(50, 50, 50, 0.8);
opacity: 1;
}
50% {
text-shadow: 0 0 60px rgba(0, 0, 0, 0.9), 0 0 100px rgba(30, 30, 30, 1),
text-shadow:
0 0 60px rgba(0, 0, 0, 0.9),
0 0 100px rgba(30, 30, 30, 1),
inset 0 0 50px rgba(0, 0, 0, 0.7);
opacity: 0.9;
}
100% {
text-shadow: 0 0 20px rgba(0, 0, 0, 0.5), 0 0 40px rgba(50, 50, 50, 0.8);
text-shadow:
0 0 20px rgba(0, 0, 0, 0.5),
0 0 40px rgba(50, 50, 50, 0.8);
opacity: 1;
}
}
@@ -461,34 +584,37 @@
</svelte:head>
<main
class="relative min-h-screen overflow-hidden bg-slate-950 text-slate-100 {isGeckoMoriaWin ? 'moria-screen-chaos' : ''}"
class="relative min-h-screen overflow-hidden bg-slate-950 text-slate-100 {isGeckoMoriaWin
? 'moria-screen-chaos'
: ''}"
>
<div class="absolute inset-0 bg-gradient-to-br from-slate-950/85 via-slate-900/60 to-slate-950/80"></div>
<div
class="absolute inset-0 mix-blend-screen opacity-20 bg-[radial-gradient(circle_at_top,rgba(255,215,84,0.35),transparent_55%)]"
class="absolute inset-0 bg-linear-to-br from-slate-950/85 via-slate-900/60 to-slate-950/80"
></div>
<div
class="absolute inset-0 bg-[radial-gradient(circle_at_top,rgba(255,215,84,0.35),transparent_55%)] opacity-20 mix-blend-screen"
></div>
<div class="relative mx-auto flex min-h-screen w-full max-w-6xl flex-col px-6 py-8 sm:py-10">
<header class="flex flex-col items-start gap-6 w-full">
<header class="flex w-full flex-col items-start gap-6">
<div class="flex w-full items-center justify-between gap-4">
<div>
<h1 class="text-3xl font-black uppercase tracking-[0.25em] text-amber-50 sm:text-5xl">
Mode Infini
<h1 class="text-3xl font-black tracking-[0.25em] text-amber-50 uppercase sm:text-5xl">
{$t.game.infinite.title}
</h1>
<p class="mt-2 text-2xl font-bold text-amber-300">Score: {score}</p>
<p class="mt-2 text-2xl font-bold text-amber-300">{$t.game.infinite.score}: {score}</p>
</div>
{#if score > 0}
<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={resetScore}
>
Réinitialiser
{$t.game.infinite.resetScore}
</button>
{/if}
</div>
<p class="max-w-2xl text-base text-slate-200 sm:text-lg">
Devine des personnages à l'infini ! Chaque indice se débloque après un certain nombre de
tentatives. Bonne chance !
{$t.game.infinite.description}
</p>
</header>
@@ -496,17 +622,13 @@
{#if currentCharacter}
{#if hasWon}
<div>
<WinPanel
selectedCharacter={currentCharacter}
{selectedCharacters}
{isGeckoMoriaWin}
/>
<WinPanel selectedCharacter={currentCharacter} {selectedCharacters} {isGeckoMoriaWin} />
<button
type="button"
onclick={nextCharacter}
class="mt-4 w-full rounded-full bg-emerald-500 px-6 py-2 text-sm font-semibold text-white transition hover:bg-emerald-600"
>
Recommencer
{$t.game.infinite.nextCharacter}
</button>
</div>
{:else}
@@ -518,25 +640,27 @@
{showFruitUnlock}
{showAffiliationUnlock}
/>
<div class="flex justify-center mt-2">
<div class="mt-2 flex justify-center">
<button
type="button"
onclick={revealAnswer}
class="rounded-lg border border-red-600/40 bg-red-900/20 px-4 py-2 text-sm text-red-300 transition hover:border-red-500 hover:bg-red-900/40 hover:text-red-200"
>
Révéler la réponse
{$t.game.infinite.revealAnswer}
</button>
</div>
{/if}
<CharacterSearchInput
{characters}
{selectedCharacters}
on:select={handleCharacterSelect}
onSelect={handleCharacterSelect}
/>
{/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">
<p class="text-center text-slate-300">Chargement du personnage...</p>
<div
class="rounded-3xl border border-white/10 bg-white/5 p-6 shadow-[0_24px_60px_rgba(0,0,0,0.45)] backdrop-blur"
>
<p class="text-center text-slate-300">{$t.game.infinite.loadingCharacter}</p>
</div>
{/if}
</section>
@@ -550,16 +674,18 @@
<!-- Character Filters -->
<section class="mt-6">
<div class="rounded-2xl border border-white/10 bg-white/5 p-3 sm:p-4 backdrop-blur">
<div class="rounded-2xl border border-white/10 bg-white/5 p-3 backdrop-blur sm:p-4">
<div class="mb-3 flex items-center justify-between gap-3">
<h3 class="text-xs font-semibold uppercase tracking-[0.2em] text-amber-200">Filtres de personnages</h3>
{#if characterFilters.gender.length > 0 || characterFilters.hasHaki || characterFilters.hasDevilFruit !== null || characterFilters.status.length > 0 || characterFilters.hasHeight || characterFilters.hasOrigin || characterFilters.arcs.length > 0}
<h3 class="text-xs font-semibold tracking-[0.2em] text-amber-200 uppercase">
{$t.game.infinite.filtersTitle}
</h3>
{#if characterFilters.gender.length > 0 || characterFilters.hasHaki || characterFilters.hasDevilFruit !== null || characterFilters.status.length > 0 || characterFilters.hasHeight || characterFilters.hasAge || characterFilters.hasOrigin || characterFilters.arcs.length > 0}
<button
type="button"
onclick={clearAllFilters}
class="text-xs text-red-300 hover:text-red-200 transition"
class="text-xs text-red-300 transition hover:text-red-200"
>
Réinitialiser
{$t.game.infinite.clearFilters}
</button>
{/if}
</div>
@@ -567,17 +693,19 @@
<div class="space-y-3">
<!-- Gender Filter -->
<div>
<p class="text-xs text-slate-400 mb-2">Genre</p>
<p class="mb-2 text-xs text-slate-400">{$t.game.infinite.filterGender}</p>
<div class="flex flex-wrap gap-2">
{#each ['Male', 'Female'] as gender}
{#each ['Male', 'Female'] as gender (gender)}
<button
type="button"
onclick={() => toggleGenderFilter(gender)}
class="rounded-full border px-2.5 py-1 text-xs font-medium transition-colors {characterFilters.gender.includes(gender)
class="rounded-full border px-2.5 py-1 text-xs font-medium transition-colors {characterFilters.gender.includes(
gender
)
? 'border-amber-300/50 bg-amber-300/10 text-amber-100 hover:bg-amber-300/20'
: 'border-white/20 bg-slate-900/40 text-slate-400 hover:bg-slate-900/60'}"
>
{gender === 'Male' ? 'Homme' : 'Femme'}
{gender === 'Male' ? $t.game.infinite.male : $t.game.infinite.female}
</button>
{/each}
</div>
@@ -585,17 +713,19 @@
<!-- Status Filter -->
<div>
<p class="text-xs text-slate-400 mb-2">Statut</p>
<p class="mb-2 text-xs text-slate-400">{$t.game.infinite.filterStatus}</p>
<div class="flex flex-wrap gap-2">
{#each ['Alive', 'Dead', 'Unknown'] as status}
{#each ['Alive', 'Dead', 'Unknown'] as status (status)}
<button
type="button"
onclick={() => toggleStatusFilter(status)}
class="rounded-full border px-2.5 py-1 text-xs font-medium transition-colors {characterFilters.status.includes(status)
class="rounded-full border px-2.5 py-1 text-xs font-medium transition-colors {characterFilters.status.includes(
status
)
? 'border-amber-300/50 bg-amber-300/10 text-amber-100 hover:bg-amber-300/20'
: 'border-white/20 bg-slate-900/40 text-slate-400 hover:bg-slate-900/60'}"
>
{status === 'Alive' ? 'Vivant' : status === 'Dead' ? 'Mort' : 'Inconnu'}
{status === 'Alive' ? $t.game.infinite.alive : status === 'Dead' ? $t.game.infinite.dead : $t.game.infinite.unknown}
</button>
{/each}
</div>
@@ -603,7 +733,7 @@
<!-- Haki Filter -->
<div>
<p class="text-xs text-slate-400 mb-2">Capacités</p>
<p class="mb-2 text-xs text-slate-400">{$t.game.infinite.filterAbilities}</p>
<div class="flex flex-wrap gap-2">
<button
type="button"
@@ -612,25 +742,30 @@
? 'border-amber-300/50 bg-amber-300/10 text-amber-100 hover:bg-amber-300/20'
: 'border-white/20 bg-slate-900/40 text-slate-400 hover:bg-slate-900/60'}"
>
A du Haki
{$t.game.infinite.hasHaki}
</button>
<button
type="button"
onclick={toggleDevilFruitFilter}
class="rounded-full border px-2.5 py-1 text-xs font-medium transition-colors {characterFilters.hasDevilFruit === true
class="rounded-full border px-2.5 py-1 text-xs font-medium transition-colors {characterFilters.hasDevilFruit ===
true
? 'border-amber-300/50 bg-amber-300/10 text-amber-100 hover:bg-amber-300/20'
: characterFilters.hasDevilFruit === false
? 'border-purple-300/50 bg-purple-300/10 text-purple-100 hover:bg-purple-300/20'
: 'border-white/20 bg-slate-900/40 text-slate-400 hover:bg-slate-900/60'}"
? 'border-purple-300/50 bg-purple-300/10 text-purple-100 hover:bg-purple-300/20'
: 'border-white/20 bg-slate-900/40 text-slate-400 hover:bg-slate-900/60'}"
>
{characterFilters.hasDevilFruit === null ? 'Fruit (Tous)' : characterFilters.hasDevilFruit ? 'Avec Fruit' : 'Sans Fruit'}
{characterFilters.hasDevilFruit === null
? $t.game.infinite.fruitAll
: characterFilters.hasDevilFruit
? $t.game.infinite.withFruit
: $t.game.infinite.withoutFruit}
</button>
</div>
</div>
<!-- Informations Filter -->
<div>
<p class="text-xs text-slate-400 mb-2">Informations</p>
<p class="mb-2 text-xs text-slate-400">{$t.game.infinite.filterInformation}</p>
<div class="flex flex-wrap gap-2">
<button
type="button"
@@ -639,7 +774,16 @@
? 'border-amber-300/50 bg-amber-300/10 text-amber-100 hover:bg-amber-300/20'
: 'border-white/20 bg-slate-900/40 text-slate-400 hover:bg-slate-900/60'}"
>
Taille définie
{$t.game.infinite.heightDefined}
</button>
<button
type="button"
onclick={toggleAgeFilter}
class="rounded-full border px-2.5 py-1 text-xs font-medium transition-colors {characterFilters.hasAge
? 'border-amber-300/50 bg-amber-300/10 text-amber-100 hover:bg-amber-300/20'
: 'border-white/20 bg-slate-900/40 text-slate-400 hover:bg-slate-900/60'}"
>
{$t.game.infinite.ageDefined}
</button>
<button
type="button"
@@ -648,20 +792,22 @@
? 'border-amber-300/50 bg-amber-300/10 text-amber-100 hover:bg-amber-300/20'
: 'border-white/20 bg-slate-900/40 text-slate-400 hover:bg-slate-900/60'}"
>
Origine définie
{$t.game.infinite.originDefined}
</button>
</div>
</div>
<!-- Arc Filter -->
<div>
<p class="text-xs text-slate-400 mb-2">Arcs</p>
<p class="mb-2 text-xs text-slate-400">{$t.game.infinite.filterArcs}</p>
<div class="flex flex-wrap gap-2">
{#each availableArcs as arc (arc.id)}
<button
type="button"
onclick={() => toggleArcFilter(arc.id)}
class="rounded-full border px-2.5 py-1 text-xs font-medium transition-colors {characterFilters.arcs.includes(arc.id)
class="rounded-full border px-2.5 py-1 text-xs font-medium transition-colors {characterFilters.arcs.includes(
arc.id
)
? 'border-amber-300/50 bg-amber-300/10 text-amber-100 hover:bg-amber-300/20'
: 'border-white/20 bg-slate-900/40 text-slate-400 hover:bg-slate-900/60'}"
>
@@ -671,8 +817,8 @@
</div>
</div>
<p class="text-xs text-slate-500 mt-2">
{characters.length} personnage{characters.length > 1 ? 's' : ''} disponible{characters.length > 1 ? 's' : ''}
<p class="mt-2 text-xs text-slate-500">
{characters.length} {characters.length > 1 ? $t.game.infinite.availableCharactersPlural : $t.game.infinite.availableCharactersSingular}
</p>
</div>
</div>
@@ -680,11 +826,15 @@
<!-- Column Visibility Toggle -->
<section class="mt-6">
<div class="rounded-2xl border border-white/10 bg-white/5 p-3 sm:p-4 backdrop-blur">
<div class="rounded-2xl border border-white/10 bg-white/5 p-3 backdrop-blur sm:p-4">
<div class="mb-3 flex items-center justify-between gap-3">
<h3 class="text-xs font-semibold uppercase tracking-[0.2em] text-amber-200">Colonnes</h3>
<h3 class="text-xs font-semibold tracking-[0.2em] text-amber-200 uppercase">
{$t.game.infinite.columnsTitle}
</h3>
<p class="text-xs text-slate-400">
{Object.values(columnVisibility).filter(Boolean).length}/{Object.keys(columnVisibility).length}
{Object.values(columnVisibility).filter(Boolean).length}/{Object.keys(
columnVisibility
).length}
</p>
</div>
<div class="flex flex-wrap gap-2">

View File

@@ -1,5 +1,7 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { resolve } from '$app/paths';
import { t } from '$lib/i18n';
import type { ActionData } from './$types';
export let form: ActionData;
@@ -24,11 +26,11 @@
</script>
<svelte:head>
<title>OnePieceDle - {isSignUp ? 'Inscription' : 'Connexion'}</title>
<title>OnePieceDle - {isSignUp ? $t.game.login.titleSignUp : $t.game.login.titleSignIn}</title>
</svelte:head>
<main class="relative min-h-[calc(100vh-5rem)] bg-slate-950 text-slate-100">
<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 bg-linear-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>
@@ -41,7 +43,7 @@
OnePieceDle
</h1>
<p class="mt-4 text-slate-300">
{isSignUp ? 'Créer votre compte' : 'Bienvenue, pirate'}
{isSignUp ? $t.game.login.headerSignUp : $t.game.login.headerSignIn}
</p>
</div>
@@ -63,7 +65,7 @@
{#if isSignUp}
<div>
<label for="name" class="block text-sm font-semibold uppercase tracking-[0.2em] text-amber-100">
Nom
{$t.game.login.nameLabel}
</label>
<input
id="name"
@@ -71,7 +73,7 @@
name="name"
bind:value={name}
required
placeholder="Votre nom"
placeholder={$t.game.login.namePlaceholder}
class="mt-3 w-full rounded-lg border border-white/10 bg-white/5 px-4 py-3 text-white placeholder-slate-500 transition focus:border-amber-300 focus:outline-none focus:ring-2 focus:ring-amber-300/30"
/>
</div>
@@ -81,7 +83,7 @@
{#if isSignUp}
<div>
<label for="username" class="block text-sm font-semibold uppercase tracking-[0.2em] text-amber-100">
Nom d'utilisateur
{$t.game.login.usernameLabel}
</label>
<input
id="username"
@@ -89,7 +91,7 @@
name="username"
bind:value={username}
required
placeholder="ex: luffy_gear5"
placeholder={$t.game.login.usernamePlaceholder}
class="mt-3 w-full rounded-lg border border-white/10 bg-white/5 px-4 py-3 text-white placeholder-slate-500 transition focus:border-amber-300 focus:outline-none focus:ring-2 focus:ring-amber-300/30"
/>
</div>
@@ -98,7 +100,7 @@
<!-- Email / Username Field -->
<div>
<label for="identifier" class="block text-sm font-semibold uppercase tracking-[0.2em] text-amber-100">
{isSignUp ? 'E-mail' : 'E-mail ou nom d\'utilisateur'}
{isSignUp ? $t.game.login.identifierLabelSignUp : $t.game.login.identifierLabelSignIn}
</label>
<input
id="identifier"
@@ -106,7 +108,7 @@
name={isSignUp ? 'email' : 'identifier'}
bind:value={email}
required
placeholder={isSignUp ? 'votremail@email.com' : 'votremail@email.com ou luffy_gear5'}
placeholder={isSignUp ? $t.game.login.identifierPlaceholderSignUp : $t.game.login.identifierPlaceholderSignIn}
class="mt-3 w-full rounded-lg border border-white/10 bg-white/5 px-4 py-3 text-white placeholder-slate-500 transition focus:border-amber-300 focus:outline-none focus:ring-2 focus:ring-amber-300/30"
/>
</div>
@@ -114,7 +116,7 @@
<!-- Password Field -->
<div>
<label for="password" class="block text-sm font-semibold uppercase tracking-[0.2em] text-amber-100">
Mot de passe
{$t.game.login.passwordLabel}
</label>
<input
id="password"
@@ -134,7 +136,7 @@
for="confirmPassword"
class="block text-sm font-semibold uppercase tracking-[0.2em] text-amber-100"
>
Confirmer le mot de passe
{$t.game.login.confirmPasswordLabel}
</label>
<input
id="confirmPassword"
@@ -161,20 +163,20 @@
disabled={isLoading}
class="w-full rounded-full bg-amber-300 px-6 py-3 text-sm font-semibold text-slate-900 transition disabled:opacity-50 hover:bg-amber-200"
>
{isLoading ? 'Chargement...' : isSignUp ? 'Créer un compte' : 'Se connecter'}
{isLoading ? $t.game.login.loading : isSignUp ? $t.game.login.submitSignUp : $t.game.login.submitSignIn}
</button>
</form>
<!-- Toggle Sign Up / Login -->
<div class="mt-6 border-t border-white/10 pt-6">
<p class="text-center text-sm text-slate-400">
{isSignUp ? 'Vous avez déjà un compte ?' : "Vous n'avez pas de compte ?"}
{isSignUp ? $t.game.login.togglePromptSignUp : $t.game.login.togglePromptSignIn}
<button
type="button"
on:click={handleToggle}
class="text-amber-300 transition hover:text-amber-200"
>
{isSignUp ? 'Se connecter' : "S'inscrire"}
{isSignUp ? $t.game.login.toggleActionSignUp : $t.game.login.toggleActionSignIn}
</button>
</p>
</div>
@@ -182,8 +184,8 @@
<!-- Back to Home -->
<div class="text-center">
<a href="/" class="text-sm text-slate-400 transition hover:text-slate-300">
Retour à l'accueil
<a href={resolve("/")} class="text-sm text-slate-400 transition hover:text-slate-300">
{$t.game.login.backHome}
</a>
</div>
</div>

View File

@@ -3,7 +3,7 @@ import type { Actions, PageServerLoad } from './$types';
import { auth } from '$lib/server/auth';
import { db } from '$lib/server/db';
import { session, userCharacterHistory, characterHistory, character, friendship, user } from '$lib/server/db/schema';
import { and, desc, eq, or, sql } from 'drizzle-orm';
import { and, desc, eq, inArray, or, sql } from 'drizzle-orm';
import { APIError } from 'better-auth/api';
export const load: PageServerLoad = async (event) => {
@@ -20,12 +20,13 @@ export const load: PageServerLoad = async (event) => {
.where(eq(session.userId, event.locals.user.id));
// Fetch daily history for this user
const dailyHistory = await db
const dailyHistoryRaw = await db
.select({
id: userCharacterHistory.id,
characterId: characterHistory.characterId,
date: characterHistory.date,
tryCount: userCharacterHistory.tryCount,
triedCharacterIds: userCharacterHistory.triedCharacterIds,
won: characterHistory.won,
characterName: character.name,
characterImage: character.pictureUrl
@@ -36,6 +37,30 @@ export const load: PageServerLoad = async (event) => {
.where(eq(userCharacterHistory.userId, event.locals.user.id))
.orderBy(desc(characterHistory.date));
const uniqueTriedCharacterIds = Array.from(new Set(
dailyHistoryRaw.flatMap((entry) => entry.triedCharacterIds ?? [])
));
const triedCharacters = uniqueTriedCharacterIds.length > 0
? await db
.select({
id: character.id,
name: character.name,
pictureUrl: character.pictureUrl
})
.from(character)
.where(inArray(character.id, uniqueTriedCharacterIds))
: [];
const triedCharactersById = new Map(triedCharacters.map((entry) => [entry.id, entry]));
const dailyHistory = dailyHistoryRaw.map((entry) => ({
...entry,
triedCharacters: (entry.triedCharacterIds ?? [])
.map((characterId) => triedCharactersById.get(characterId))
.filter((triedEntry): triedEntry is (typeof triedCharacters)[number] => !!triedEntry)
}));
const incomingRequests = await db
.select({
id: friendship.id,
@@ -190,6 +215,7 @@ export const actions: Actions = {
// Delete the session from database
await db.delete(session).where(eq(session.id, sessionId));
} catch (error) {
console.error('Error revoking session:', error);
return fail(500, { message: 'Erreur lors de la révocation de la session' });
}

View File

@@ -1,51 +1,56 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { PageData } from './$types';
import { resolve } from '$app/paths';
import { t, language } from '$lib/i18n';
interface Props {
data: PageData;
form?: { success?: boolean; message?: string } | null;
}
interface DailyHistoryEntry {
id: string;
characterId: string | null;
date: number;
tryCount: number;
won: number;
characterName: string;
characterImage: string | null;
triedCharacters?: Array<{
id: string;
name: string;
pictureUrl: string | null;
}>;
}
let { data, form }: Props = $props();
let isLoading = $state(false);
let activeTab = $state<'profile' | 'password' | 'sessions' | 'daily' | 'friends'>('profile');
let name = $state('');
let name = $derived(data.user?.name || '');
let friendUsername = $state('');
let showSuccess = $state(false);
let oldPassword = $state('');
let newPassword = $state('');
let confirmPassword = $state('');
let sessions = $state<any[]>([]);
let dailyHistory = $state<any[]>([]);
let friends = $state<any[]>([]);
let incomingRequests = $state<any[]>([]);
let outgoingRequests = $state<any[]>([]);
let sessions = $derived(data.sessions || []);
let dailyHistory = $derived((data.dailyHistory || []) as DailyHistoryEntry[]);
let friends = $derived(data.friends || []);
let incomingRequests = $derived(data.incomingRequests || []);
let outgoingRequests = $derived(data.outgoingRequests || []);
let tabsElement: HTMLDivElement | undefined;
$effect(() => {
name = data.user?.name || '';
friends = data.friends || [];
});
$effect(() => {
sessions = (data as any).sessions || [];
incomingRequests = data.incomingRequests || [];
});
$effect(() => {
dailyHistory = (data as any).dailyHistory || [];
});
$effect(() => {
friends = (data as any).friends || [];
});
$effect(() => {
incomingRequests = (data as any).incomingRequests || [];
});
$effect(() => {
outgoingRequests = (data as any).outgoingRequests || [];
outgoingRequests = data.outgoingRequests || [];
});
$effect(() => {
@@ -67,11 +72,11 @@
</script>
<svelte:head>
<title>Mon Profil - OnePieceDle</title>
<title>{$t.game.profile.pageTitle} - OnePieceDle</title>
</svelte:head>
<main class="relative min-h-[calc(100vh-5rem)] bg-slate-950 text-slate-100">
<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 bg-linear-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 w-full max-w-2xl flex-col items-center px-6 py-4">
@@ -79,10 +84,10 @@
<!-- Header -->
<div class="text-center">
<h1 class="text-3xl font-black uppercase tracking-[0.3em] text-amber-50 sm:text-4xl">
Mon Profil
{$t.game.profile.headerTitle}
</h1>
<p class="mt-2 text-sm text-slate-300">
Modifie les informations de ton profil
{$t.game.profile.headerSubtitle}
</p>
</div>
@@ -90,43 +95,43 @@
<div bind:this={tabsElement} class="sticky top-20 z-10 flex gap-2 border-b border-white/10 bg-slate-950/80 backdrop-blur">
<button
onclick={() => handleTabChange('profile')}
class="px-4 py-3 font-semibold uppercase tracking-[0.1em] transition {activeTab === 'profile'
class="px-4 py-3 font-semibold uppercase tracking-widest transition {activeTab === 'profile'
? 'border-b-2 border-amber-300 text-amber-100'
: 'text-slate-400 hover:text-slate-100'}"
>
Profil
{$t.game.profile.tabProfile}
</button>
<button
onclick={() => handleTabChange('password')}
class="px-4 py-3 font-semibold uppercase tracking-[0.1em] transition {activeTab === 'password'
class="px-4 py-3 font-semibold uppercase tracking-widest transition {activeTab === 'password'
? 'border-b-2 border-amber-300 text-amber-100'
: 'text-slate-400 hover:text-slate-100'}"
>
Mot de passe
{$t.game.profile.tabPassword}
</button>
<button
onclick={() => handleTabChange('daily')}
class="px-4 py-3 font-semibold uppercase tracking-[0.1em] transition {activeTab === 'daily'
class="px-4 py-3 font-semibold uppercase tracking-widest transition {activeTab === 'daily'
? 'border-b-2 border-amber-300 text-amber-100'
: 'text-slate-400 hover:text-slate-100'}"
>
Historique Daily
{$t.game.profile.tabDaily}
</button>
<button
onclick={() => handleTabChange('sessions')}
class="px-4 py-3 font-semibold uppercase tracking-[0.1em] transition {activeTab === 'sessions'
class="px-4 py-3 font-semibold uppercase tracking-widest transition {activeTab === 'sessions'
? 'border-b-2 border-amber-300 text-amber-100'
: 'text-slate-400 hover:text-slate-100'}"
>
Sessions
{$t.game.profile.tabSessions}
</button>
<button
onclick={() => handleTabChange('friends')}
class="px-4 py-3 font-semibold uppercase tracking-[0.1em] transition {activeTab === 'friends'
class="px-4 py-3 font-semibold uppercase tracking-widest transition {activeTab === 'friends'
? 'border-b-2 border-amber-300 text-amber-100'
: 'text-slate-400 hover:text-slate-100'}"
>
Amis
{$t.game.profile.tabFriends}
</button>
</div>
@@ -138,7 +143,7 @@
{#if data.user.image}
<img
src={data.user.image}
alt={data.user.name || 'Profil'}
alt={data.user.name || $t.game.profile.avatarFallbackAlt}
class="h-24 w-24 rounded-full border-2 border-amber-300 object-cover"
/>
{:else}
@@ -147,7 +152,7 @@
</div>
{/if}
<div class="text-center">
<p class="text-sm text-slate-400">Email</p>
<p class="text-sm text-slate-400">{$t.game.profile.email}</p>
<p class="font-semibold text-white">{data.user.email}</p>
</div>
</div>
@@ -169,7 +174,7 @@
<!-- Name Field -->
<div>
<label for="name" class="block text-sm font-semibold uppercase tracking-[0.2em] text-amber-100">
Nom d'affichage
{$t.game.profile.displayName}
</label>
<input
id="name"
@@ -177,7 +182,7 @@
name="name"
bind:value={name}
required
placeholder="Ton nom"
placeholder={$t.game.profile.displayNamePlaceholder}
class="mt-3 w-full rounded-lg border border-white/10 bg-white/5 px-4 py-3 text-white placeholder-slate-500 transition focus:border-amber-300 focus:outline-none focus:ring-2 focus:ring-amber-300/30"
/>
</div>
@@ -192,7 +197,7 @@
<!-- Success Message -->
{#if showSuccess}
<div class="rounded-lg border border-green-500/30 bg-green-900/20 px-4 py-3 text-sm text-green-200">
Profil mis à jour avec succès !
{$t.game.profile.profileUpdateSuccess}
</div>
{/if}
@@ -202,7 +207,7 @@
disabled={isLoading}
class="w-full rounded-full bg-amber-300 px-6 py-3 text-sm font-semibold text-slate-900 transition disabled:opacity-50 hover:bg-amber-200"
>
{isLoading ? 'Mise à jour...' : 'Enregistrer les modifications'}
{isLoading ? $t.game.profile.updating : $t.game.profile.saveChanges}
</button>
</form>
</div>
@@ -212,7 +217,7 @@
{#if activeTab === 'friends'}
<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 sm:p-8">
<h2 class="mb-6 text-2xl font-bold uppercase tracking-[0.2em] text-amber-50">
Système d'amis
{$t.game.profile.friendsTitle}
</h2>
<form
@@ -229,7 +234,7 @@
class="mb-8 space-y-3"
>
<label for="friendUsername" class="block text-sm font-semibold uppercase tracking-[0.2em] text-amber-100">
Ajouter un ami par nom d'utilisateur
{$t.game.profile.addFriendByUsername}
</label>
<div class="flex gap-2">
<input
@@ -238,7 +243,7 @@
name="friendUsername"
required
bind:value={friendUsername}
placeholder="ex: luffy_gear5"
placeholder={$t.game.profile.friendUsernamePlaceholder}
class="w-full rounded-lg border border-white/10 bg-white/5 px-4 py-3 text-white placeholder-slate-500 transition focus:border-amber-300 focus:outline-none focus:ring-2 focus:ring-amber-300/30"
/>
<button
@@ -246,7 +251,7 @@
disabled={isLoading}
class="rounded-full bg-amber-300 px-4 py-2 text-sm font-semibold text-slate-900 transition disabled:opacity-50 hover:bg-amber-200"
>
{isLoading ? 'Envoi...' : 'Envoyer'}
{isLoading ? $t.game.profile.sending : $t.game.profile.send}
</button>
</div>
{#if form?.message}
@@ -256,12 +261,12 @@
<div class="space-y-8">
<div>
<h3 class="mb-3 text-sm font-semibold uppercase tracking-[0.15em] text-amber-100">Demandes reçues</h3>
<h3 class="mb-3 text-sm font-semibold uppercase tracking-[0.15em] text-amber-100">{$t.game.profile.incomingRequests}</h3>
{#if incomingRequests.length === 0}
<p class="text-sm text-slate-400">Aucune demande reçue.</p>
<p class="text-sm text-slate-400">{$t.game.profile.noIncomingRequests}</p>
{:else}
<div class="space-y-3">
{#each incomingRequests as req}
{#each incomingRequests as req (req.id)}
<div class="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-white/10 bg-white/5 px-4 py-3">
<div>
<p class="font-semibold text-white">{req.requesterName}</p>
@@ -270,11 +275,11 @@
<div class="flex gap-2">
<form method="POST" action="?/acceptFriendRequest" use:enhance>
<input type="hidden" name="friendshipId" value={req.id} />
<button type="submit" class="rounded-lg border border-emerald-400/50 bg-emerald-900/20 px-3 py-1.5 text-xs font-semibold text-emerald-300 transition hover:bg-emerald-900/40">Accepter</button>
<button type="submit" class="rounded-lg border border-emerald-400/50 bg-emerald-900/20 px-3 py-1.5 text-xs font-semibold text-emerald-300 transition hover:bg-emerald-900/40">{$t.game.profile.accept}</button>
</form>
<form method="POST" action="?/declineFriendRequest" use:enhance>
<input type="hidden" name="friendshipId" value={req.id} />
<button type="submit" class="rounded-lg border border-red-500/50 bg-red-900/20 px-3 py-1.5 text-xs font-semibold text-red-300 transition hover:bg-red-900/40">Refuser</button>
<button type="submit" class="rounded-lg border border-red-500/50 bg-red-900/20 px-3 py-1.5 text-xs font-semibold text-red-300 transition hover:bg-red-900/40">{$t.game.profile.decline}</button>
</form>
</div>
</div>
@@ -284,12 +289,12 @@
</div>
<div>
<h3 class="mb-3 text-sm font-semibold uppercase tracking-[0.15em] text-amber-100">Demandes envoyées</h3>
<h3 class="mb-3 text-sm font-semibold uppercase tracking-[0.15em] text-amber-100">{$t.game.profile.outgoingRequests}</h3>
{#if outgoingRequests.length === 0}
<p class="text-sm text-slate-400">Aucune demande envoyée.</p>
<p class="text-sm text-slate-400">{$t.game.profile.noOutgoingRequests}</p>
{:else}
<div class="space-y-3">
{#each outgoingRequests as req}
{#each outgoingRequests as req (req.id)}
<div class="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-white/10 bg-white/5 px-4 py-3">
<div>
<p class="font-semibold text-white">{req.addresseeName}</p>
@@ -297,7 +302,7 @@
</div>
<form method="POST" action="?/cancelFriendRequest" use:enhance>
<input type="hidden" name="friendshipId" value={req.id} />
<button type="submit" class="rounded-lg border border-red-500/50 bg-red-900/20 px-3 py-1.5 text-xs font-semibold text-red-300 transition hover:bg-red-900/40">Annuler</button>
<button type="submit" class="rounded-lg border border-red-500/50 bg-red-900/20 px-3 py-1.5 text-xs font-semibold text-red-300 transition hover:bg-red-900/40">{$t.game.profile.cancel}</button>
</form>
</div>
{/each}
@@ -306,12 +311,12 @@
</div>
<div>
<h3 class="mb-3 text-sm font-semibold uppercase tracking-[0.15em] text-amber-100">Mes amis</h3>
<h3 class="mb-3 text-sm font-semibold uppercase tracking-[0.15em] text-amber-100">{$t.game.profile.myFriends}</h3>
{#if friends.length === 0}
<p class="text-sm text-slate-400">Tu n'as pas encore d'amis.</p>
<p class="text-sm text-slate-400">{$t.game.profile.noFriends}</p>
{:else}
<div class="space-y-3">
{#each friends as friend}
{#each friends as friend (friend.id)}
<div class="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-white/10 bg-white/5 px-4 py-3">
<div>
<p class="font-semibold text-white">{friend.friendName}</p>
@@ -319,7 +324,7 @@
</div>
<form method="POST" action="?/removeFriend" use:enhance>
<input type="hidden" name="friendshipId" value={friend.id} />
<button type="submit" class="rounded-lg border border-red-500/50 bg-red-900/20 px-3 py-1.5 text-xs font-semibold text-red-300 transition hover:bg-red-900/40">Supprimer</button>
<button type="submit" class="rounded-lg border border-red-500/50 bg-red-900/20 px-3 py-1.5 text-xs font-semibold text-red-300 transition hover:bg-red-900/40">{$t.game.profile.remove}</button>
</form>
</div>
{/each}
@@ -334,7 +339,7 @@
{#if activeTab === 'password'}
<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 sm:p-8">
<h2 class="mb-6 text-2xl font-bold uppercase tracking-[0.2em] text-amber-50">
Changer le mot de passe
{$t.game.profile.changePasswordTitle}
</h2>
<!-- Form -->
@@ -356,7 +361,7 @@
<!-- Old Password Field -->
<div>
<label for="oldPassword" class="block text-sm font-semibold uppercase tracking-[0.2em] text-amber-100">
Mot de passe actuel
{$t.game.profile.currentPassword}
</label>
<input
id="oldPassword"
@@ -372,7 +377,7 @@
<!-- New Password Field -->
<div>
<label for="newPassword" class="block text-sm font-semibold uppercase tracking-[0.2em] text-amber-100">
Nouveau mot de passe
{$t.game.profile.newPassword}
</label>
<input
id="newPassword"
@@ -388,7 +393,7 @@
<!-- Confirm Password Field -->
<div>
<label for="confirmPassword" class="block text-sm font-semibold uppercase tracking-[0.2em] text-amber-100">
Confirmer le mot de passe
{$t.game.profile.confirmPassword}
</label>
<input
id="confirmPassword"
@@ -411,7 +416,7 @@
<!-- Success Message -->
{#if showSuccess}
<div class="rounded-lg border border-green-500/30 bg-green-900/20 px-4 py-3 text-sm text-green-200">
Mot de passe changé avec succès !
{$t.game.profile.passwordChangeSuccess}
</div>
{/if}
@@ -421,7 +426,7 @@
disabled={isLoading}
class="w-full rounded-full bg-amber-300 px-6 py-3 text-sm font-semibold text-slate-900 transition disabled:opacity-50 hover:bg-amber-200"
>
{isLoading ? 'Changement en cours...' : 'Changer le mot de passe'}
{isLoading ? $t.game.profile.changing : $t.game.profile.changePassword}
</button>
</form>
</div>
@@ -431,17 +436,17 @@
{#if activeTab === 'daily'}
<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 sm:p-8">
<h2 class="mb-6 text-2xl font-bold uppercase tracking-[0.2em] text-amber-50">
Historique des Daily
{$t.game.profile.dailyHistoryTitle}
</h2>
{#if dailyHistory.length === 0}
<p class="text-center text-slate-400">Aucun historique disponible</p>
<p class="text-center text-slate-400">{$t.game.profile.noDailyHistory}</p>
{:else}
<div class="space-y-4">
{#each dailyHistory as day}
{#each dailyHistory as day (day.id)}
<div class="flex items-center gap-4 rounded-lg border border-white/10 bg-white/5 p-4">
<!-- Character Image -->
<div class="flex-shrink-0">
<div class="shrink-0">
{#if day.characterImage}
<img
src={day.characterImage}
@@ -450,7 +455,7 @@
/>
{:else}
<div class="flex h-16 w-16 items-center justify-center rounded-lg border border-white/20 bg-slate-700">
<span class="text-xs text-slate-400">N/A</span>
<span class="text-xs text-slate-400">{$t.game.profile.noImage}</span>
</div>
{/if}
</div>
@@ -459,18 +464,41 @@
<div class="flex-1">
<p class="font-semibold text-white">{day.characterName}</p>
<p class="text-xs text-slate-400">
{new Date(day.date).toLocaleDateString('fr-FR', {
{new Date(day.date).toLocaleDateString($language === 'fr' ? 'fr-FR' : 'en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
})}
</p>
<div class="mt-3">
<p class="text-[11px] font-semibold uppercase tracking-[0.2em] text-amber-100">
{$t.game.profile.triedCharactersTitle}
</p>
{#if day.triedCharacters && day.triedCharacters.length > 0}
<div class="mt-2 flex flex-wrap gap-2">
{#each day.triedCharacters as triedCharacter (triedCharacter.id)}
<span class="inline-flex items-center gap-2 rounded-full border border-white/15 bg-white/5 px-2.5 py-1 text-xs text-slate-200">
{#if triedCharacter.pictureUrl}
<img
src={triedCharacter.pictureUrl}
alt={triedCharacter.name}
class="h-4 w-4 rounded-full object-cover"
/>
{/if}
{triedCharacter.name}
</span>
{/each}
</div>
{:else}
<p class="mt-1 text-xs text-slate-500">{$t.game.profile.noTriedCharacters}</p>
{/if}
</div>
</div>
<!-- Tries -->
<div class="flex flex-col items-end">
<p class="text-xs text-slate-400">
{day.tryCount} {day.tryCount === 1 ? 'tentative' : 'tentatives'}
{day.tryCount} {day.tryCount === 1 ? $t.game.profile.trySingular : $t.game.profile.tryPlural}
</p>
</div>
</div>
@@ -484,24 +512,24 @@
{#if activeTab === 'sessions'}
<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 sm:p-8">
<h2 class="mb-6 text-2xl font-bold uppercase tracking-[0.2em] text-amber-50">
Sessions actives
{$t.game.profile.activeSessionsTitle}
</h2>
{#if sessions.length === 0}
<p class="text-center text-slate-400">Aucune session active</p>
<p class="text-center text-slate-400">{$t.game.profile.noActiveSessions}</p>
{:else}
<div class="space-y-4">
{#each sessions as sess}
{#each sessions as sess (sess.id)}
<div class="flex items-center justify-between rounded-lg border border-white/10 bg-white/5 px-4 py-4">
<div class="flex-1">
<p class="font-semibold text-white">
{sess.userAgent || 'Appareil inconnu'}
{sess.userAgent || $t.game.profile.unknownDevice}
</p>
<p class="text-xs text-slate-400">
IP: {sess.ipAddress || 'Inconnue'}
{$t.game.profile.ip}: {sess.ipAddress || $t.game.profile.unknown}
</p>
<p class="mt-1 text-xs text-slate-500">
Créée: {new Date(sess.createdAt).toLocaleDateString('fr-FR', {
{$t.game.profile.created}: {new Date(sess.createdAt).toLocaleDateString($language === 'fr' ? 'fr-FR' : 'en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
@@ -525,7 +553,7 @@
type="submit"
class="rounded-lg border border-red-500/50 bg-red-900/20 px-4 py-2 text-xs font-semibold text-red-300 transition hover:border-red-500 hover:bg-red-900/40"
>
Terminer
{$t.game.profile.terminate}
</button>
</form>
</div>
@@ -537,8 +565,8 @@
<!-- Back to Home -->
<div class="text-center">
<a href="/" class="text-sm text-slate-400 transition hover:text-slate-300">
Retour à l'accueil
<a href={resolve("/")} class="text-sm text-slate-400 transition hover:text-slate-300">
{$t.game.profile.backHome}
</a>
</div>
</div>