Compare commits

...

13 Commits

Author SHA1 Message Date
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
35 changed files with 2376 additions and 946 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

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

View File

@@ -5,8 +5,8 @@
{
"idx": 0,
"version": "6",
"when": 1773447741334,
"tag": "0000_keen_rockslide",
"when": 1773602933375,
"tag": "0000_huge_doctor_octopus",
"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

@@ -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;
@@ -119,6 +120,7 @@ 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),
@@ -137,7 +139,8 @@ 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)
};
}
@@ -168,6 +171,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 +180,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)

View File

@@ -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,99 @@ 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) {
return;
}
if (parseInt(charChapter, 10) === 0) {
if (!charChapter || parseInt(charChapter, 10) === 0) {
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(', '),
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
});
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 +407,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}...`);
@@ -453,6 +525,8 @@ async function fetchCharacter(
frName = name;
}
const pictureUrl = frCharacterPictureMap[frName || ''] || null;
return {
id: finalCharacterId,
name,
@@ -475,7 +549,7 @@ async function fetchCharacter(
firstAppearance,
arcId,
status,
pictureUrl: 'Image_Non_Disponible',
pictureUrl,
url: characterUrl,
frUrl
};
@@ -629,29 +703,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;
}
/**
@@ -941,72 +1008,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 };
})
const devilFruitUrls = new Set<string>(
characters.filter((c) => c.devilFruitUrl).map((c) => c.devilFruitUrl!)
);
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`);
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;
if (Array.isArray(parsedEpithets)) {
epithetsMatches = parsedEpithets.some((epithet: string) =>
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)
);
} 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);
!selectedCharacters.some((selected) => selected.id === char.id);
});
});
// Reset highlighted index when filtered list changes
$: if (filteredCharacters) {
highlightedIndex = 0;
// Reset highlighted index when filtered list changes.
$effect(() => {
const nextFilteredCharacters = filteredCharacters;
if (!nextFilteredCharacters) {
return;
}
state.highlightedIndex = 0;
});
// Scroll highlighted item into view.
$effect(() => {
const nextFilteredCharacters = filteredCharacters;
if (!state.dropdownContainer || state.highlightedIndex < 0) {
return;
}
// 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' });
}
if (state.highlightedIndex >= nextFilteredCharacters.length) {
return;
}
function selectCharacter(character: any) {
dispatch('select', character);
searchInput = '';
highlightedIndex = 0;
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,17 +198,12 @@
</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>
{/each}
@@ -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

@@ -1,192 +1,379 @@
<script lang="ts">
import { formatBounty } from '$lib';
import { resolve } from '$app/paths';
import type { CharacterWithRelations } from '$lib/server/daily-character';
import { language, t } from '$lib/i18n';
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;
affiliations?: boolean;
devilFruitType?: boolean;
haki?: boolean;
bounty?: boolean;
height?: boolean;
origin?: boolean;
arc?: boolean;
};
function normalizeAffiliations(value: unknown): string[] {
if (Array.isArray(value)) {
return value
.map((entry) => (typeof entry === 'string' ? entry.trim() : ''))
.filter((entry) => entry.length > 0);
}
if (typeof value === 'string') {
const trimmed = value.trim();
if (trimmed.length === 0) {
return [];
}
if (trimmed.startsWith('[')) {
try {
const parsed = JSON.parse(trimmed);
if (Array.isArray(parsed)) {
return parsed
.map((entry) => (typeof entry === 'string' ? entry.trim() : ''))
.filter((entry) => entry.length > 0);
}
} catch {
return [];
}
}
return trimmed
.split(',')
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
}
return [];
}
function firstAffiliation(value: unknown): string | null {
const affiliations = normalizeAffiliations(value);
return affiliations.length > 0 ? affiliations[0] : null;
}
function hasMatchingPrimaryAffiliation(characterAffiliations: unknown, dailyAffiliations: unknown): boolean {
const characterPrimary = firstAffiliation(characterAffiliations);
const dailyPrimary = firstAffiliation(dailyAffiliations);
return characterPrimary === dailyPrimary;
}
$: isFrench = $language === 'fr';
function getDisplayName(character: CharacterWithRelations): string {
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 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
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
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
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
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
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
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
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.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
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
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}
href={getWikiBaseUrl() + getWikiUrl(character)}
target="_blank"
rel="noopener noreferrer"
class="block w-full h-full"
class="block h-full w-full"
>
<img
src={character.pictureUrl}
alt={character.name}
class="w-full h-full object-cover hover:opacity-80 transition-opacity cursor-pointer"
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">
<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'
? 'Vivant'
: character.status === 'Deceased' || character.status === 'Dead'
? 'Mort'
? $t.game.components.guessHistory.alive
: character.status === 'Dead'
? $t.game.components.guessHistory.dead
: character.status === 'Unknown'
? 'Inconnu'
? $t.game.components.guessHistory.unknown
: character.status === null
? '-'
: character.status || 'Inconnu'}
: 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'}
<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>
<div
class="h-16 w-16 shrink-0 rounded-lg border border-white/10 sm:h-20 sm:w-20 md:h-24 md:w-24 {hasMatchingPrimaryAffiliation(character.affiliations, dailyCharacter.affiliations)
? 'bg-emerald-600/90'
: 'bg-red-900/60'} flex items-center justify-center overflow-hidden p-1 sm:p-2"
>
{#if firstAffiliation(character.affiliations)}
<p
class="w-full text-center text-[10px] leading-tight font-bold wrap-break-word whitespace-normal text-white sm:text-xs md:text-sm"
>
{firstAffiliation(character.affiliations)}
</p>
{: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>
<p class="text-center text-xs font-bold text-slate-400 sm:text-sm md:text-base">
-
</p>
{/if}
</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">
<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-[10px] sm:text-xs md:text-sm font-bold text-white text-center">{character.devilFruitType}</p>
<p class="text-center text-[10px] font-bold text-white sm:text-xs md:text-sm">
{character.devilFruitType}
</p>
{:else}
<p class="text-2xl sm:text-3xl md:text-5xl font-bold text-white text-center"></p>
<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) {
<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) ||
} else if (
(character.hakiObservation && dailyCharacter.hakiObservation) ||
(character.hakiArmament && dailyCharacter.hakiArmament) ||
(character.hakiConqueror && dailyCharacter.hakiConqueror)) {
(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}
})()} 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}
@@ -196,61 +383,110 @@
<!-- 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">
<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="absolute w-full h-full opacity-30 pointer-events-none" style="
<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>
"
></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>
<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="text-[10px] sm:text-xs md:text-sm font-bold text-white text-center relative z-10">Inconnue</p>
<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">
<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="absolute w-full h-full opacity-30 pointer-events-none" style="
<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>
"
></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>
<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>
<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
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>
"
></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>
<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>

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,16 @@
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">{Array.isArray(affiliations) ? affiliations[0] : affiliations || $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>
{#if isFrench}
<a
href={"https://onepiece.fandom.com/fr/wiki/" + yesterdayCharacter.url}
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"
>
Voir la page
{$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}

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

@@ -0,0 +1,227 @@
{
"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",
"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",
"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",
"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",
"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"
}
}
}
}

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

@@ -0,0 +1,227 @@
{
"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",
"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",
"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",
"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",
"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,5 +1,5 @@
import { db } from '$lib/server/db';
import { arc, character, characterHistory, characterOverride, devilFruit } from '$lib/server/db/schema';
import { arc, character, characterHistory, characterOverride, devilFruit, type Character, type CharacterOverride } from '$lib/server/db/schema';
import { desc, eq, inArray, and } from 'drizzle-orm';
// Generate or get random seed for daily character selection
@@ -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,
frAffiliations: character.frAffiliations,
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 }>;
@@ -48,7 +53,7 @@ function isNotNullish<T>(value: T | null | undefined): value is T {
function mergeCharacterWithOverride(
baseCharacter: CharacterWithRelations,
overrideRow?: CharacterOverrideRow,
overrideRow?: CharacterOverride,
relationMaps?: RelationMaps
): CharacterWithRelations {
if (!overrideRow) {
@@ -70,8 +75,10 @@ function mergeCharacterWithOverride(
if (relationMaps) {
if (mergedCharacter.arcId) {
mergedCharacter.arcName = relationMaps.arcNameById.get(mergedCharacter.arcId) ?? null;
mergedCharacter.frArcName = relationMaps.arcNameById.get(mergedCharacter.arcId) ?? null;
} else {
mergedCharacter.arcName = null;
mergedCharacter.frArcName = null;
}
if (mergedCharacter.devilFruitId) {
@@ -104,7 +111,7 @@ async function applyCharacterOverrides(
return characters;
}
const overrideByCharacterId = new Map<string, CharacterOverrideRow>(
const overrideByCharacterId = new Map<string, CharacterOverride>(
overrideRows.map((overrideRow) => [overrideRow.characterId, overrideRow])
);

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(),
@@ -39,6 +44,7 @@ export const character = sqliteTable('character', {
gender: text('gender'),
age: integer('age'),
affiliations: text('affiliations', { mode: 'json' }).$type<string[]>(),
frAffiliations: text('fr_affiliations', { mode: 'json' }).$type<string[]>(),
devilFruitId: text('devil_fruit_id').references(() => devilFruit.id),
hakiObservation: integer('haki_observation', { mode: 'boolean' }).default(false),
hakiArmament: integer('haki_armament', { mode: 'boolean' }).default(false),
@@ -58,6 +64,8 @@ export const character = sqliteTable('character', {
isInDailyMode: integer('is_in_daily_mode', { mode: 'boolean' }).default(false)
});
export type Character = InferSelectModel<typeof character>;
// Define the character override table schema
export const characterOverride = sqliteTable('character_override', {
characterId: text('character_id').primaryKey().references(() => character.id, { onDelete: 'cascade' }),
@@ -65,6 +73,7 @@ export const characterOverride = sqliteTable('character_override', {
gender: text('gender'),
age: integer('age'),
affiliations: text('affiliations', { mode: 'json' }).$type<string[]>(),
frAffiliations: text('fr_affiliations', { mode: 'json' }).$type<string[]>(),
devilFruitId: text('devil_fruit_id').references(() => devilFruit.id, { onDelete: 'set null' }),
hakiObservation: integer('haki_observation', { mode: 'boolean' }),
hakiArmament: integer('haki_armament', { mode: 'boolean' }),
@@ -72,9 +81,11 @@ export const characterOverride = sqliteTable('character_override', {
bounty: integer('bounty'),
height: real('height'),
origin: text('origin'),
frOrigin: text('fr_origin'),
firstAppearance: integer('first_appearance'),
pictureUrl: text('picture_url'),
epithets: text('epithets', { mode: 'json' }).$type<string[]>(),
frEpithets: text('fr_epithets', { mode: 'json' }).$type<string[]>(),
status: text('status').$type<Status | null>(),
arcId: text('arc_id').references(() => arc.id, { onDelete: 'set null' }),
url: text('url'),
@@ -82,6 +93,8 @@ export const characterOverride = sqliteTable('character_override', {
notes: text('notes')
});
export type CharacterOverride = InferSelectModel<typeof characterOverride>;
// Define the character scrape validation table schema
export const characterScrapeValidation = sqliteTable('character_scrape_validation', {
id: text('id').primaryKey(),
@@ -90,6 +103,7 @@ export const characterScrapeValidation = sqliteTable('character_scrape_validatio
gender: text('gender'),
age: integer('age'),
affiliations: text('affiliations', { mode: 'json' }).$type<string[]>(),
frAffiliations: text('fr_affiliations', { mode: 'json' }).$type<string[]>(),
devilFruitId: text('devil_fruit_id').references(() => devilFruit.id, { onDelete: 'set null' }),
hakiObservation: integer('haki_observation', { mode: 'boolean' }).default(false),
hakiArmament: integer('haki_armament', { mode: 'boolean' }).default(false),
@@ -108,6 +122,8 @@ export const characterScrapeValidation = sqliteTable('character_scrape_validatio
frUrl: text('fr_url')
});
export type CharacterScrapeValidation = InferSelectModel<typeof characterScrapeValidation>;
// Define the character history table schema
export const characterHistory = sqliteTable('character_history', {
id: text('id')
@@ -120,6 +136,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 +152,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 +172,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

@@ -26,6 +26,7 @@ async function upsertCharacterFromScrapeValidation(characterId: string): Promise
.values({
id: scraped.id,
name: scraped.name,
frName: scraped.frName,
gender: scraped.gender,
age: scraped.age,
affiliations: scraped.affiliations,
@@ -36,17 +37,21 @@ 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,
@@ -57,12 +62,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
}
});
@@ -101,6 +109,7 @@ export async function load() {
const differences: Record<string, { current: any; scraped: any }> = {};
const fieldsToCompare = [
'name',
'frName',
'gender',
'age',
'affiliations',
@@ -111,12 +120,15 @@ export async function load() {
'bounty',
'height',
'origin',
'frOrigin',
'firstAppearance',
'pictureUrl',
'epithets',
'frEpithets',
'status',
'arcId',
'url'
'url',
'frUrl'
];
for (const field of fieldsToCompare) {

View File

@@ -1,16 +1,9 @@
<script lang="ts">
import { page } from '$app/stores';
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'));
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 {
if (value === null || value === undefined) {
return '—';
@@ -23,13 +16,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>
@@ -80,7 +66,7 @@
<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}
alt={change.scraped.name}
@@ -139,7 +125,7 @@
<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}
@@ -165,7 +151,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">

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: '',
@@ -98,7 +90,6 @@
};
const openEditModal = (char: any) => {
selectedCharacterId = char.id;
selectedChar = char;
const override = char.override || {};
@@ -123,13 +114,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: '',
@@ -179,6 +168,7 @@
}, 3000);
}
} catch (error) {
console.error('Error deleting character:', error);
saveMessage = {
type: 'error',
text: 'Error deleting character'
@@ -221,7 +211,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 +220,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 +229,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,17 +274,17 @@
</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' : ''}">
<div class="flex items-center gap-3 min-w-0">
{#if getFandomUrl(char.displayValues.url)}
{#if char.displayValues.url}
<a
href={getFandomUrl(char.displayValues.url)}
href={"https://onepiece.fandom.com/fr/wiki/" + char.displayValues.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}
<img
@@ -315,18 +305,18 @@
src={char.displayValues.pictureUrl}
alt={char.displayValues.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">
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-slate-700 text-gray-400">
{char.displayValues.name?.charAt(0).toUpperCase() || '?'}
</div>
{/if}
{/if}
<div class="flex flex-col min-w-0">
{#if getFandomUrl(char.displayValues.url)}
{#if char.displayValues.url}
<a
href={getFandomUrl(char.displayValues.url)}
href="https://onepiece.fandom.com/fr/wiki/${char.displayValues.url}"
target="_blank"
rel="noopener noreferrer"
class="font-medium truncate text-white hover:text-amber-200 hover:underline"
@@ -461,7 +451,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 +618,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 +641,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,11 +9,14 @@
<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>
<div class="flex items-center gap-3">
<LanguageSwitcher />
<ProfileButton user={data.user} />
</div>
</div>
</header>
<main class="pt-20">
{@render children()}

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>
{#if isFrench}
<a
href={"https://onepiece.fandom.com/fr/wiki/" + yesterdayCharacter.url}
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"
>
Voir la page
{$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,23 +1,97 @@
<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 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 +111,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 +125,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,39 +148,14 @@
$: 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) {
@@ -122,13 +179,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 +257,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 +265,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 +276,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,7 +306,7 @@
<CharacterSearchInput
{characters}
{selectedCharacters}
on:select={handleCharacterSelect}
onSelect={handleCharacterSelect}
/>
{/if}
</section>
@@ -255,9 +314,9 @@
{#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>
<p class="text-xs font-semibold uppercase tracking-[0.28em] text-amber-100 text-center">{$t.game.daily.friendsToday}</p>
<div class="mt-4 space-y-2">
{#each data.friendsTodayResults as friendResult}
{#each data.friendsTodayResults as friendResult (friendResult.userId)}
<div class="flex items-center justify-between rounded-lg border border-white/10 bg-slate-950/50 px-4 py-2">
<div class="flex items-center gap-3">
{#if friendResult.image}
@@ -274,7 +333,7 @@
<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'}
{friendResult.tryCount} {friendResult.tryCount > 1 ? $t.game.daily.friendTryPlural : $t.game.daily.friendTrySingular}
</p>
</div>
{/each}

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 = {
@@ -35,13 +32,85 @@
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 +125,7 @@
try {
columnVisibility = JSON.parse(storedColumnVisibility);
} catch (e) {
console.error('Failed to parse column visibility', e);
columnVisibility = data.columnVisibility || {};
}
} else {
@@ -86,18 +156,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 +176,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 +208,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;
}
@@ -185,61 +283,56 @@
}
// 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,
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 +358,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) {
@@ -392,20 +491,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 +566,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 +604,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 +622,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 +656,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>
<h3 class="text-xs font-semibold tracking-[0.2em] text-amber-200 uppercase">
{$t.game.infinite.filtersTitle}
</h3>
{#if characterFilters.gender.length > 0 || characterFilters.hasHaki || characterFilters.hasDevilFruit !== null || characterFilters.status.length > 0 || characterFilters.hasHeight || characterFilters.hasOrigin || characterFilters.arcs.length > 0}
<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 +675,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 +695,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 +715,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 +724,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'}"
>
{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 +756,7 @@
? '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"
@@ -648,20 +765,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 +790,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 +799,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

@@ -190,6 +190,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,6 +1,8 @@
<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;
@@ -11,41 +13,29 @@
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 || []);
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 +57,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 +69,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 +80,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 +128,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 +137,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 +159,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 +167,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 +182,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 +192,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 +202,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 +219,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 +228,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 +236,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 +246,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 +260,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 +274,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 +287,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 +296,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 +309,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 +324,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 +346,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 +362,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 +378,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 +401,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 +411,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 +421,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 +440,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,7 +449,7 @@
<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'
@@ -470,7 +460,7 @@
<!-- 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 +474,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 +515,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 +527,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>