Compare commits
15 Commits
main
...
201c4759b6
| Author | SHA1 | Date | |
|---|---|---|---|
| 201c4759b6 | |||
| 485d96026a | |||
| a339498879 | |||
| b5d53d69c1 | |||
| f31f49aec7 | |||
| 8aac371c32 | |||
| c3bc429af2 | |||
| 7157e8c5a6 | |||
| bbce1ff136 | |||
| a80e977e87 | |||
| b4aa5e1a73 | |||
| b849e6c4dc | |||
| 40bdc80773 | |||
| b183b5877b | |||
| ce9ffd2736 |
1
drizzle/0002_large_gwen_stacy.sql
Normal file
1
drizzle/0002_large_gwen_stacy.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE `user` ADD `is_admin` integer DEFAULT false NOT NULL;
|
||||
16
drizzle/0003_wise_blonde_phantom.sql
Normal file
16
drizzle/0003_wise_blonde_phantom.sql
Normal file
@@ -0,0 +1,16 @@
|
||||
PRAGMA foreign_keys=OFF;--> statement-breakpoint
|
||||
CREATE TABLE `__new_characterHistory` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`characterId` text,
|
||||
`date` integer NOT NULL,
|
||||
`won` integer DEFAULT 0 NOT NULL,
|
||||
`createdAt` integer NOT NULL,
|
||||
`updatedAt` integer NOT NULL,
|
||||
FOREIGN KEY (`characterId`) REFERENCES `character`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
INSERT INTO `__new_characterHistory`("id", "characterId", "date", "won", "createdAt", "updatedAt") SELECT "id", "characterId", "date", "won", "createdAt", "updatedAt" FROM `characterHistory`;--> statement-breakpoint
|
||||
DROP TABLE `characterHistory`;--> statement-breakpoint
|
||||
ALTER TABLE `__new_characterHistory` RENAME TO `characterHistory`;--> statement-breakpoint
|
||||
PRAGMA foreign_keys=ON;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `characterHistory_date_unique` ON `characterHistory` (`date`);
|
||||
1084
drizzle/meta/0002_snapshot.json
Normal file
1084
drizzle/meta/0002_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1092
drizzle/meta/0003_snapshot.json
Normal file
1092
drizzle/meta/0003_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,20 @@
|
||||
"when": 1772383366179,
|
||||
"tag": "0001_nostalgic_hercules",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "6",
|
||||
"when": 1772390182445,
|
||||
"tag": "0002_large_gwen_stacy",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "6",
|
||||
"when": 1772449624450,
|
||||
"tag": "0003_wise_blonde_phantom",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
2
package-lock.json
generated
2
package-lock.json
generated
@@ -23,7 +23,7 @@
|
||||
"@tailwindcss/forms": "^0.5.11",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@types/node": "^24",
|
||||
"@types/node": "^24.11.0",
|
||||
"cheerio": "^1.0.0-rc.12",
|
||||
"csv-writer": "^1.6.0",
|
||||
"eslint": "^9.39.2",
|
||||
|
||||
13
package.json
13
package.json
@@ -18,6 +18,7 @@
|
||||
"db:studio": "drizzle-kit studio",
|
||||
"db:import": "npx tsx scripts/import-json.ts",
|
||||
"db:set-daily-mode": "npx tsx scripts/set-daily-mode.ts",
|
||||
"user:promote-admin": "npx tsx scripts/promote-admin.ts",
|
||||
"auth:schema": "npx @better-auth/cli generate --config src/lib/server/auth.ts --output src/lib/server/db/auth.schema.ts --yes",
|
||||
"scrape": "npx tsx scripts/scrape-onepiece.ts"
|
||||
},
|
||||
@@ -29,7 +30,7 @@
|
||||
"@tailwindcss/forms": "^0.5.11",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@types/node": "^24",
|
||||
"@types/node": "^24.11.0",
|
||||
"cheerio": "^1.0.0-rc.12",
|
||||
"csv-writer": "^1.6.0",
|
||||
"eslint": "^9.39.2",
|
||||
@@ -47,11 +48,11 @@
|
||||
"vite": "^7.3.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"tsx": "^4.21.0",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"drizzle-kit": "^0.31.8",
|
||||
"better-auth": "^1.4.18",
|
||||
"@libsql/client": "^0.17.0",
|
||||
"@sveltejs/adapter-node": "^5.5.4",
|
||||
"@libsql/client": "^0.17.0"
|
||||
"better-auth": "^1.4.18",
|
||||
"drizzle-kit": "^0.31.8",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"tsx": "^4.21.0"
|
||||
}
|
||||
}
|
||||
|
||||
55
scripts/promote-admin.ts
Normal file
55
scripts/promote-admin.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { createClient } from '@libsql/client';
|
||||
import { drizzle } from 'drizzle-orm/libsql';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { user } from '../src/lib/server/db/auth.schema';
|
||||
|
||||
const DATABASE_URL = process.env.DATABASE_URL || 'file:local.db';
|
||||
|
||||
function getErrorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
async function promoteAdmin(): Promise<void> {
|
||||
const email = process.argv[2]?.trim();
|
||||
|
||||
if (!email) {
|
||||
console.error('❌ Missing email argument');
|
||||
console.error('Usage: npm run user:promote-admin -- <email>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const client = createClient({ url: DATABASE_URL });
|
||||
const db = drizzle(client);
|
||||
|
||||
try {
|
||||
const existingUsers = await db.select().from(user).where(eq(user.email, email)).limit(1);
|
||||
const targetUser = existingUsers[0];
|
||||
|
||||
if (!targetUser) {
|
||||
console.error(`❌ User not found for email: ${email}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (targetUser.isAdmin) {
|
||||
console.log(`ℹ️ User is already admin: ${targetUser.email}`);
|
||||
return;
|
||||
}
|
||||
|
||||
await db
|
||||
.update(user)
|
||||
.set({ isAdmin: true })
|
||||
.where(eq(user.id, targetUser.id));
|
||||
|
||||
console.log(`✅ Admin granted to: ${targetUser.email}`);
|
||||
} catch (error) {
|
||||
console.error(`❌ Failed to promote admin: ${getErrorMessage(error)}`);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
promoteAdmin().catch((error) => {
|
||||
console.error(getErrorMessage(error));
|
||||
process.exit(1);
|
||||
});
|
||||
9
src/app.d.ts
vendored
9
src/app.d.ts
vendored
@@ -1,18 +1,11 @@
|
||||
import type { User, Session } from 'better-auth/minimal';
|
||||
|
||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||
// for information about these interfaces
|
||||
declare global {
|
||||
namespace App {
|
||||
interface Locals {
|
||||
user?: User;
|
||||
user?: User & { isAdmin?: boolean };
|
||||
session?: Session;
|
||||
}
|
||||
|
||||
// interface Error {}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { Handle } from '@sveltejs/kit';
|
||||
import { building } from '$app/environment';
|
||||
import { auth } from '$lib/server/auth';
|
||||
import { db } from '$lib/server/db';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { user as userTable } from '$lib/server/db/auth.schema';
|
||||
import { svelteKitHandler } from 'better-auth/svelte-kit';
|
||||
|
||||
const handleBetterAuth: Handle = async ({ event, resolve }) => {
|
||||
@@ -9,6 +12,12 @@ const handleBetterAuth: Handle = async ({ event, resolve }) => {
|
||||
if (session) {
|
||||
event.locals.session = session.session;
|
||||
event.locals.user = session.user;
|
||||
|
||||
// Fetch the isAdmin field from the database
|
||||
const dbUser = await db.select({ isAdmin: userTable.isAdmin }).from(userTable).where(eq(userTable.id, session.user.id)).limit(1);
|
||||
if (dbUser.length > 0) {
|
||||
(event.locals.user as any).isAdmin = dbUser[0].isAdmin;
|
||||
}
|
||||
}
|
||||
|
||||
return svelteKitHandler({ event, resolve, auth, building });
|
||||
|
||||
167
src/lib/components/CharacterSearchInput.svelte
Normal file
167
src/lib/components/CharacterSearchInput.svelte
Normal file
@@ -0,0 +1,167 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
export let characters: any[];
|
||||
export let selectedCharacters: any[];
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
let searchInput = '';
|
||||
let highlightedIndex = 0;
|
||||
let dropdownContainer: HTMLDivElement;
|
||||
let searchContainer: HTMLDivElement;
|
||||
|
||||
onMount(() => {
|
||||
// Add click outside listener
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('click', handleClickOutside);
|
||||
};
|
||||
});
|
||||
|
||||
$: filteredCharacters = characters.filter(char => {
|
||||
const searchTerm = searchInput.toLowerCase();
|
||||
const nameMatches = char.name.toLowerCase().includes(searchTerm);
|
||||
|
||||
let epithetsMatches = false;
|
||||
if (char.epithets) {
|
||||
try {
|
||||
const parsedEpithets = typeof char.epithets === 'string'
|
||||
? JSON.parse(char.epithets)
|
||||
: char.epithets;
|
||||
|
||||
if (Array.isArray(parsedEpithets)) {
|
||||
epithetsMatches = parsedEpithets.some((epithet: string) =>
|
||||
epithet.toLowerCase().includes(searchTerm)
|
||||
);
|
||||
} else if (typeof parsedEpithets === 'string') {
|
||||
epithetsMatches = parsedEpithets.toLowerCase().includes(searchTerm);
|
||||
}
|
||||
} catch {
|
||||
epithetsMatches = String(char.epithets).toLowerCase().includes(searchTerm);
|
||||
}
|
||||
}
|
||||
|
||||
return (nameMatches || epithetsMatches) &&
|
||||
!selectedCharacters.some(selected => selected.id === char.id);
|
||||
});
|
||||
|
||||
// Reset highlighted index when filtered list changes
|
||||
$: if (filteredCharacters) {
|
||||
highlightedIndex = 0;
|
||||
}
|
||||
|
||||
// Scroll highlighted item into view
|
||||
$: if (dropdownContainer && highlightedIndex >= 0) {
|
||||
const highlightedButton = dropdownContainer.querySelector(
|
||||
`button:nth-child(${highlightedIndex + 1})`
|
||||
) as HTMLElement;
|
||||
if (highlightedButton) {
|
||||
highlightedButton.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
||||
}
|
||||
}
|
||||
|
||||
function selectCharacter(character: any) {
|
||||
dispatch('select', character);
|
||||
searchInput = '';
|
||||
highlightedIndex = 0;
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (filteredCharacters.length === 0) return;
|
||||
|
||||
switch (event.key) {
|
||||
case 'ArrowDown':
|
||||
event.preventDefault();
|
||||
highlightedIndex = Math.min(highlightedIndex + 1, filteredCharacters.length - 1);
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
event.preventDefault();
|
||||
highlightedIndex = Math.max(highlightedIndex - 1, 0);
|
||||
break;
|
||||
case 'Enter':
|
||||
event.preventDefault();
|
||||
if (filteredCharacters[highlightedIndex]) {
|
||||
selectCharacter(filteredCharacters[highlightedIndex]);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function submitGuess() {
|
||||
if (filteredCharacters.length === 0) return;
|
||||
const characterToSelect =
|
||||
filteredCharacters[highlightedIndex] ?? filteredCharacters[0];
|
||||
if (characterToSelect) {
|
||||
selectCharacter(characterToSelect);
|
||||
}
|
||||
}
|
||||
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (searchContainer && !searchContainer.contains(event.target as Node)) {
|
||||
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>
|
||||
<div class="mt-4 flex flex-col gap-3 sm:flex-row">
|
||||
<div bind:this={searchContainer} class="relative w-full">
|
||||
<input
|
||||
bind:value={searchInput}
|
||||
class="w-full rounded-full border border-amber-200/30 bg-slate-900/60 px-5 py-3 text-sm text-slate-100 placeholder:text-slate-400 focus:border-amber-200/70 focus:outline-none"
|
||||
placeholder="Nom du personnage"
|
||||
type="text"
|
||||
onkeydown={handleKeydown}
|
||||
/>
|
||||
{#if searchInput.length > 0 && filteredCharacters.length > 0}
|
||||
<div bind:this={dropdownContainer} class="absolute top-full left-0 right-0 mt-2 max-h-96 overflow-y-auto rounded-2xl border border-amber-200/30 bg-slate-900/90 backdrop-blur z-10">
|
||||
{#each filteredCharacters as character, index (character.id)}
|
||||
<button
|
||||
class="w-full px-5 py-4 text-left text-base text-slate-100 transition border-b border-slate-800/50 last:border-b-0 flex items-center gap-4 {index === highlightedIndex ? 'bg-slate-700' : 'hover:bg-slate-800/70'}"
|
||||
type="button"
|
||||
onmouseenter={() => highlightedIndex = index}
|
||||
onclick={() => selectCharacter(character)}
|
||||
>
|
||||
{#if character.pictureUrl}
|
||||
<img
|
||||
src={character.pictureUrl}
|
||||
alt={character.name}
|
||||
class="w-12 h-12 rounded-full object-cover border border-amber-200/30"
|
||||
/>
|
||||
{:else}
|
||||
<div class="w-12 h-12 rounded-full bg-slate-800 border border-amber-200/30 flex items-center justify-center">
|
||||
<span class="text-xs text-slate-400">?</span>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex-1">
|
||||
<span class="font-semibold text-amber-100">{character.name}</span>
|
||||
{#if character.epithets}
|
||||
{@const parsedEpithets = typeof character.epithets === 'string'
|
||||
? JSON.parse(character.epithets)
|
||||
: character.epithets}
|
||||
{#if Array.isArray(parsedEpithets) && parsedEpithets.length > 0}
|
||||
<span class="ml-2 text-xs text-slate-400">
|
||||
• {parsedEpithets.join(', ')}
|
||||
</span>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={submitGuess}
|
||||
disabled={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
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
246
src/lib/components/GuessHistoryTable.svelte
Normal file
246
src/lib/components/GuessHistoryTable.svelte
Normal file
@@ -0,0 +1,246 @@
|
||||
<script lang="ts">
|
||||
import { formatBounty } from '$lib';
|
||||
|
||||
export let selectedCharacters: any[];
|
||||
export let dailyCharacter: any;
|
||||
export let columnVisibility: any;
|
||||
</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">
|
||||
<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>
|
||||
</div>
|
||||
{#if selectedCharacters.length === 0}
|
||||
<p class="text-sm text-slate-200 text-center">Aucune tentative pour le moment.</p>
|
||||
{:else}
|
||||
<div class="overflow-x-auto pb-2 -mx-6 px-6 sm:mx-0 sm:px-0">
|
||||
<div class="w-max min-w-max mx-auto">
|
||||
<!-- Header -->
|
||||
<div class="flex gap-2 mb-2">
|
||||
<div class="w-24 shrink-0 rounded-lg border border-amber-200/30 bg-amber-900/30 p-2 text-center flex items-center justify-center">
|
||||
<p class="text-xs font-semibold uppercase tracking-wider text-amber-100">Personnage</p>
|
||||
</div>
|
||||
{#if columnVisibility.status !== false}
|
||||
<div class="w-24 shrink-0 rounded-lg border border-amber-200/30 bg-amber-900/30 p-2 text-center flex items-center justify-center">
|
||||
<p class="text-xs font-semibold uppercase tracking-wider text-amber-100">Statut</p>
|
||||
</div>
|
||||
{/if}
|
||||
{#if columnVisibility.gender !== false}
|
||||
<div class="w-24 shrink-0 rounded-lg border border-amber-200/30 bg-amber-900/30 p-2 text-center flex items-center justify-center">
|
||||
<p class="text-xs font-semibold uppercase tracking-wider text-amber-100">Genre</p>
|
||||
</div>
|
||||
{/if}
|
||||
{#if columnVisibility.affiliations !== false}
|
||||
<div class="w-24 shrink-0 rounded-lg border border-amber-200/30 bg-amber-900/30 p-2 text-center flex items-center justify-center">
|
||||
<p class="text-xs font-semibold uppercase tracking-wider text-amber-100">Affiliations</p>
|
||||
</div>
|
||||
{/if}
|
||||
{#if columnVisibility.devilFruitType !== false}
|
||||
<div class="w-24 shrink-0 rounded-lg border border-amber-200/30 bg-amber-900/30 p-2 text-center flex items-center justify-center">
|
||||
<p class="text-xs font-semibold uppercase tracking-wider text-amber-100">Fruit</p>
|
||||
</div>
|
||||
{/if}
|
||||
{#if columnVisibility.haki !== false}
|
||||
<div class="w-24 shrink-0 rounded-lg border border-amber-200/30 bg-amber-900/30 p-2 text-center flex items-center justify-center">
|
||||
<p class="text-xs font-semibold uppercase tracking-wider text-amber-100">Haki</p>
|
||||
</div>
|
||||
{/if}
|
||||
{#if columnVisibility.bounty !== false}
|
||||
<div class="w-24 shrink-0 rounded-lg border border-amber-200/30 bg-amber-900/30 p-2 text-center flex items-center justify-center">
|
||||
<p class="text-xs font-semibold uppercase tracking-wider text-amber-100">Prime</p>
|
||||
</div>
|
||||
{/if}
|
||||
{#if columnVisibility.height !== false}
|
||||
<div class="w-24 shrink-0 rounded-lg border border-amber-200/30 bg-amber-900/30 p-2 text-center flex items-center justify-center">
|
||||
<p class="text-xs font-semibold uppercase tracking-wider text-amber-100">Taille</p>
|
||||
</div>
|
||||
{/if}
|
||||
{#if columnVisibility.origin !== false}
|
||||
<div class="w-24 shrink-0 rounded-lg border border-amber-200/30 bg-amber-900/30 p-2 text-center flex items-center justify-center">
|
||||
<p class="text-xs font-semibold uppercase tracking-wider text-amber-100">Origine</p>
|
||||
</div>
|
||||
{/if}
|
||||
{#if columnVisibility.arc !== false}
|
||||
<div class="w-24 shrink-0 rounded-lg border border-amber-200/30 bg-amber-900/30 p-2 text-center flex items-center justify-center">
|
||||
<p class="text-xs font-semibold uppercase tracking-wider text-amber-100">Arc</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Rows -->
|
||||
{#each selectedCharacters as character (character.id)}
|
||||
<div class="flex gap-2 mb-2">
|
||||
<!-- Personnage -->
|
||||
<div class="w-24 h-24 shrink-0 rounded-lg border border-white/10 bg-slate-950/60 overflow-hidden">
|
||||
{#if character.pictureUrl}
|
||||
<a
|
||||
href={"https://onepiece.fandom.com/fr/wiki/" + character.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="block w-full h-full"
|
||||
>
|
||||
<img
|
||||
src={character.pictureUrl}
|
||||
alt={character.name}
|
||||
class="w-full h-full object-cover hover:opacity-80 transition-opacity cursor-pointer"
|
||||
/>
|
||||
</a>
|
||||
{:else}
|
||||
<div class="w-full h-full bg-slate-800 flex items-center justify-center p-2">
|
||||
<span class="text-xl text-center font-semibold line-clamp-3">{character.name}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Vivant / Mort -->
|
||||
{#if columnVisibility.status !== false}
|
||||
<div class="w-24 h-24 shrink-0 rounded-lg border border-white/10 {character.status === dailyCharacter.status ? 'bg-emerald-600/90' : 'bg-red-900/60'} p-2 flex items-center justify-center">
|
||||
<p class="text-sm font-bold text-white text-center">
|
||||
{character.status === 'Alive' ? 'Vivant' : character.status === 'Deceased' || character.status === 'Dead' ? 'Mort' : character.status || 'Inconnu'}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Genre -->
|
||||
{#if columnVisibility.gender !== false}
|
||||
<div class="w-24 h-24 shrink-0 rounded-lg border border-white/10 {character.gender === dailyCharacter.gender ? 'bg-emerald-600/90' : 'bg-red-900/60'} p-2 flex items-center justify-center">
|
||||
<p class="text-base font-bold text-white text-center">
|
||||
{character.gender === 'Male' ? 'Homme' : character.gender === 'Female' ? 'Femme' : character.gender || 'Inconnu'}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Affiliations -->
|
||||
{#if columnVisibility.affiliations !== false}
|
||||
<div class="w-24 h-24 shrink-0 rounded-lg border border-white/10 {(() => {
|
||||
try {
|
||||
const charAff = typeof character.affiliations === 'string'
|
||||
? ((character.affiliations as string).includes('[') ? JSON.parse(character.affiliations) : (character.affiliations as string).split(',').map((a: string) => a.trim()))
|
||||
: character.affiliations;
|
||||
const dailyAff = typeof dailyCharacter.affiliations === 'string'
|
||||
? ((dailyCharacter.affiliations as string).includes('[') ? JSON.parse(dailyCharacter.affiliations) : (dailyCharacter.affiliations as string).split(',').map((a: string) => a.trim()))
|
||||
: dailyCharacter.affiliations;
|
||||
const charFirstAff = Array.isArray(charAff) ? charAff[0] : charAff;
|
||||
const dailyFirstAff = Array.isArray(dailyAff) ? dailyAff[0] : dailyAff;
|
||||
return charFirstAff && dailyFirstAff && charFirstAff === dailyFirstAff ? 'bg-emerald-600/90' : 'bg-red-900/60';
|
||||
} catch (e) {
|
||||
return 'bg-slate-950/60';
|
||||
}
|
||||
})()} p-2 flex items-center justify-center overflow-hidden">
|
||||
{#if character.affiliations}
|
||||
{@const parsedAffiliations = typeof character.affiliations === 'string'
|
||||
? (character.affiliations.includes('[') ? JSON.parse(character.affiliations) : character.affiliations.split(',').map((a: string) => a.trim()))
|
||||
: character.affiliations}
|
||||
{#if Array.isArray(parsedAffiliations) && parsedAffiliations.length > 0}
|
||||
<p class="w-full text-sm font-bold text-white text-center whitespace-normal break-words leading-tight">{parsedAffiliations[0]}</p>
|
||||
{:else}
|
||||
<p class="w-full text-sm font-bold text-white text-center whitespace-normal break-words leading-tight">{parsedAffiliations}</p>
|
||||
{/if}
|
||||
{:else}
|
||||
<p class="text-base font-bold text-slate-400 text-center">-</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Fruit -->
|
||||
{#if columnVisibility.devilFruitType !== false}
|
||||
<div class="w-24 h-24 shrink-0 rounded-lg border border-white/10 {character.devilFruitType === dailyCharacter.devilFruitType ? 'bg-emerald-600/90' : 'bg-red-900/60'} p-2 flex items-center justify-center">
|
||||
{#if character.devilFruitType}
|
||||
<p class="text-sm font-bold text-white text-center">{character.devilFruitType}</p>
|
||||
{:else}
|
||||
<p class="text-5xl font-bold text-white text-center">✕</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Haki -->
|
||||
{#if columnVisibility.haki !== false}
|
||||
<div class="w-24 h-24 shrink-0 rounded-lg border border-white/10 {(() => {
|
||||
if (character.hakiObservation === dailyCharacter.hakiObservation && character.hakiArmament === dailyCharacter.hakiArmament && character.hakiConqueror === dailyCharacter.hakiConqueror) {
|
||||
return 'bg-emerald-600/90';
|
||||
} else if ((character.hakiObservation && dailyCharacter.hakiObservation) ||
|
||||
(character.hakiArmament && dailyCharacter.hakiArmament) ||
|
||||
(character.hakiConqueror && dailyCharacter.hakiConqueror)) {
|
||||
return 'bg-yellow-600/80';
|
||||
} else {
|
||||
return 'bg-red-900/60';
|
||||
}
|
||||
})()} p-2 flex items-center justify-center">
|
||||
<p class="text-2xl font-bold text-white text-center">
|
||||
{#if character.hakiObservation}<span title="Haki de l'Observation">👁️</span>{/if}
|
||||
{#if character.hakiArmament}<span title="Haki de l'Armement">🦾</span>{/if}
|
||||
{#if character.hakiConqueror}<span title="Haki des Rois">👑</span>{/if}
|
||||
{#if !character.hakiObservation && !character.hakiArmament && !character.hakiConqueror}
|
||||
<span class="text-5xl">✕</span>
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Prime -->
|
||||
{#if columnVisibility.bounty !== false}
|
||||
<div class="w-24 h-24 shrink-0 rounded-lg border border-white/10 {character.bounty === dailyCharacter.bounty ? 'bg-emerald-600/90' : 'bg-red-900/60'} p-2 flex items-center justify-center relative overflow-hidden">
|
||||
{#if character.bounty != null && dailyCharacter.bounty != null && character.bounty !== dailyCharacter.bounty}
|
||||
<div class="absolute w-full h-full opacity-30 pointer-events-none" style="
|
||||
background-color: rgb(203, 213, 225);
|
||||
clip-path: {character.bounty > dailyCharacter.bounty
|
||||
? 'polygon(97% 60%,80% 60%,80% 5%,20% 5%,20% 60%,3% 60%,50% 95%)'
|
||||
: 'polygon(97% 40%,80% 40%,80% 95%,20% 95%,20% 40%,3% 40%,50% 5%)'};
|
||||
"></div>
|
||||
{/if}
|
||||
{#if character.bounty != null}
|
||||
<p class="text-sm font-bold text-white text-center relative z-10">{formatBounty(character.bounty)} ฿</p>
|
||||
{:else}
|
||||
<p class="text-sm font-bold text-white text-center relative z-10">Inconnue</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Taille -->
|
||||
{#if columnVisibility.height !== false}
|
||||
<div class="w-24 h-24 shrink-0 rounded-lg border border-white/10 {character.height === dailyCharacter.height ? 'bg-emerald-600/90' : 'bg-red-900/60'} p-2 flex items-center justify-center relative overflow-hidden">
|
||||
{#if character.height && dailyCharacter.height && character.height !== dailyCharacter.height}
|
||||
<div class="absolute w-full h-full opacity-30 pointer-events-none" style="
|
||||
background-color: rgb(203, 213, 225);
|
||||
clip-path: {character.height > dailyCharacter.height
|
||||
? 'polygon(97% 60%,80% 60%,80% 5%,20% 5%,20% 60%,3% 60%,50% 95%)'
|
||||
: 'polygon(97% 40%,80% 40%,80% 95%,20% 95%,20% 40%,3% 40%,50% 5%)'};
|
||||
"></div>
|
||||
{/if}
|
||||
{#if character.height}
|
||||
<p class="text-sm font-bold text-white text-center relative z-10">{character.height} m</p>
|
||||
{:else}
|
||||
<p class="text-sm font-bold text-white text-center relative z-10">Inconnue</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Origine -->
|
||||
{#if columnVisibility.origin !== false}
|
||||
<div class="w-24 h-24 shrink-0 rounded-lg border border-white/10 {character.origin === dailyCharacter.origin ? 'bg-emerald-600/90' : 'bg-red-900/60'} p-2 flex items-center justify-center">
|
||||
<p class="text-sm font-bold text-white text-center">{character.origin || 'Inconnue'}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Arc -->
|
||||
{#if columnVisibility.arc !== false}
|
||||
<div class="w-24 h-24 shrink-0 rounded-lg border border-white/10 {character.arcName === dailyCharacter.arcName ? 'bg-emerald-600/90' : 'bg-red-900/60'} p-2 flex items-center justify-center relative overflow-hidden">
|
||||
{#if character.arcName !== dailyCharacter.arcName && character.firstAppearance && dailyCharacter.firstAppearance && character.firstAppearance !== dailyCharacter.firstAppearance}
|
||||
<div class="absolute w-full h-full opacity-30 pointer-events-none" style="
|
||||
background-color: rgb(203, 213, 225);
|
||||
clip-path: {character.firstAppearance > dailyCharacter.firstAppearance
|
||||
? 'polygon(97% 60%,80% 60%,80% 5%,20% 5%,20% 60%,3% 60%,50% 95%)'
|
||||
: 'polygon(97% 40%,80% 40%,80% 95%,20% 95%,20% 40%,3% 40%,50% 5%)'};
|
||||
"></div>
|
||||
{/if}
|
||||
<p class="text-sm font-bold text-white text-center relative z-10">{character.arcName || 'Inconnu'}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
88
src/lib/components/HintsPanel.svelte
Normal file
88
src/lib/components/HintsPanel.svelte
Normal file
@@ -0,0 +1,88 @@
|
||||
<script lang="ts">
|
||||
export let dailyCharacter: any;
|
||||
export let selectedCharacters: any[];
|
||||
export let showOriginUnlock: boolean = false;
|
||||
export let showFruitUnlock: boolean = false;
|
||||
export let showAffiliationUnlock: boolean = false;
|
||||
|
||||
let showHintOrigin = false;
|
||||
let showHintFruit = false;
|
||||
let showHintAffiliation = false;
|
||||
|
||||
// Hint availability - indices are available after a certain number of guesses
|
||||
$: isOriginAvailable = selectedCharacters.length >= 5;
|
||||
$: isFruitAvailable = selectedCharacters.length >= 10;
|
||||
$: isAffiliationAvailable = selectedCharacters.length >= 15;
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<style>
|
||||
@keyframes hint-unlock {
|
||||
0% {
|
||||
box-shadow: 0 0 0 rgba(251, 146, 60, 0);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 20px rgba(251, 146, 60, 0.8);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 rgba(251, 146, 60, 0);
|
||||
}
|
||||
}
|
||||
.hint-unlocking {
|
||||
animation: hint-unlock 0.6s ease-out;
|
||||
}
|
||||
</style>
|
||||
</svelte:head>
|
||||
|
||||
<div class="rounded-3xl border border-white/10 bg-white/5 p-6 shadow-[0_24px_60px_rgba(0,0,0,0.45)] backdrop-blur">
|
||||
<div class="grid gap-3 sm:grid-cols-3">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-2xl border border-white/10 px-3 py-3 flex flex-col items-center justify-center cursor-pointer hover:bg-slate-900/80 transition-colors {isOriginAvailable ? 'bg-slate-950/60' : 'bg-slate-950/30 opacity-50 cursor-not-allowed hover:bg-slate-950/30'} {showOriginUnlock ? 'hint-unlocking' : ''}"
|
||||
disabled={!isOriginAvailable}
|
||||
onclick={() => showHintOrigin = !showHintOrigin}
|
||||
>
|
||||
<p class="text-sm font-medium text-amber-100">Origine</p>
|
||||
{#if showHintOrigin}
|
||||
<p class="mt-2 text-xs text-white font-semibold">{dailyCharacter.origin || 'Inconnue'}</p>
|
||||
{:else if Math.max(0, 5 - selectedCharacters.length) > 0}
|
||||
<p class="mt-2 text-xs text-slate-400">{Math.max(0, 5 - selectedCharacters.length)} essais avant déblocage</p>
|
||||
{:else}
|
||||
<p class="mt-2 text-xs text-slate-400">Indice disponible !</p>
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-2xl border border-white/10 px-3 py-3 flex flex-col items-center justify-center cursor-pointer hover:bg-slate-900/80 transition-colors {isFruitAvailable ? 'bg-slate-950/60' : 'bg-slate-950/30 opacity-50 cursor-not-allowed hover:bg-slate-950/30'} {showFruitUnlock ? 'hint-unlocking' : ''}"
|
||||
disabled={!isFruitAvailable}
|
||||
onclick={() => showHintFruit = !showHintFruit}
|
||||
>
|
||||
<p class="text-sm font-medium text-amber-100">Fruit du démon</p>
|
||||
{#if showHintFruit}
|
||||
<p class="mt-2 text-xs text-white font-semibold">{dailyCharacter.devilFruitName || 'Aucun'}</p>
|
||||
{:else if Math.max(0, 10 - selectedCharacters.length) > 0}
|
||||
<p class="mt-2 text-xs text-slate-400">{Math.max(0, 10 - selectedCharacters.length)} essais avant déblocage</p>
|
||||
{:else}
|
||||
<p class="mt-2 text-xs text-slate-400">Indice disponible !</p>
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-2xl border border-white/10 px-3 py-3 flex flex-col items-center justify-center cursor-pointer hover:bg-slate-900/80 transition-colors {isAffiliationAvailable ? 'bg-slate-950/60' : 'bg-slate-950/30 opacity-50 cursor-not-allowed hover:bg-slate-950/30'} {showAffiliationUnlock ? 'hint-unlocking' : ''}"
|
||||
disabled={!isAffiliationAvailable}
|
||||
onclick={() => showHintAffiliation = !showHintAffiliation}
|
||||
>
|
||||
<p class="text-sm font-medium text-amber-100">Affiliation</p>
|
||||
{#if showHintAffiliation}
|
||||
{@const affiliations = typeof dailyCharacter.affiliations === 'string'
|
||||
? ((dailyCharacter.affiliations 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>
|
||||
{:else if Math.max(0, 15 - selectedCharacters.length) > 0}
|
||||
<p class="mt-2 text-xs text-slate-400">{Math.max(0, 15 - selectedCharacters.length)} essais avant déblocage</p>
|
||||
{:else}
|
||||
<p class="mt-2 text-xs text-slate-400">Indice disponible !</p>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
111
src/lib/components/ProfileButton.svelte
Normal file
111
src/lib/components/ProfileButton.svelte
Normal file
@@ -0,0 +1,111 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import type { User } from 'better-auth/types';
|
||||
|
||||
interface Props {
|
||||
user: (User & { isAdmin?: boolean }) | null;
|
||||
}
|
||||
|
||||
let { user }: Props = $props();
|
||||
|
||||
let isMenuOpen = $state(false);
|
||||
let menuElement: HTMLDivElement | undefined;
|
||||
|
||||
const toggleMenu = () => {
|
||||
isMenuOpen = !isMenuOpen;
|
||||
};
|
||||
|
||||
const closeMenu = () => {
|
||||
isMenuOpen = false;
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
const formData = new FormData();
|
||||
const response = await fetch('/login?/logout', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
if (response.ok) {
|
||||
window.location.href = '/';
|
||||
}
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (menuElement && !menuElement.contains(event.target as Node)) {
|
||||
closeMenu();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
return () => document.removeEventListener('click', handleClickOutside);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div bind:this={menuElement} class="relative">
|
||||
{#if user}
|
||||
<button
|
||||
onclick={toggleMenu}
|
||||
class="flex items-center gap-3 rounded-full border border-white/10 bg-white/5 px-2 py-2 pr-4 transition hover:border-amber-300/50 hover:bg-white/10"
|
||||
>
|
||||
{#if user.image}
|
||||
<img
|
||||
src={user.image}
|
||||
alt={user.name || 'Profil'}
|
||||
class="h-8 w-8 rounded-full object-cover"
|
||||
/>
|
||||
{:else}
|
||||
<div class="flex h-8 w-8 items-center justify-center rounded-full bg-amber-300/20 text-xs font-semibold text-amber-100">
|
||||
{user.name?.charAt(0).toUpperCase() || 'U'}
|
||||
</div>
|
||||
{/if}
|
||||
<span class="max-w-[150px] truncate text-sm font-semibold text-slate-100">
|
||||
{user.name || 'Utilisateur'}
|
||||
</span>
|
||||
<svg
|
||||
class="h-4 w-4 transition {isMenuOpen ? 'rotate-180' : ''}"
|
||||
fill="none"
|
||||
stroke="white"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 14l-7 7m0 0l-7-7m7 7V3" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#if isMenuOpen}
|
||||
<div
|
||||
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"
|
||||
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}
|
||||
<a
|
||||
href="/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"
|
||||
>
|
||||
Admin
|
||||
</a>
|
||||
{/if}
|
||||
<button
|
||||
onclick={handleLogout}
|
||||
class="w-full border-t border-white/5 px-4 py-3 text-sm font-semibold text-red-300 transition hover:bg-red-900/20 last:rounded-b-xl"
|
||||
>
|
||||
Se déconnecter
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<a
|
||||
href="/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
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
57
src/lib/components/WinPanel.svelte
Normal file
57
src/lib/components/WinPanel.svelte
Normal file
@@ -0,0 +1,57 @@
|
||||
<script lang="ts">
|
||||
export let dailyCharacter: any;
|
||||
export let selectedCharacters: any[];
|
||||
export let isGeckoMoriaWin: boolean = false;
|
||||
</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>
|
||||
<div class="mt-3">
|
||||
{#if dailyCharacter.pictureUrl}
|
||||
<a
|
||||
href={"https://onepiece.fandom.com/fr/wiki/" + dailyCharacter.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-block"
|
||||
>
|
||||
<img
|
||||
src={dailyCharacter.pictureUrl}
|
||||
alt={dailyCharacter.name}
|
||||
class="w-20 h-20 mx-auto rounded-full border-2 border-slate-600 shadow-lg object-cover hover:border-slate-500 transition-colors cursor-pointer opacity-80"
|
||||
/>
|
||||
</a>
|
||||
{/if}
|
||||
<p class="mt-2 text-lg font-bold text-slate-200">{dailyCharacter.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="rounded-3xl border border-emerald-500/50 bg-emerald-500/10 p-4 shadow-[0_24px_60px_rgba(16,185,129,0.3)] backdrop-blur">
|
||||
<div class="text-center">
|
||||
<div class="text-3xl mb-2">🎉</div>
|
||||
<h2 class="text-xl font-bold text-emerald-400 mb-1">Félicitations !</h2>
|
||||
<p class="text-sm text-emerald-300">Vous avez trouvé le personnage en {selectedCharacters.length} {selectedCharacters.length > 1 ? 'tentatives' : 'tentative'} !</p>
|
||||
<div class="mt-3">
|
||||
{#if dailyCharacter.pictureUrl}
|
||||
<a
|
||||
href={"https://onepiece.fandom.com/fr/wiki/" + dailyCharacter.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-block"
|
||||
>
|
||||
<img
|
||||
src={dailyCharacter.pictureUrl}
|
||||
alt={dailyCharacter.name}
|
||||
class="w-20 h-20 mx-auto rounded-full border-2 border-emerald-400 shadow-lg object-cover hover:border-emerald-300 transition-colors cursor-pointer"
|
||||
/>
|
||||
</a>
|
||||
{/if}
|
||||
<p class="mt-2 text-lg font-bold text-white">{dailyCharacter.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
51
src/lib/components/YesterdayCharacter.svelte
Normal file
51
src/lib/components/YesterdayCharacter.svelte
Normal file
@@ -0,0 +1,51 @@
|
||||
<script lang="ts">
|
||||
export let yesterdayCharacter: any;
|
||||
</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">
|
||||
{#if yesterdayCharacter}
|
||||
<div class="flex flex-col items-center gap-5 text-center sm:flex-row sm:text-left">
|
||||
{#if yesterdayCharacter.pictureUrl}
|
||||
<img
|
||||
src={yesterdayCharacter.pictureUrl}
|
||||
alt={yesterdayCharacter.name}
|
||||
class="h-20 w-20 rounded-full border border-amber-200/40 object-cover"
|
||||
/>
|
||||
{:else}
|
||||
<div class="flex h-20 w-20 items-center justify-center rounded-full border border-amber-200/40 bg-slate-900/70 text-xs uppercase tracking-[0.25em] text-amber-100">
|
||||
Photo
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex-1">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.28em] text-amber-100">Personnage d'hier</p>
|
||||
<p class="mt-2 text-lg font-semibold text-white">{yesterdayCharacter.name}</p>
|
||||
{#if yesterdayCharacter.epithets}
|
||||
<p class="mt-1 text-sm text-slate-400">
|
||||
{typeof yesterdayCharacter.epithets === 'string'
|
||||
? JSON.parse(yesterdayCharacter.epithets).join(', ')
|
||||
: (yesterdayCharacter.epithets as string[]).join(', ')}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
<a
|
||||
href={"https://onepiece.fandom.com/fr/wiki/" + yesterdayCharacter.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="w-full rounded-full border border-amber-200/40 bg-transparent px-5 py-3 text-sm font-semibold text-amber-100 transition hover:border-amber-200 hover:text-amber-50 sm:w-auto"
|
||||
>
|
||||
Voir la page
|
||||
</a>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-col items-center gap-5 text-center sm:flex-row sm:text-left">
|
||||
<div class="flex h-20 w-20 items-center justify-center rounded-full border border-amber-200/40 bg-slate-900/70 text-xs uppercase tracking-[0.25em] text-amber-100">
|
||||
Photo
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.28em] text-amber-100">Personnage d'hier</p>
|
||||
<p class="mt-2 text-lg font-semibold text-white">Aucun personnage</p>
|
||||
<p class="mt-1 text-sm text-slate-200">Aucun personnage d'hier disponible</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
@@ -1 +1,3 @@
|
||||
// place files you want to import through the `$lib` alias in this folder.
|
||||
|
||||
export { formatBounty } from './utils';
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { db } from '$lib/server/db';
|
||||
import { arc, character, characterHistory, characterOverride, devilFruit } from '$lib/server/db/schema';
|
||||
import { desc, eq, inArray } from 'drizzle-orm';
|
||||
import { desc, eq, inArray, and } from 'drizzle-orm';
|
||||
|
||||
// Generate or get random seed for daily character selection
|
||||
const RANDOM_SEED = Math.random();
|
||||
|
||||
const characterWithRelationsSelect = {
|
||||
id: character.id,
|
||||
@@ -139,21 +142,22 @@ async function applyCharacterOverrides(
|
||||
);
|
||||
}
|
||||
|
||||
function getDateKey(date: Date): string {
|
||||
return date.toISOString().split('T')[0];
|
||||
export function getDateKey(date: Date): number {
|
||||
return normalizeDay(date).getTime();
|
||||
}
|
||||
|
||||
function normalizeDay(date: Date = new Date()): Date {
|
||||
export function normalizeDay(date: Date = new Date()): Date {
|
||||
const normalized = new Date(date);
|
||||
normalized.setHours(1, 0, 0, 0);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function pickDailyCharacter(characters: CharacterWithRelations[], date: Date): CharacterWithRelations {
|
||||
const dateStr = getDateKey(date);
|
||||
const seed = dateStr.split('-').reduce((acc, value) => acc + parseInt(value), 0);
|
||||
const index = seed % characters.length;
|
||||
return characters[index];
|
||||
const timestamp = getDateKey(date);
|
||||
const daysSinceEpoch = Math.floor(timestamp / 1000 / 60 / 60 / 24);
|
||||
// Combine timestamp with random seed to avoid predictable results
|
||||
const combinedSeed = (daysSinceEpoch + Math.floor(RANDOM_SEED * 1000000)) % characters.length;
|
||||
return characters[combinedSeed];
|
||||
}
|
||||
|
||||
export async function getDailyModeCharacters(): Promise<CharacterWithRelations[]> {
|
||||
@@ -168,6 +172,17 @@ export async function getDailyModeCharacters(): Promise<CharacterWithRelations[]
|
||||
return applyCharacterOverrides(characters);
|
||||
}
|
||||
|
||||
export async function getAllCharacters(): Promise<CharacterWithRelations[]> {
|
||||
const characters = (await db
|
||||
.select(characterWithRelationsSelect)
|
||||
.from(character)
|
||||
.leftJoin(arc, eq(character.arcId, arc.id))
|
||||
.leftJoin(devilFruit, eq(character.devilFruitId, devilFruit.id))
|
||||
.all()) as CharacterWithRelations[];
|
||||
|
||||
return applyCharacterOverrides(characters);
|
||||
}
|
||||
|
||||
export async function getCharacterById(characterId: string): Promise<CharacterWithRelations | null> {
|
||||
const [found] = await db
|
||||
.select(characterWithRelationsSelect)
|
||||
@@ -186,10 +201,12 @@ export async function getCharacterById(characterId: string): Promise<CharacterWi
|
||||
}
|
||||
|
||||
export async function getOrCreateTodayCharacter(
|
||||
characters: CharacterWithRelations[],
|
||||
characters?: CharacterWithRelations[],
|
||||
date: Date = new Date()
|
||||
): Promise<CharacterWithRelations | null> {
|
||||
if (characters.length === 0) {
|
||||
const dailyCharacters = characters ?? (await getDailyModeCharacters());
|
||||
|
||||
if (dailyCharacters.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -204,7 +221,7 @@ export async function getOrCreateTodayCharacter(
|
||||
|
||||
if (existingEntry?.characterId) {
|
||||
return (
|
||||
characters.find((currentCharacter) => currentCharacter.id === existingEntry.characterId) ??
|
||||
dailyCharacters.find((currentCharacter) => currentCharacter.id === existingEntry.characterId) ??
|
||||
(await getCharacterById(existingEntry.characterId))
|
||||
);
|
||||
}
|
||||
@@ -216,10 +233,10 @@ export async function getOrCreateTodayCharacter(
|
||||
.limit(100);
|
||||
|
||||
const excludedIds = new Set(recentHistory.map((entry) => entry.characterId));
|
||||
const availableCharacters = characters.filter((currentCharacter) => !excludedIds.has(currentCharacter.id));
|
||||
const availableCharacters = dailyCharacters.filter((currentCharacter) => !excludedIds.has(currentCharacter.id));
|
||||
|
||||
const dailyCharacter = pickDailyCharacter(
|
||||
availableCharacters.length > 0 ? availableCharacters : characters,
|
||||
availableCharacters.length > 0 ? availableCharacters : dailyCharacters,
|
||||
today
|
||||
);
|
||||
|
||||
@@ -241,9 +258,9 @@ export async function getYesterdayCharacter(
|
||||
date: Date = new Date(),
|
||||
characters?: CharacterWithRelations[]
|
||||
): Promise<CharacterWithRelations | null> {
|
||||
const baseDate = normalizeDay(date);
|
||||
baseDate.setDate(baseDate.getDate() - 1);
|
||||
const yesterdayDate = getDateKey(baseDate);
|
||||
const yesterday = new Date(date);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
const yesterdayDate = getDateKey(yesterday);
|
||||
|
||||
const [yesterdayEntry] = await db
|
||||
.select()
|
||||
@@ -264,3 +281,23 @@ export async function getYesterdayCharacter(
|
||||
|
||||
return getCharacterById(yesterdayEntry.characterId);
|
||||
}
|
||||
|
||||
export async function getTodayCharacterWinsCount(
|
||||
characterId: string,
|
||||
date: Date = new Date()
|
||||
): Promise<number> {
|
||||
const today = normalizeDay(date);
|
||||
const todayDate = getDateKey(today);
|
||||
|
||||
const [result] = await db
|
||||
.select({ won: characterHistory.won })
|
||||
.from(characterHistory)
|
||||
.where(
|
||||
and(
|
||||
eq(characterHistory.characterId, characterId),
|
||||
eq(characterHistory.date, todayDate)
|
||||
)
|
||||
);
|
||||
|
||||
return result?.won ?? 0;
|
||||
}
|
||||
@@ -9,6 +9,7 @@ export const user = sqliteTable("user", {
|
||||
.default(false)
|
||||
.notNull(),
|
||||
image: text("image"),
|
||||
isAdmin: integer("is_admin", { mode: "boolean" }).default(false).notNull(),
|
||||
createdAt: integer("created_at", { mode: "timestamp_ms" })
|
||||
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
|
||||
.notNull(),
|
||||
|
||||
@@ -100,7 +100,7 @@ export const characterHistory = sqliteTable('characterHistory', {
|
||||
.primaryKey()
|
||||
.$defaultFn(() => crypto.randomUUID()),
|
||||
characterId: text('characterId').references(() => character.id),
|
||||
date: text('date'),
|
||||
date: integer('date').notNull().unique(),
|
||||
won: integer('won').notNull().default(0),
|
||||
createdAt: integer('createdAt').notNull().$default(() => Date.now()),
|
||||
updatedAt: integer('updatedAt').notNull().$default(() => Date.now()),
|
||||
|
||||
13
src/lib/utils.ts
Normal file
13
src/lib/utils.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export function formatBounty(bounty: number): string {
|
||||
if (bounty >= 1_000_000_000) {
|
||||
const billions = bounty / 1_000_000_000;
|
||||
return `${billions}B`;
|
||||
} else if (bounty >= 1_000_000) {
|
||||
const millions = bounty / 1_000_000;
|
||||
return `${millions}M`;
|
||||
} else if (bounty >= 1_000) {
|
||||
const thousands = bounty / 1_000;
|
||||
return `${thousands}K`;
|
||||
}
|
||||
return bounty.toString();
|
||||
}
|
||||
12
src/routes/(admin)/admin/+layout.server.ts
Normal file
12
src/routes/(admin)/admin/+layout.server.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { LayoutServerLoad } from './$types';
|
||||
|
||||
export const load: LayoutServerLoad = async ({ locals }) => {
|
||||
if (!locals.user) {
|
||||
redirect(302, '/login');
|
||||
}
|
||||
|
||||
if (!locals.user.isAdmin) {
|
||||
redirect(302, '/');
|
||||
}
|
||||
};
|
||||
67
src/routes/(admin)/admin/+layout.svelte
Normal file
67
src/routes/(admin)/admin/+layout.svelte
Normal file
@@ -0,0 +1,67 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import ProfileButton from '$lib/components/ProfileButton.svelte';
|
||||
|
||||
let { children, data } = $props();
|
||||
|
||||
const navItems = [
|
||||
{ href: '/admin', label: 'Dashboard', icon: '📊' },
|
||||
{ href: '/admin/characters', label: 'Characters', icon: '🗣️' },
|
||||
{ href: '/admin/devil-fruits', label: 'Devil Fruits', icon: '🍎' },
|
||||
{ href: '/admin/arcs', label: 'Arcs', icon: '📚' },
|
||||
{ href: '/admin/users', label: 'Users', icon: '👥' },
|
||||
{ href: '/admin/config', label: 'Settings', icon: '⚙️' }
|
||||
];
|
||||
|
||||
const isActive = (href: string, currentPath: string) => {
|
||||
if (href === '/admin') {
|
||||
return currentPath === '/admin';
|
||||
}
|
||||
return currentPath.startsWith(href);
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="flex min-h-screen bg-slate-900">
|
||||
<!-- Sidebar -->
|
||||
<aside class="flex flex-col w-64 border-r border-white/5 bg-slate-950">
|
||||
<div class="p-6">
|
||||
<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}
|
||||
<a
|
||||
href={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'
|
||||
: 'text-gray-300 hover:bg-slate-800 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<span>{item.icon}</span>
|
||||
<span>{item.label}</span>
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
<div class="border-t border-white/5 p-3">
|
||||
<a
|
||||
href="/"
|
||||
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"
|
||||
>
|
||||
<span>←</span>
|
||||
<span>Retour au site</span>
|
||||
</a>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main content -->
|
||||
<main class="flex-1">
|
||||
<div class="flex items-center justify-between border-b border-white/5 bg-slate-950 px-8 py-4">
|
||||
<h1 class="text-2xl font-bold text-white">Admin Dashboard</h1>
|
||||
<ProfileButton user={data.user} />
|
||||
</div>
|
||||
<div class="p-8">
|
||||
{@render children()}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
33
src/routes/(admin)/admin/+page.server.ts
Normal file
33
src/routes/(admin)/admin/+page.server.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
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';
|
||||
|
||||
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)
|
||||
]);
|
||||
|
||||
// Get today's daily character and count wins
|
||||
const todayCharacter = await getOrCreateTodayCharacter();
|
||||
|
||||
let dailyCharacterWins = 0;
|
||||
if (todayCharacter) {
|
||||
dailyCharacterWins = await getTodayCharacterWinsCount(todayCharacter.id);
|
||||
}
|
||||
|
||||
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,
|
||||
dailyCharacterWins
|
||||
}
|
||||
};
|
||||
};
|
||||
121
src/routes/(admin)/admin/+page.svelte
Normal file
121
src/routes/(admin)/admin/+page.svelte
Normal file
@@ -0,0 +1,121 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
|
||||
interface Props {
|
||||
data: PageData;
|
||||
}
|
||||
|
||||
let { data }: Props = $props();
|
||||
|
||||
const statCards = $derived.by(() => [
|
||||
{
|
||||
label: 'Total Characters',
|
||||
value: data.stats.totalCharacters,
|
||||
icon: '🗣️',
|
||||
bgColor: 'bg-blue-500/10 border-blue-500/20'
|
||||
},
|
||||
{
|
||||
label: 'In Daily Mode',
|
||||
value: data.stats.charactersInDaily,
|
||||
icon: '📅',
|
||||
bgColor: 'bg-green-500/10 border-green-500/20'
|
||||
},
|
||||
{
|
||||
label: 'Devil Fruits',
|
||||
value: data.stats.totalDevilFruits,
|
||||
icon: '🍎',
|
||||
bgColor: 'bg-red-500/10 border-red-500/20'
|
||||
},
|
||||
{
|
||||
label: 'Arcs',
|
||||
value: data.stats.totalArcs,
|
||||
icon: '📚',
|
||||
bgColor: 'bg-purple-500/10 border-purple-500/20'
|
||||
},
|
||||
{
|
||||
label: 'Total Users',
|
||||
value: data.stats.totalUsers,
|
||||
icon: '👥',
|
||||
bgColor: 'bg-yellow-500/10 border-yellow-500/20'
|
||||
},
|
||||
{
|
||||
label: 'Admin Users',
|
||||
value: data.stats.adminUsers,
|
||||
icon: '🔑',
|
||||
bgColor: 'bg-orange-500/10 border-orange-500/20'
|
||||
}
|
||||
]);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Admin Dashboard - OnePieceDle</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-8">
|
||||
<!-- Welcome Section -->
|
||||
<div class="rounded-lg border border-white/10 bg-gradient-to-r from-amber-600/20 to-amber-700/10 p-6">
|
||||
<h2 class="text-2xl font-bold text-white">Welcome Back!</h2>
|
||||
<p class="mt-2 text-gray-400">
|
||||
{#if data.stats.dailyCharacterWins > 0}
|
||||
<strong class="text-amber-400">{data.stats.dailyCharacterWins}</strong>
|
||||
{data.stats.dailyCharacterWins === 1 ? 'person has' : 'people have'} found today's daily character!
|
||||
{:else}
|
||||
No one has found today's daily character yet.
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Stats Grid -->
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{#each statCards as card}
|
||||
<div
|
||||
class={`rounded-lg border p-6 transition-all hover:shadow-lg hover:shadow-white/5 ${card.bgColor}`}
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-400">{card.label}</p>
|
||||
<p class="mt-2 text-3xl font-bold text-white">{card.value}</p>
|
||||
</div>
|
||||
<div class="text-4xl">{card.icon}</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="rounded-lg border border-white/10 bg-slate-800/50 p-6">
|
||||
<h3 class="mb-4 text-lg font-bold text-white">Quick Actions</h3>
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<a
|
||||
href="/admin/characters"
|
||||
class="rounded-lg bg-slate-700 px-4 py-3 text-sm font-medium text-white transition-colors hover:bg-slate-600"
|
||||
>
|
||||
↳ Manage Characters
|
||||
</a>
|
||||
<a
|
||||
href="/admin/devil-fruits"
|
||||
class="rounded-lg bg-slate-700 px-4 py-3 text-sm font-medium text-white transition-colors hover:bg-slate-600"
|
||||
>
|
||||
↳ Manage Devil Fruits
|
||||
</a>
|
||||
<a
|
||||
href="/admin/arcs"
|
||||
class="rounded-lg bg-slate-700 px-4 py-3 text-sm font-medium text-white transition-colors hover:bg-slate-600"
|
||||
>
|
||||
↳ Manage Arcs
|
||||
</a>
|
||||
<a
|
||||
href="/admin/users"
|
||||
class="rounded-lg bg-slate-700 px-4 py-3 text-sm font-medium text-white transition-colors hover:bg-slate-600"
|
||||
>
|
||||
↳ Manage Users
|
||||
</a>
|
||||
<a
|
||||
href="/admin/config"
|
||||
class="rounded-lg bg-slate-700 px-4 py-3 text-sm font-medium text-white transition-colors hover:bg-slate-600"
|
||||
>
|
||||
↳ App Settings
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
69
src/routes/(admin)/admin/arcs/+page.server.ts
Normal file
69
src/routes/(admin)/admin/arcs/+page.server.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { db } from '$lib/server/db';
|
||||
import { arc } from '$lib/server/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
const arcs = await db.select().from(arc).orderBy(arc.name);
|
||||
|
||||
return {
|
||||
arcs
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
update: async ({ request, locals }) => {
|
||||
if (!locals.user?.isAdmin) {
|
||||
return fail(401, { error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const id = formData.get('id') as string;
|
||||
|
||||
if (!id) {
|
||||
return fail(400, { error: 'Arc ID is required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const updates: Record<string, any> = {};
|
||||
formData.forEach((value, key) => {
|
||||
if (key !== 'id') {
|
||||
if (key === 'startChapter' || key === 'endChapter') {
|
||||
updates[key] = value ? parseInt(value as string) : null;
|
||||
} else {
|
||||
updates[key] = value || null;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await db.update(arc).set(updates).where(eq(arc.id, id));
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Arc update error:', error);
|
||||
return fail(500, { error: 'Failed to update arc' });
|
||||
}
|
||||
},
|
||||
|
||||
delete: async ({ request, locals }) => {
|
||||
if (!locals.user?.isAdmin) {
|
||||
return fail(401, { error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const id = formData.get('id') as string;
|
||||
|
||||
if (!id) {
|
||||
return fail(400, { error: 'Arc ID is required' });
|
||||
}
|
||||
|
||||
try {
|
||||
await db.delete(arc).where(eq(arc.id, id));
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Arc delete error:', error);
|
||||
return fail(500, { error: 'Failed to delete arc' });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
246
src/routes/(admin)/admin/arcs/+page.svelte
Normal file
246
src/routes/(admin)/admin/arcs/+page.svelte
Normal file
@@ -0,0 +1,246 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
interface Props {
|
||||
data: PageData;
|
||||
}
|
||||
|
||||
let { data }: Props = $props();
|
||||
|
||||
let searchQuery = $state('');
|
||||
let isEditModalOpen = $state(false);
|
||||
let isSaving = $state(false);
|
||||
let saveMessage = $state<{ type: 'success' | 'error'; message: string } | null>(null);
|
||||
let selectedArcId = $state<string | null>(null);
|
||||
|
||||
let editForm = $state<any>({
|
||||
id: '',
|
||||
name: '',
|
||||
startChapter: 1,
|
||||
endChapter: null,
|
||||
url: ''
|
||||
});
|
||||
|
||||
const filteredArcs = $derived.by(() => {
|
||||
return data.arcs.filter((arc) => {
|
||||
return arc.name.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
});
|
||||
});
|
||||
|
||||
const openEditModal = (arc: any) => {
|
||||
selectedArcId = arc.id;
|
||||
editForm = { ...arc };
|
||||
isEditModalOpen = true;
|
||||
saveMessage = null;
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
isEditModalOpen = false;
|
||||
selectedArcId = null;
|
||||
editForm = {
|
||||
id: '',
|
||||
name: '',
|
||||
startChapter: 1,
|
||||
endChapter: null,
|
||||
url: ''
|
||||
};
|
||||
saveMessage = null;
|
||||
};
|
||||
|
||||
const handleDeleteArc = async (id: string) => {
|
||||
if (!confirm('Are you sure you want to delete this arc?')) return;
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('id', id);
|
||||
|
||||
const response = await fetch('?/delete', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
alert('Failed to delete arc');
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Error deleting arc: ' + error);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Arcs - Admin - OnePieceDle</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-3xl font-bold text-white">Arc Management</h2>
|
||||
<button
|
||||
class="rounded-lg bg-amber-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-amber-700"
|
||||
>
|
||||
+ Add Arc
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="flex flex-col gap-4 rounded-lg border border-white/10 bg-slate-800/50 p-4 md:flex-row md:items-center">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search arcs..."
|
||||
bind:value={searchQuery}
|
||||
class="flex-1 rounded-lg bg-slate-700 px-4 py-2 text-sm text-white placeholder-gray-400 outline-none transition focus:ring-2 focus:ring-amber-600"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Arcs Table -->
|
||||
<div class="rounded-lg border border-white/10">
|
||||
<div class="max-h-[calc(100vh-20rem)] overflow-auto">
|
||||
<table class="w-full">
|
||||
<thead class="sticky top-0 bg-slate-800 z-10">
|
||||
<tr class="border-b border-white/10">
|
||||
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-300">Name</th>
|
||||
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-300">Start Chapter</th>
|
||||
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-300">End Chapter</th>
|
||||
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-300">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each filteredArcs as arc}
|
||||
<tr class="border-b border-white/5 hover:bg-slate-800/50">
|
||||
<td class="px-6 py-4 text-sm text-white">{arc.name}</td>
|
||||
<td class="px-6 py-4 text-sm text-gray-400">{arc.startChapter}</td>
|
||||
<td class="px-6 py-4 text-sm text-gray-400">{arc.endChapter || '-'}</td>
|
||||
<td class="px-6 py-4 text-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
onclick={() => openEditModal(arc)}
|
||||
class="text-amber-400 hover:text-amber-300 transition-colors"
|
||||
title="Edit arc"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path></svg>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => handleDeleteArc(arc.id)}
|
||||
class="text-red-400 hover:text-red-300 transition-colors"
|
||||
title="Delete arc"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div> </div>
|
||||
{#if filteredArcs.length === 0}
|
||||
<div class="rounded-lg border border-white/10 bg-slate-800/50 py-12 text-center">
|
||||
<p class="text-gray-400">No arcs found</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Edit Modal -->
|
||||
{#if isEditModalOpen}
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||
<div class="w-full max-w-md rounded-lg border border-white/10 bg-slate-900 p-6">
|
||||
<h3 class="text-lg font-bold text-white">Edit Arc</h3>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/update"
|
||||
class="mt-6 space-y-4"
|
||||
use:enhance={() => {
|
||||
isSaving = true;
|
||||
saveMessage = null;
|
||||
return async ({ result }) => {
|
||||
isSaving = false;
|
||||
if (result.type === 'success') {
|
||||
saveMessage = { type: 'success', message: 'Arc updated successfully' };
|
||||
setTimeout(() => {
|
||||
closeModal();
|
||||
window.location.reload();
|
||||
}, 500);
|
||||
} else if (result.type === 'failure') {
|
||||
saveMessage = { type: 'error', message: (result.data as any)?.error || 'Failed to update arc' };
|
||||
}
|
||||
};
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="id" value={editForm.id} />
|
||||
<div>
|
||||
<label for="arc-name" class="block text-sm font-medium text-gray-300">Name</label>
|
||||
<input
|
||||
id="arc-name"
|
||||
type="text"
|
||||
name="name"
|
||||
bind:value={editForm.name}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="arc-start" class="block text-sm font-medium text-gray-300">Start Chapter</label>
|
||||
<input
|
||||
id="arc-start"
|
||||
type="number"
|
||||
name="startChapter"
|
||||
bind:value={editForm.startChapter}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="arc-end" class="block text-sm font-medium text-gray-300">End Chapter</label>
|
||||
<input
|
||||
id="arc-end"
|
||||
type="number"
|
||||
name="endChapter"
|
||||
bind:value={editForm.endChapter}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label for="arc-url" class="block text-sm font-medium text-gray-300">URL</label>
|
||||
<input
|
||||
id="arc-url"
|
||||
type="text"
|
||||
name="url"
|
||||
bind:value={editForm.url}
|
||||
placeholder="https://..."
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
{#if saveMessage}
|
||||
<div class={`rounded-lg p-3 text-sm ${
|
||||
saveMessage.type === 'success'
|
||||
? 'bg-green-500/10 text-green-400'
|
||||
: 'bg-red-500/10 text-red-400'
|
||||
}`}>
|
||||
{saveMessage.message}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onclick={closeModal}
|
||||
disabled={isSaving}
|
||||
class="flex-1 rounded-lg border border-gray-500 px-4 py-2 text-sm font-medium text-gray-300 transition-colors hover:bg-slate-700 disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSaving}
|
||||
class="flex-1 rounded-lg bg-amber-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-amber-700 disabled:opacity-50"
|
||||
>
|
||||
{isSaving ? 'Saving...' : 'Save Changes'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
191
src/routes/(admin)/admin/characters/+page.server.ts
Normal file
191
src/routes/(admin)/admin/characters/+page.server.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
import { db } from '$lib/server/db';
|
||||
import { character, devilFruit, arc, characterOverride } from '$lib/server/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
const [charactersData, devilFruits, arcs, overrides] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
id: character.id,
|
||||
name: character.name,
|
||||
gender: character.gender,
|
||||
age: character.age,
|
||||
affiliations: character.affiliations,
|
||||
devilFruitId: character.devilFruitId,
|
||||
hakiObservation: character.hakiObservation,
|
||||
hakiArmament: character.hakiArmament,
|
||||
hakiConqueror: character.hakiConqueror,
|
||||
bounty: character.bounty,
|
||||
height: character.height,
|
||||
origin: character.origin,
|
||||
firstAppearance: character.firstAppearance,
|
||||
pictureUrl: character.pictureUrl,
|
||||
epithets: character.epithets,
|
||||
status: character.status,
|
||||
url: character.url,
|
||||
arcId: character.arcId,
|
||||
isInDailyMode: character.isInDailyMode,
|
||||
arcName: arc.name,
|
||||
devilFruitName: devilFruit.name,
|
||||
devilFruitType: devilFruit.type
|
||||
})
|
||||
.from(character)
|
||||
.leftJoin(arc, eq(character.arcId, arc.id))
|
||||
.leftJoin(devilFruit, eq(character.devilFruitId, devilFruit.id))
|
||||
.orderBy(character.name),
|
||||
db.select().from(devilFruit).orderBy(devilFruit.name),
|
||||
db.select().from(arc).orderBy(arc.name),
|
||||
db.select().from(characterOverride)
|
||||
]);
|
||||
|
||||
// Create a map of overrides by characterId for easy lookup
|
||||
const overridesMap = new Map(overrides.map((o) => [o.characterId, o]));
|
||||
|
||||
// Merge character data with overrides
|
||||
const charactersWithOverrides = charactersData.map((char) => {
|
||||
const override = overridesMap.get(char.id);
|
||||
|
||||
// Build displayValues by only applying non-null override fields
|
||||
const displayValues = { ...char } as any;
|
||||
if (override) {
|
||||
Object.keys(override).forEach((key) => {
|
||||
if (override[key as keyof typeof override] !== null && key !== 'characterId') {
|
||||
displayValues[key as keyof typeof displayValues] = override[key as keyof typeof override];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
...char,
|
||||
override,
|
||||
displayValues
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
characters: charactersWithOverrides,
|
||||
devilFruits,
|
||||
arcs
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
update: async ({ request, locals }) => {
|
||||
if (!locals.user?.isAdmin) {
|
||||
return fail(401, { error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const id = formData.get('id') as string;
|
||||
|
||||
if (!id) {
|
||||
return fail(400, { error: 'Character ID is required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const [originalCharacter] = await db
|
||||
.select({
|
||||
hakiObservation: character.hakiObservation,
|
||||
hakiArmament: character.hakiArmament,
|
||||
hakiConqueror: character.hakiConqueror
|
||||
})
|
||||
.from(character)
|
||||
.where(eq(character.id, id))
|
||||
.limit(1);
|
||||
|
||||
if (!originalCharacter) {
|
||||
return fail(404, { error: 'Character not found' });
|
||||
}
|
||||
|
||||
const updates: Record<string, any> = {};
|
||||
|
||||
formData.forEach((value, key) => {
|
||||
if (key !== 'id') {
|
||||
// Handle integers (age, bounty, height, devilFruitId, arcId)
|
||||
if (key === 'age' || key === 'bounty' || key === 'height' || key === 'devilFruitId' || key === 'arcId') {
|
||||
const strValue = value as string;
|
||||
updates[key] = strValue && strValue !== '' ? parseInt(strValue) : null;
|
||||
}
|
||||
// Handle checkboxes (haki fields) after parsing all form data
|
||||
else if (key === 'hakiObservation' || key === 'hakiArmament' || key === 'hakiConqueror') {
|
||||
return;
|
||||
}
|
||||
// Handle strings (name, gender, status, origin, affiliations, epithets, pictureUrl, url, firstAppearance)
|
||||
else {
|
||||
updates[key] = value || null;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const submittedHakiObservation = formData.has('hakiObservation');
|
||||
const submittedHakiArmament = formData.has('hakiArmament');
|
||||
const submittedHakiConqueror = formData.has('hakiConqueror');
|
||||
|
||||
updates.hakiObservation =
|
||||
submittedHakiObservation === originalCharacter.hakiObservation ? null : submittedHakiObservation;
|
||||
updates.hakiArmament =
|
||||
submittedHakiArmament === originalCharacter.hakiArmament ? null : submittedHakiArmament;
|
||||
updates.hakiConqueror =
|
||||
submittedHakiConqueror === originalCharacter.hakiConqueror ? null : submittedHakiConqueror;
|
||||
|
||||
// Update or insert into characterOverride table
|
||||
await db
|
||||
.insert(characterOverride)
|
||||
.values({ characterId: id, ...updates })
|
||||
.onConflictDoUpdate({ target: characterOverride.characterId, set: updates });
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Character update error:', error);
|
||||
return fail(500, { error: 'Failed to update character' });
|
||||
}
|
||||
},
|
||||
|
||||
delete: async ({ request, locals }) => {
|
||||
if (!locals.user?.isAdmin) {
|
||||
return fail(401, { error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const id = formData.get('id') as string;
|
||||
|
||||
if (!id) {
|
||||
return fail(400, { error: 'Character ID is required' });
|
||||
}
|
||||
|
||||
try {
|
||||
await db.delete(character).where(eq(character.id, id));
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Character delete error:', error);
|
||||
return fail(500, { error: 'Failed to delete character' });
|
||||
}
|
||||
},
|
||||
|
||||
toggleDailyMode: async ({ request, locals }) => {
|
||||
if (!locals.user?.isAdmin) {
|
||||
return fail(401, { error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const id = formData.get('id') as string;
|
||||
const isInDailyMode = formData.get('isInDailyMode') === 'true';
|
||||
|
||||
if (!id) {
|
||||
return fail(400, { error: 'Character ID is required' });
|
||||
}
|
||||
|
||||
try {
|
||||
await db.update(character)
|
||||
.set({ isInDailyMode })
|
||||
.where(eq(character.id, id));
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Toggle daily mode error:', error);
|
||||
return fail(500, { error: 'Failed to toggle daily mode' });
|
||||
}
|
||||
}
|
||||
};
|
||||
828
src/routes/(admin)/admin/characters/+page.svelte
Normal file
828
src/routes/(admin)/admin/characters/+page.svelte
Normal file
@@ -0,0 +1,828 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { formatBounty } from '$lib';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
interface Props {
|
||||
data: PageData;
|
||||
}
|
||||
|
||||
let { data }: Props = $props();
|
||||
|
||||
let searchQuery = $state('');
|
||||
let filterDaily = $state<'all' | 'daily' | 'not-daily'>('all');
|
||||
let filterStatus = $state('all');
|
||||
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 };
|
||||
setTimeout(() => {
|
||||
dailyModeToast = null;
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
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: '',
|
||||
gender: '',
|
||||
age: null,
|
||||
bounty: 0,
|
||||
height: 0,
|
||||
origin: '',
|
||||
affiliations: '',
|
||||
epithets: '',
|
||||
pictureUrl: '',
|
||||
url: '',
|
||||
devilFruitId: null,
|
||||
hakiObservation: false,
|
||||
hakiArmament: false,
|
||||
hakiConqueror: false,
|
||||
firstAppearance: '',
|
||||
arcId: null,
|
||||
status: ''
|
||||
});
|
||||
|
||||
const availableStatuses = $derived.by(() => {
|
||||
const statuses = new Set<string>();
|
||||
for (const char of data.characters) {
|
||||
const status = char.displayValues.status;
|
||||
if (status && String(status).trim() !== '') {
|
||||
statuses.add(String(status));
|
||||
}
|
||||
}
|
||||
return Array.from(statuses).sort((a, b) => a.localeCompare(b));
|
||||
});
|
||||
|
||||
const availableGenders = $derived.by(() => {
|
||||
const genders = new Set<string>();
|
||||
for (const char of data.characters) {
|
||||
const gender = char.displayValues.gender;
|
||||
if (gender && String(gender).trim() !== '') {
|
||||
genders.add(String(gender));
|
||||
}
|
||||
}
|
||||
return Array.from(genders).sort((a, b) => a.localeCompare(b));
|
||||
});
|
||||
|
||||
const filteredCharacters = $derived.by(() => {
|
||||
return data.characters.filter((char) => {
|
||||
const normalizedQuery = searchQuery.toLowerCase().trim();
|
||||
let epithetsText = '';
|
||||
|
||||
if (char.displayValues.epithets) {
|
||||
if (typeof char.displayValues.epithets === 'string') {
|
||||
if (char.displayValues.epithets.includes('[')) {
|
||||
try {
|
||||
const parsed = JSON.parse(char.displayValues.epithets);
|
||||
epithetsText = Array.isArray(parsed) ? parsed.join(' ') : String(parsed);
|
||||
} catch {
|
||||
epithetsText = char.displayValues.epithets;
|
||||
}
|
||||
} else {
|
||||
epithetsText = char.displayValues.epithets;
|
||||
}
|
||||
} else if (Array.isArray(char.displayValues.epithets)) {
|
||||
epithetsText = char.displayValues.epithets.join(' ');
|
||||
}
|
||||
}
|
||||
|
||||
const matchesSearch =
|
||||
normalizedQuery === '' ||
|
||||
char.displayValues.name.toLowerCase().includes(normalizedQuery) ||
|
||||
epithetsText.toLowerCase().includes(normalizedQuery);
|
||||
const matchesDaily =
|
||||
filterDaily === 'all' ||
|
||||
(filterDaily === 'daily' && char.displayValues.isInDailyMode) ||
|
||||
(filterDaily === 'not-daily' && !char.displayValues.isInDailyMode);
|
||||
const matchesStatus = filterStatus === 'all' || (char.displayValues.status || '') === filterStatus;
|
||||
const matchesGender = filterGender === 'all' || (char.displayValues.gender || '') === filterGender;
|
||||
const matchesArc =
|
||||
filterArc === 'all' ||
|
||||
String(char.displayValues.arcId ?? '') === filterArc;
|
||||
const matchesHaki =
|
||||
filterHaki === 'all' ||
|
||||
(filterHaki === 'observation' && !!char.displayValues.hakiObservation) ||
|
||||
(filterHaki === 'armament' && !!char.displayValues.hakiArmament) ||
|
||||
(filterHaki === 'conqueror' && !!char.displayValues.hakiConqueror) ||
|
||||
(filterHaki === 'none' && !char.displayValues.hakiObservation && !char.displayValues.hakiArmament && !char.displayValues.hakiConqueror);
|
||||
|
||||
return matchesSearch && matchesDaily && matchesStatus && matchesGender && matchesArc && matchesHaki;
|
||||
});
|
||||
});
|
||||
|
||||
const isFieldOverridden = (char: any, field: string) => {
|
||||
return char.override && char.override[field] !== null && char.override[field] !== undefined;
|
||||
};
|
||||
|
||||
const openEditModal = (char: any) => {
|
||||
selectedCharacterId = char.id;
|
||||
selectedChar = char;
|
||||
|
||||
const override = char.override || {};
|
||||
|
||||
editForm = {
|
||||
id: char.id,
|
||||
name: override.name ?? '',
|
||||
gender: override.gender ?? '',
|
||||
age: override.age ?? null,
|
||||
bounty: override.bounty ?? null,
|
||||
height: override.height ?? null,
|
||||
origin: override.origin ?? '',
|
||||
affiliations: override.affiliations ?? '',
|
||||
epithets: override.epithets ?? '',
|
||||
pictureUrl: override.pictureUrl ?? '',
|
||||
url: override.url ?? '',
|
||||
devilFruitId: override.devilFruitId !== null && override.devilFruitId !== undefined ? override.devilFruitId : '',
|
||||
hakiObservation: override.hakiObservation ?? char.hakiObservation,
|
||||
hakiArmament: override.hakiArmament ?? char.hakiArmament,
|
||||
hakiConqueror: override.hakiConqueror ?? char.hakiConqueror,
|
||||
firstAppearance: override.firstAppearance ?? '',
|
||||
arcId: override.arcId !== null && override.arcId !== undefined ? override.arcId : '',
|
||||
status: override.status ?? ''
|
||||
};
|
||||
showOriginalValue = {};
|
||||
isEditModalOpen = true;
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
isEditModalOpen = false;
|
||||
selectedCharacterId = null;
|
||||
selectedChar = null;
|
||||
editForm = {
|
||||
id: '',
|
||||
name: '',
|
||||
gender: '',
|
||||
age: null,
|
||||
bounty: 0,
|
||||
height: 0,
|
||||
origin: '',
|
||||
affiliations: '',
|
||||
epithets: '',
|
||||
pictureUrl: '',
|
||||
url: '',
|
||||
devilFruitId: null,
|
||||
hakiObservation: false,
|
||||
hakiArmament: false,
|
||||
hakiConqueror: false,
|
||||
firstAppearance: '',
|
||||
arcId: null,
|
||||
status: ''
|
||||
};
|
||||
};
|
||||
|
||||
const handleDeleteCharacter = async (id: string) => {
|
||||
if (!confirm('Are you sure you want to delete this character?')) return;
|
||||
|
||||
isSaving = true;
|
||||
const formData = new FormData();
|
||||
formData.append('id', id);
|
||||
|
||||
try {
|
||||
const response = await fetch('?/delete', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
location.reload();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
saveMessage = {
|
||||
type: 'error',
|
||||
text: error.error || 'Failed to delete character'
|
||||
};
|
||||
setTimeout(() => {
|
||||
saveMessage = null;
|
||||
}, 3000);
|
||||
}
|
||||
} catch (error) {
|
||||
saveMessage = {
|
||||
type: 'error',
|
||||
text: 'Error deleting character'
|
||||
};
|
||||
setTimeout(() => {
|
||||
saveMessage = null;
|
||||
}, 3000);
|
||||
} finally {
|
||||
isSaving = false;
|
||||
}
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Characters - Admin - OnePieceDle</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-3xl font-bold text-white">Character Management</h2>
|
||||
<button
|
||||
class="rounded-lg bg-amber-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-amber-700"
|
||||
>
|
||||
+ Add Character
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="flex flex-col gap-4 rounded-lg border border-white/10 bg-slate-800/50 p-4 md:flex-row md:items-center">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search characters..."
|
||||
bind:value={searchQuery}
|
||||
class="flex-1 rounded-lg bg-slate-700 px-4 py-2 text-sm text-white placeholder-gray-400 outline-none transition focus:ring-2 focus:ring-amber-600"
|
||||
/>
|
||||
<select
|
||||
bind:value={filterStatus}
|
||||
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 availableStatuses as status}
|
||||
<option value={status}>{status}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<select
|
||||
bind:value={filterGender}
|
||||
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 availableGenders as gender}
|
||||
<option value={gender}>{gender}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<select
|
||||
bind:value={filterArc}
|
||||
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}
|
||||
</select>
|
||||
<select
|
||||
bind:value={filterHaki}
|
||||
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 Haki</option>
|
||||
<option value="observation">Observation</option>
|
||||
<option value="armament">Armament</option>
|
||||
<option value="conqueror">Conqueror</option>
|
||||
<option value="none">No Haki</option>
|
||||
</select>
|
||||
<select
|
||||
bind:value={filterDaily}
|
||||
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 Characters</option>
|
||||
<option value="daily">In Daily Mode</option>
|
||||
<option value="not-daily">Not in Daily Mode</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Characters Table -->
|
||||
<div class="rounded-lg border border-white/10">
|
||||
<div class="max-h-[calc(100vh-20rem)] overflow-auto">
|
||||
<table class="w-full">
|
||||
<thead class="sticky top-0 bg-slate-800 z-10">
|
||||
<tr class="border-b border-white/10">
|
||||
<th class="px-4 py-3 text-left text-sm font-semibold text-gray-300 w-64">Character</th>
|
||||
<th class="px-4 py-3 text-left text-sm font-semibold text-gray-300">Status</th>
|
||||
<th class="px-4 py-3 text-left text-sm font-semibold text-gray-300">Gender</th>
|
||||
<th class="px-4 py-3 text-left text-sm font-semibold text-gray-300">Affiliations</th>
|
||||
<th class="px-4 py-3 text-left text-sm font-semibold text-gray-300">Fruit</th>
|
||||
<th class="px-4 py-3 text-left text-sm font-semibold text-gray-300">Haki</th>
|
||||
<th class="px-4 py-3 text-left text-sm font-semibold text-gray-300">Bounty</th>
|
||||
<th class="px-4 py-3 text-left text-sm font-semibold text-gray-300">Height</th>
|
||||
<th class="px-4 py-3 text-left text-sm font-semibold text-gray-300">Origin</th>
|
||||
<th class="px-4 py-3 text-left text-sm font-semibold text-gray-300">Arc</th>
|
||||
<th class="px-4 py-3 text-left text-sm font-semibold text-gray-300">Daily Mode</th>
|
||||
<th class="px-4 py-3 text-left text-sm font-semibold text-gray-300">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each filteredCharacters as char}
|
||||
<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)}
|
||||
<a
|
||||
href={getFandomUrl(char.displayValues.url)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex-shrink-0 transition-opacity hover:opacity-80"
|
||||
>
|
||||
{#if char.displayValues.pictureUrl}
|
||||
<img
|
||||
src={char.displayValues.pictureUrl}
|
||||
alt={char.displayValues.name}
|
||||
class="h-10 w-10 rounded-full object-cover"
|
||||
/>
|
||||
{:else}
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-slate-700 text-gray-400">
|
||||
{char.displayValues.name?.charAt(0).toUpperCase() || '?'}
|
||||
</div>
|
||||
{/if}
|
||||
</a>
|
||||
{:else}
|
||||
{#if char.displayValues.pictureUrl}
|
||||
<img
|
||||
src={char.displayValues.pictureUrl}
|
||||
alt={char.displayValues.name}
|
||||
class="h-10 w-10 flex-shrink-0 rounded-full object-cover"
|
||||
/>
|
||||
{:else}
|
||||
<div class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full bg-slate-700 text-gray-400">
|
||||
{char.displayValues.name?.charAt(0).toUpperCase() || '?'}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
<div class="flex flex-col min-w-0">
|
||||
{#if getFandomUrl(char.displayValues.url)}
|
||||
<a
|
||||
href={getFandomUrl(char.displayValues.url)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="font-medium truncate text-white hover:text-amber-200 hover:underline"
|
||||
>
|
||||
{char.displayValues.name}
|
||||
</a>
|
||||
{:else}
|
||||
<span class="font-medium truncate">{char.displayValues.name}</span>
|
||||
{/if}
|
||||
{#if char.displayValues.epithets}
|
||||
<span class="text-xs text-gray-500 truncate">
|
||||
{typeof char.displayValues.epithets === 'string'
|
||||
? (char.displayValues.epithets.includes('[') ? JSON.parse(char.displayValues.epithets).join(', ') : char.displayValues.epithets)
|
||||
: char.displayValues.epithets.join(', ')}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<!-- Status -->
|
||||
<td class="px-4 py-4 text-sm text-gray-400 {isFieldOverridden(char, 'status') ? 'bg-amber-500/10' : ''}">{char.displayValues.status || '-'}</td>
|
||||
<!-- Gender -->
|
||||
<td class="px-4 py-4 text-sm text-gray-400 {isFieldOverridden(char, 'gender') ? 'bg-amber-500/10' : ''}">{char.displayValues.gender || '-'}</td>
|
||||
<!-- Affiliations -->
|
||||
<td class="px-4 py-4 text-sm text-gray-400 {isFieldOverridden(char, 'affiliations') ? 'bg-amber-500/10' : ''}">
|
||||
{#if char.displayValues.affiliations}
|
||||
{@const parsedAffiliations = typeof char.displayValues.affiliations === 'string'
|
||||
? (char.displayValues.affiliations.includes('[') ? JSON.parse(char.displayValues.affiliations) : char.displayValues.affiliations.split(',').map((a: string) => a.trim()))
|
||||
: char.displayValues.affiliations}
|
||||
{#if Array.isArray(parsedAffiliations) && parsedAffiliations.length > 0}
|
||||
<span class="inline-block" title={parsedAffiliations.join(', ')}>{parsedAffiliations[0]}</span>
|
||||
{:else}
|
||||
{parsedAffiliations}
|
||||
{/if}
|
||||
{:else}
|
||||
-
|
||||
{/if}
|
||||
</td>
|
||||
<!-- Fruit -->
|
||||
<td class="px-4 py-4 text-sm text-gray-400 {isFieldOverridden(char, 'devilFruitId') ? 'bg-amber-500/10' : ''}">{char.displayValues.devilFruitName || '-'}</td>
|
||||
<!-- Haki -->
|
||||
<td class="px-4 py-4 text-sm {isFieldOverridden(char, 'hakiObservation') || isFieldOverridden(char, 'hakiArmament') || isFieldOverridden(char, 'hakiConqueror') ? 'bg-amber-500/10' : ''}">
|
||||
<div class="flex gap-1">
|
||||
{#if char.displayValues.hakiObservation}<span title="Haki de l'Observation">👁️</span>{/if}
|
||||
{#if char.displayValues.hakiArmament}<span title="Haki de l'Armement">🦾</span>{/if}
|
||||
{#if char.displayValues.hakiConqueror}<span title="Haki des Rois">👑</span>{/if}
|
||||
{#if !char.displayValues.hakiObservation && !char.displayValues.hakiArmament && !char.displayValues.hakiConqueror}
|
||||
<span class="text-gray-400">-</span>
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
<!-- Bounty -->
|
||||
<td class="px-4 py-4 text-sm text-gray-400 {isFieldOverridden(char, 'bounty') ? 'bg-amber-500/10' : ''}">
|
||||
{#if char.displayValues.bounty != null}
|
||||
{formatBounty(char.displayValues.bounty)} ฿
|
||||
{:else}
|
||||
-
|
||||
{/if}
|
||||
</td>
|
||||
<!-- Height -->
|
||||
<td class="px-4 py-4 text-sm text-gray-400 {isFieldOverridden(char, 'height') ? 'bg-amber-500/10' : ''}">
|
||||
{#if char.displayValues.height}
|
||||
{char.displayValues.height} m
|
||||
{:else}
|
||||
-
|
||||
{/if}
|
||||
</td>
|
||||
<!-- Origin -->
|
||||
<td class="px-4 py-4 text-sm text-gray-400 {isFieldOverridden(char, 'origin') ? 'bg-amber-500/10' : ''}">{char.displayValues.origin || '-'}</td>
|
||||
<!-- Arc -->
|
||||
<td class="px-4 py-4 text-sm text-gray-400 {isFieldOverridden(char, 'arcId') || isFieldOverridden(char, 'arcName') ? 'bg-amber-500/10' : ''}">{char.displayValues.arcName || '-'}</td>
|
||||
<!-- Daily Mode -->
|
||||
<td class="px-4 py-4 text-sm {isFieldOverridden(char, 'isInDailyMode') ? 'bg-amber-500/10' : ''}">
|
||||
<form
|
||||
method="POST"
|
||||
action="?/toggleDailyMode"
|
||||
use:enhance={() => {
|
||||
return async ({ result, update }) => {
|
||||
if (result.type === 'success') {
|
||||
await update();
|
||||
showDailyModeToast('success', 'Daily mode updated successfully!');
|
||||
} else if (result.type === 'failure') {
|
||||
showDailyModeToast('error', (result.data as any)?.error || 'Failed to update daily mode');
|
||||
} else {
|
||||
showDailyModeToast('error', 'Failed to update daily mode');
|
||||
}
|
||||
};
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="id" value={char.id} />
|
||||
<input type="hidden" name="isInDailyMode" value={(!char.displayValues.isInDailyMode).toString()} />
|
||||
<label class="flex items-center justify-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={char.displayValues.isInDailyMode}
|
||||
onchange={(e) => {
|
||||
const form = e.currentTarget.closest('form');
|
||||
if (form) form.requestSubmit();
|
||||
}}
|
||||
class="w-5 h-5 rounded border-gray-600 bg-slate-700 text-green-500 focus:ring-2 focus:ring-green-500 focus:ring-offset-0 cursor-pointer"
|
||||
/>
|
||||
</label>
|
||||
</form>
|
||||
</td>
|
||||
<!-- Actions -->
|
||||
<td class="px-4 py-4 text-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
onclick={() => openEditModal(char)}
|
||||
class="text-amber-400 hover:text-amber-300 transition-colors"
|
||||
title="Edit character"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path></svg>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => handleDeleteCharacter(char.id)}
|
||||
class="text-red-400 hover:text-red-300 transition-colors"
|
||||
title="Delete character"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if filteredCharacters.length === 0}
|
||||
<div class="rounded-lg border border-white/10 bg-slate-800/50 py-12 text-center">
|
||||
<p class="text-gray-400">No characters found</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if dailyModeToast}
|
||||
<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'
|
||||
? 'border-green-500/30 bg-green-900/20 text-green-200'
|
||||
: 'border-red-500/30 bg-red-900/20 text-red-200'
|
||||
}`}
|
||||
>
|
||||
{dailyModeToast.text}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Edit Modal -->
|
||||
{#if isEditModalOpen}
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
|
||||
<div class="w-full max-w-2xl max-h-[90vh] overflow-y-auto rounded-lg border border-white/10 bg-slate-900 p-6">
|
||||
<h3 class="text-lg font-bold text-white">Edit Character</h3>
|
||||
<form
|
||||
class="mt-6 space-y-4"
|
||||
method="POST"
|
||||
action="?/update"
|
||||
use:enhance={() => {
|
||||
isSaving = true;
|
||||
return async ({ result }) => {
|
||||
isSaving = false;
|
||||
if (result.type === 'success') {
|
||||
saveMessage = { type: 'success', text: 'Character saved successfully!' };
|
||||
setTimeout(() => {
|
||||
location.reload();
|
||||
}, 1000);
|
||||
} else if (result.type === 'failure') {
|
||||
saveMessage = { type: 'error', text: (result.data as any)?.error || 'Failed to save character' };
|
||||
}
|
||||
setTimeout(() => {
|
||||
saveMessage = null;
|
||||
}, 3000);
|
||||
};
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="id" value={editForm.id} />
|
||||
|
||||
<!-- Basic Information -->
|
||||
<div class="space-y-4">
|
||||
<h4 class="text-sm font-semibold text-amber-500">Basic Information</h4>
|
||||
|
||||
<!-- Name -->
|
||||
<div>
|
||||
<label for="char-name" class="block text-sm font-medium text-gray-300 mb-2">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
id="char-name"
|
||||
name="name"
|
||||
bind:value={editForm.name}
|
||||
placeholder={selectedChar?.name || ''}
|
||||
class="w-full rounded-lg border border-gray-500 bg-slate-800 px-3 py-2 text-white placeholder:text-gray-400/40"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Gender and Age -->
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label for="char-gender" class="block text-sm font-medium text-gray-300 mb-2">Gender</label>
|
||||
<input
|
||||
type="text"
|
||||
id="char-gender"
|
||||
name="gender"
|
||||
bind:value={editForm.gender}
|
||||
placeholder={selectedChar?.gender || ''}
|
||||
class="w-full rounded-lg border border-gray-500 bg-slate-800 px-3 py-2 text-white placeholder:text-gray-400/40"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="char-age" class="block text-sm font-medium text-gray-300 mb-2">Age</label>
|
||||
<input
|
||||
type="number"
|
||||
id="char-age"
|
||||
name="age"
|
||||
bind:value={editForm.age}
|
||||
placeholder={selectedChar?.age?.toString() || ''}
|
||||
class="w-full rounded-lg border border-gray-500 bg-slate-800 px-3 py-2 text-white placeholder:text-gray-400/40"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status -->
|
||||
<div>
|
||||
<label for="char-status" class="block text-sm font-medium text-gray-300 mb-2">Status</label>
|
||||
<input
|
||||
type="text"
|
||||
id="char-status"
|
||||
name="status"
|
||||
bind:value={editForm.status}
|
||||
placeholder={selectedChar?.status || ''}
|
||||
class="w-full rounded-lg border border-gray-500 bg-slate-800 px-3 py-2 text-white placeholder:text-gray-400/40"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Physical Attributes -->
|
||||
<div class="space-y-4">
|
||||
<h4 class="text-sm font-semibold text-amber-500">Physical Attributes</h4>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label for="char-bounty" class="block text-sm font-medium text-gray-300 mb-2">Bounty</label>
|
||||
<input
|
||||
type="number"
|
||||
id="char-bounty"
|
||||
name="bounty"
|
||||
bind:value={editForm.bounty}
|
||||
placeholder={selectedChar?.bounty?.toString() || ''}
|
||||
class="w-full rounded-lg border border-gray-500 bg-slate-800 px-3 py-2 text-white placeholder:text-gray-400/40"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="char-height" class="block text-sm font-medium text-gray-300 mb-2">Height (cm)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="char-height"
|
||||
name="height"
|
||||
bind:value={editForm.height}
|
||||
placeholder={selectedChar?.height?.toString() || ''}
|
||||
class="w-full rounded-lg border border-gray-500 bg-slate-800 px-3 py-2 text-white placeholder:text-gray-400/40"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Location & Affiliations -->
|
||||
<div class="space-y-4">
|
||||
<h4 class="text-sm font-semibold text-amber-500">Location & Affiliations</h4>
|
||||
|
||||
<div>
|
||||
<label for="char-origin" class="block text-sm font-medium text-gray-300 mb-2">Origin</label>
|
||||
<input
|
||||
type="text"
|
||||
id="char-origin"
|
||||
name="origin"
|
||||
bind:value={editForm.origin}
|
||||
placeholder={selectedChar?.origin || ''}
|
||||
class="w-full rounded-lg border border-gray-500 bg-slate-800 px-3 py-2 text-white placeholder:text-gray-400/40"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="char-affiliations" class="block text-sm font-medium text-gray-300 mb-2">Affiliations</label>
|
||||
<input
|
||||
type="text"
|
||||
id="char-affiliations"
|
||||
name="affiliations"
|
||||
bind:value={editForm.affiliations}
|
||||
placeholder={selectedChar?.affiliations || ''}
|
||||
class="w-full rounded-lg border border-gray-500 bg-slate-800 px-3 py-2 text-white placeholder:text-gray-400/40"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="char-arc" class="block text-sm font-medium text-gray-300 mb-2">Arc</label>
|
||||
<select
|
||||
id="char-arc"
|
||||
name="arcId"
|
||||
bind:value={editForm.arcId}
|
||||
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}
|
||||
<option value={arc.id}>{arc.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{#if selectedChar?.arcName}
|
||||
<p class="mt-1 text-xs text-gray-500">Original: {selectedChar.arcName}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Powers -->
|
||||
<div class="space-y-4">
|
||||
<h4 class="text-sm font-semibold text-amber-500">Powers</h4>
|
||||
|
||||
<div>
|
||||
<label for="char-fruit" class="block text-sm font-medium text-gray-300 mb-2">Devil Fruit</label>
|
||||
<select
|
||||
id="char-fruit"
|
||||
name="devilFruitId"
|
||||
bind:value={editForm.devilFruitId}
|
||||
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}
|
||||
<option value={fruit.id}>{fruit.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{#if selectedChar?.devilFruitName}
|
||||
<p class="mt-1 text-xs text-gray-500">Original: {selectedChar.devilFruitName}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<p class="text-sm font-medium text-gray-300">Haki</p>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="hakiObservation"
|
||||
bind:checked={editForm.hakiObservation}
|
||||
class="rounded bg-slate-700"
|
||||
/>
|
||||
<span class="text-sm text-gray-300">Observation Haki</span>
|
||||
{#if selectedChar?.hakiObservation !== undefined}
|
||||
<span class="text-xs text-gray-500">(Original: {selectedChar.hakiObservation ? 'Yes' : 'No'})</span>
|
||||
{/if}
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="hakiArmament"
|
||||
bind:checked={editForm.hakiArmament}
|
||||
class="rounded bg-slate-700"
|
||||
/>
|
||||
<span class="text-sm text-gray-300">Armament Haki</span>
|
||||
{#if selectedChar?.hakiArmament !== undefined}
|
||||
<span class="text-xs text-gray-500">(Original: {selectedChar.hakiArmament ? 'Yes' : 'No'})</span>
|
||||
{/if}
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="hakiConqueror"
|
||||
bind:checked={editForm.hakiConqueror}
|
||||
class="rounded bg-slate-700"
|
||||
/>
|
||||
<span class="text-sm text-gray-300">Conqueror's Haki</span>
|
||||
{#if selectedChar?.hakiConqueror !== undefined}
|
||||
<span class="text-xs text-gray-500">(Original: {selectedChar.hakiConqueror ? 'Yes' : 'No'})</span>
|
||||
{/if}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Timeline -->
|
||||
<div class="space-y-4">
|
||||
<h4 class="text-sm font-semibold text-amber-500">Timeline</h4>
|
||||
|
||||
<div>
|
||||
<label for="char-first-appearance" class="block text-sm font-medium text-gray-300 mb-2">First Appearance</label>
|
||||
<input
|
||||
type="text"
|
||||
id="char-first-appearance"
|
||||
name="firstAppearance"
|
||||
bind:value={editForm.firstAppearance}
|
||||
placeholder={selectedChar?.firstAppearance || ''}
|
||||
class="w-full rounded-lg border border-gray-500 bg-slate-800 px-3 py-2 text-white placeholder:text-gray-400/40"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Media -->
|
||||
<div class="space-y-4">
|
||||
<h4 class="text-sm font-semibold text-amber-500">Media & Details</h4>
|
||||
|
||||
<div>
|
||||
<label for="char-epithets" class="block text-sm font-medium text-gray-300 mb-2">Epithets</label>
|
||||
<input
|
||||
type="text"
|
||||
id="char-epithets"
|
||||
name="epithets"
|
||||
bind:value={editForm.epithets}
|
||||
placeholder={selectedChar?.epithets || ''}
|
||||
class="w-full rounded-lg border border-gray-500 bg-slate-800 px-3 py-2 text-white placeholder:text-gray-400/40"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="char-picture-url" class="block text-sm font-medium text-gray-300 mb-2">Picture URL</label>
|
||||
<input
|
||||
type="url"
|
||||
id="char-picture-url"
|
||||
name="pictureUrl"
|
||||
bind:value={editForm.pictureUrl}
|
||||
placeholder={selectedChar?.pictureUrl || ''}
|
||||
class="w-full rounded-lg border border-gray-500 bg-slate-800 px-3 py-2 text-white placeholder:text-gray-400/40"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="char-url" class="block text-sm font-medium text-gray-300 mb-2">Fandom URL</label>
|
||||
<input
|
||||
type="url"
|
||||
id="char-url"
|
||||
name="url"
|
||||
bind:value={editForm.url}
|
||||
placeholder={selectedChar?.url || ''}
|
||||
class="w-full rounded-lg border border-gray-500 bg-slate-800 px-3 py-2 text-white placeholder:text-gray-400/40"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onclick={closeModal}
|
||||
disabled={isSaving}
|
||||
class="flex-1 rounded-lg border border-gray-500 px-4 py-2 text-sm font-medium text-gray-300 transition-colors hover:bg-slate-700 disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSaving}
|
||||
class="flex-1 rounded-lg bg-amber-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-amber-700 disabled:opacity-50"
|
||||
>
|
||||
{isSaving ? 'Saving...' : 'Save Changes'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{#if saveMessage}
|
||||
<div
|
||||
class={`mt-4 rounded-lg p-3 text-sm font-medium ${
|
||||
saveMessage.type === 'success'
|
||||
? 'border border-green-500/50 bg-green-500/10 text-green-300'
|
||||
: 'border border-red-500/50 bg-red-500/10 text-red-300'
|
||||
}`}
|
||||
>
|
||||
{saveMessage.text}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
62
src/routes/(admin)/admin/config/+page.server.ts
Normal file
62
src/routes/(admin)/admin/config/+page.server.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { db } from '$lib/server/db';
|
||||
import { config } from '$lib/server/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
const configEntries = await db.select().from(config);
|
||||
|
||||
return {
|
||||
config: configEntries
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
update: async ({ request, locals }) => {
|
||||
if (!locals.user?.isAdmin) {
|
||||
return fail(401, { error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const key = formData.get('key') as string;
|
||||
const value = formData.get('value') as string;
|
||||
|
||||
if (!key) {
|
||||
return fail(400, { error: 'Config key is required' });
|
||||
}
|
||||
|
||||
try {
|
||||
await db
|
||||
.insert(config)
|
||||
.values({ key, value })
|
||||
.onConflictDoUpdate({ target: config.key, set: { value } });
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Config update error:', error);
|
||||
return fail(500, { error: 'Failed to update configuration' });
|
||||
}
|
||||
},
|
||||
|
||||
delete: async ({ request, locals }) => {
|
||||
if (!locals.user?.isAdmin) {
|
||||
return fail(401, { error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const key = formData.get('key') as string;
|
||||
|
||||
if (!key) {
|
||||
return fail(400, { error: 'Config key is required' });
|
||||
}
|
||||
|
||||
try {
|
||||
await db.delete(config).where(eq(config.key, key));
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Config delete error:', error);
|
||||
return fail(500, { error: 'Failed to delete configuration' });
|
||||
}
|
||||
}
|
||||
};
|
||||
266
src/routes/(admin)/admin/config/+page.svelte
Normal file
266
src/routes/(admin)/admin/config/+page.svelte
Normal file
@@ -0,0 +1,266 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
interface Props {
|
||||
data: PageData;
|
||||
}
|
||||
|
||||
interface ConfigItem {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
let { data }: Props = $props();
|
||||
|
||||
let configItems = $state<ConfigItem[]>([]);
|
||||
let newKey = $state('');
|
||||
let newValue = $state('');
|
||||
let editingKey = $state<string | null>(null);
|
||||
let editingValue = $state('');
|
||||
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;
|
||||
editingValue = item.value;
|
||||
};
|
||||
|
||||
const cancelEdit = () => {
|
||||
editingKey = null;
|
||||
editingValue = '';
|
||||
saveMessage = null;
|
||||
};
|
||||
|
||||
const handleAddNew = async () => {
|
||||
if (!newKey || !newValue) {
|
||||
saveMessage = { type: 'error', text: 'Both key and value are required' };
|
||||
return;
|
||||
}
|
||||
|
||||
if (configItems.some((item) => item.key === newKey)) {
|
||||
saveMessage = { type: 'error', text: 'A config with this key already exists' };
|
||||
return;
|
||||
}
|
||||
|
||||
isSaving = true;
|
||||
const formData = new FormData();
|
||||
formData.append('key', newKey);
|
||||
formData.append('value', newValue);
|
||||
|
||||
try {
|
||||
const response = await fetch('?/update', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
configItems = [...configItems, { key: newKey, value: newValue }];
|
||||
newKey = '';
|
||||
newValue = '';
|
||||
saveMessage = { type: 'success', text: 'Config added successfully' };
|
||||
} else {
|
||||
saveMessage = { type: 'error', text: 'Failed to add config' };
|
||||
}
|
||||
} catch (error) {
|
||||
saveMessage = { type: 'error', text: 'Error adding config' };
|
||||
} finally {
|
||||
isSaving = false;
|
||||
setTimeout(() => {
|
||||
saveMessage = null;
|
||||
}, 3000);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (key: string) => {
|
||||
if (!confirm(`Are you sure you want to delete "${key}"?`)) return;
|
||||
|
||||
isSaving = true;
|
||||
const formData = new FormData();
|
||||
formData.append('key', key);
|
||||
|
||||
try {
|
||||
const response = await fetch('?/delete', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
configItems = configItems.filter((item) => item.key !== key);
|
||||
saveMessage = { type: 'success', text: 'Config deleted successfully' };
|
||||
} else {
|
||||
saveMessage = { type: 'error', text: 'Failed to delete config' };
|
||||
}
|
||||
} catch (error) {
|
||||
saveMessage = { type: 'error', text: 'Error deleting config' };
|
||||
} finally {
|
||||
isSaving = false;
|
||||
setTimeout(() => {
|
||||
saveMessage = null;
|
||||
}, 3000);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Settings - Admin - OnePieceDle</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<h2 class="text-3xl font-bold text-white">Configuration</h2>
|
||||
|
||||
<!-- Add New Config -->
|
||||
<div class="rounded-lg border border-white/10 bg-slate-800/50 p-6">
|
||||
<h3 class="mb-4 text-lg font-semibold text-white">Add New Configuration</h3>
|
||||
<div class="flex gap-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Key name"
|
||||
bind:value={newKey}
|
||||
class="flex-1 rounded-lg bg-slate-700 px-4 py-2 text-sm text-white placeholder-gray-400 outline-none transition focus:ring-2 focus:ring-amber-600"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Value"
|
||||
bind:value={newValue}
|
||||
class="flex-1 rounded-lg bg-slate-700 px-4 py-2 text-sm text-white placeholder-gray-400 outline-none transition focus:ring-2 focus:ring-amber-600"
|
||||
/>
|
||||
<button
|
||||
onclick={handleAddNew}
|
||||
disabled={isSaving}
|
||||
class="rounded-lg bg-amber-600 px-6 py-2 text-sm font-medium text-white transition-colors hover:bg-amber-700 disabled:opacity-50"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Config Table -->
|
||||
<div class="rounded-lg border border-white/10">
|
||||
<div class="max-h-[calc(100vh-20rem)] overflow-auto">
|
||||
<table class="w-full">
|
||||
<thead class="sticky top-0 bg-slate-800 z-10">
|
||||
<tr class="border-b border-white/10">
|
||||
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-300">Key</th>
|
||||
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-300">Value</th>
|
||||
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-300">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each configItems as item}
|
||||
{#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>
|
||||
<td class="px-6 py-4 text-sm">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={editingValue}
|
||||
class="w-full rounded-lg bg-slate-700 px-3 py-1 text-sm text-white outline-none transition focus:ring-2 focus:ring-amber-600"
|
||||
/>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm">
|
||||
<form
|
||||
method="POST"
|
||||
action="?/update"
|
||||
use:enhance={() => {
|
||||
isSaving = true;
|
||||
return async ({ result }) => {
|
||||
isSaving = false;
|
||||
if (result.type === 'success') {
|
||||
const idx = configItems.findIndex((i) => i.key === item.key);
|
||||
if (idx !== -1) {
|
||||
configItems[idx].value = editingValue;
|
||||
}
|
||||
editingKey = null;
|
||||
saveMessage = { type: 'success', text: 'Config updated' };
|
||||
} else if (result.type === 'failure') {
|
||||
saveMessage = { type: 'error', text: (result.data?.error as string) || 'Failed to update' };
|
||||
} else {
|
||||
saveMessage = { type: 'error', text: 'Failed to update' };
|
||||
}
|
||||
setTimeout(() => {
|
||||
saveMessage = null;
|
||||
}, 3000);
|
||||
};
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="key" value={item.key} />
|
||||
<input type="hidden" name="value" value={editingValue} />
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSaving}
|
||||
class="rounded bg-green-600 px-3 py-1 text-xs font-medium text-white hover:bg-green-700 disabled:opacity-50"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={cancelEdit}
|
||||
disabled={isSaving}
|
||||
class="rounded bg-gray-600 px-3 py-1 text-xs font-medium text-white hover:bg-gray-700 disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{:else}
|
||||
<tr class="border-b border-white/5 hover:bg-slate-800/50">
|
||||
<td class="px-6 py-4 text-sm font-medium text-white">{item.key}</td>
|
||||
<td class="px-6 py-4 text-sm text-gray-400">
|
||||
<code class="rounded bg-slate-800/50 px-2 py-1">{item.value}</code>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
onclick={() => startEdit(item)}
|
||||
class="text-amber-400 hover:text-amber-300 transition-colors"
|
||||
title="Edit config"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path></svg>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => handleDelete(item.key)}
|
||||
disabled={isSaving}
|
||||
class="text-red-400 hover:text-red-300 transition-colors disabled:opacity-50"
|
||||
title="Delete config"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/if}
|
||||
{/each}
|
||||
</tbody>
|
||||
</table> </div> </div>
|
||||
|
||||
{#if configItems.length === 0}
|
||||
<div class="rounded-lg border border-white/10 bg-slate-800/50 py-12 text-center">
|
||||
<p class="text-gray-400">No configuration entries yet</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Save Message -->
|
||||
{#if saveMessage}
|
||||
<div
|
||||
class={`rounded-lg p-4 text-sm font-medium ${
|
||||
saveMessage.type === 'success'
|
||||
? 'border border-green-500/50 bg-green-500/10 text-green-300'
|
||||
: 'border border-red-500/50 bg-red-500/10 text-red-300'
|
||||
}`}
|
||||
>
|
||||
{saveMessage.text}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
65
src/routes/(admin)/admin/devil-fruits/+page.server.ts
Normal file
65
src/routes/(admin)/admin/devil-fruits/+page.server.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { db } from '$lib/server/db';
|
||||
import { devilFruit } from '$lib/server/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
const devilFruits = await db.select().from(devilFruit).orderBy(devilFruit.name);
|
||||
|
||||
return {
|
||||
devilFruits
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
update: async ({ request, locals }) => {
|
||||
if (!locals.user?.isAdmin) {
|
||||
return fail(401, { error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const id = formData.get('id') as string;
|
||||
|
||||
if (!id) {
|
||||
return fail(400, { error: 'Devil Fruit ID is required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const updates: Record<string, any> = {};
|
||||
formData.forEach((value, key) => {
|
||||
if (key !== 'id') {
|
||||
updates[key] = value || null;
|
||||
}
|
||||
});
|
||||
|
||||
await db.update(devilFruit).set(updates).where(eq(devilFruit.id, id));
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Devil Fruit update error:', error);
|
||||
return fail(500, { error: 'Failed to update devil fruit' });
|
||||
}
|
||||
},
|
||||
|
||||
delete: async ({ request, locals }) => {
|
||||
if (!locals.user?.isAdmin) {
|
||||
return fail(401, { error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const id = formData.get('id') as string;
|
||||
|
||||
if (!id) {
|
||||
return fail(400, { error: 'Devil Fruit ID is required' });
|
||||
}
|
||||
|
||||
try {
|
||||
await db.delete(devilFruit).where(eq(devilFruit.id, id));
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Devil Fruit delete error:', error);
|
||||
return fail(500, { error: 'Failed to delete devil fruit' });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
284
src/routes/(admin)/admin/devil-fruits/+page.svelte
Normal file
284
src/routes/(admin)/admin/devil-fruits/+page.svelte
Normal file
@@ -0,0 +1,284 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
interface Props {
|
||||
data: PageData;
|
||||
}
|
||||
|
||||
let { data }: Props = $props();
|
||||
|
||||
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);
|
||||
|
||||
const fruitTypes = ['Paramecia', 'Zoan', 'Logia', 'Unknown'] as const;
|
||||
|
||||
let editForm = $state<any>({
|
||||
id: '',
|
||||
name: '',
|
||||
type: 'Paramecia',
|
||||
url: ''
|
||||
});
|
||||
|
||||
const filteredFruits = $derived.by(() => {
|
||||
return data.devilFruits.filter((fruit) => {
|
||||
const matchesSearch = fruit.name.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
const matchesFilter = filterType === 'all' || fruit.type === filterType;
|
||||
return matchesSearch && matchesFilter;
|
||||
});
|
||||
});
|
||||
|
||||
const openEditModal = (fruit: any) => {
|
||||
selectedFruitId = fruit.id;
|
||||
editForm = { ...fruit };
|
||||
isEditModalOpen = true;
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
isEditModalOpen = false;
|
||||
selectedFruitId = null;
|
||||
editForm = {
|
||||
id: '',
|
||||
name: '',
|
||||
type: 'Paramecia',
|
||||
url: ''
|
||||
};
|
||||
};
|
||||
|
||||
const getTypeColor = (type: string) => {
|
||||
switch (type) {
|
||||
case 'Paramecia':
|
||||
return 'bg-blue-500/20 text-blue-300';
|
||||
case 'Zoan':
|
||||
return 'bg-green-500/20 text-green-300';
|
||||
case 'Logia':
|
||||
return 'bg-red-500/20 text-red-300';
|
||||
default:
|
||||
return 'bg-gray-500/20 text-gray-300';
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteFruit = async (id: string) => {
|
||||
if (!confirm('Are you sure you want to delete this devil fruit?')) return;
|
||||
|
||||
isSaving = true;
|
||||
const formData = new FormData();
|
||||
formData.append('id', id);
|
||||
|
||||
try {
|
||||
const response = await fetch('?/delete', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
location.reload();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
saveMessage = {
|
||||
type: 'error',
|
||||
text: error.error || 'Failed to delete devil fruit'
|
||||
};
|
||||
setTimeout(() => {
|
||||
saveMessage = null;
|
||||
}, 3000);
|
||||
}
|
||||
} catch (error) {
|
||||
saveMessage = {
|
||||
type: 'error',
|
||||
text: 'Error deleting devil fruit'
|
||||
};
|
||||
setTimeout(() => {
|
||||
saveMessage = null;
|
||||
}, 3000);
|
||||
} finally {
|
||||
isSaving = false;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Devil Fruits - Admin - OnePieceDle</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-3xl font-bold text-white">Devil Fruit Management</h2>
|
||||
<button
|
||||
class="rounded-lg bg-amber-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-amber-700"
|
||||
>
|
||||
+ Add Devil Fruit
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="flex flex-col gap-4 rounded-lg border border-white/10 bg-slate-800/50 p-4 md:flex-row md:items-center">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search devil fruits..."
|
||||
bind:value={searchQuery}
|
||||
class="flex-1 rounded-lg bg-slate-700 px-4 py-2 text-sm text-white placeholder-gray-400 outline-none transition focus:ring-2 focus:ring-amber-600"
|
||||
/>
|
||||
<select
|
||||
bind:value={filterType}
|
||||
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 Types</option>
|
||||
<option value="Paramecia">Paramecia</option>
|
||||
<option value="Zoan">Zoan</option>
|
||||
<option value="Logia">Logia</option>
|
||||
<option value="Unknown">Unknown</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Devil Fruits Table -->
|
||||
<div class="rounded-lg border border-white/10">
|
||||
<div class="max-h-[calc(100vh-20rem)] overflow-auto">
|
||||
<table class="w-full">
|
||||
<thead class="sticky top-0 bg-slate-800 z-10">
|
||||
<tr class="border-b border-white/10">
|
||||
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-300">Name</th>
|
||||
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-300">Type</th>
|
||||
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-300">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each filteredFruits as fruit}
|
||||
<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">
|
||||
<span class={`inline-block rounded-full px-2 py-1 text-xs ${getTypeColor(fruit.type || 'Unknown')}`}>
|
||||
{fruit.type || 'Unknown'}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
onclick={() => openEditModal(fruit)}
|
||||
class="text-amber-400 hover:text-amber-300 transition-colors"
|
||||
title="Edit devil fruit"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path></svg>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => handleDeleteFruit(fruit.id)}
|
||||
class="text-red-400 hover:text-red-300 transition-colors"
|
||||
title="Delete devil fruit"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div> </div>
|
||||
{#if filteredFruits.length === 0}
|
||||
<div class="rounded-lg border border-white/10 bg-slate-800/50 py-12 text-center">
|
||||
<p class="text-gray-400">No devil fruits found</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Edit Modal -->
|
||||
{#if isEditModalOpen}
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||
<div class="w-full max-w-md rounded-lg border border-white/10 bg-slate-900 p-6">
|
||||
<h3 class="text-lg font-bold text-white">Edit Devil Fruit</h3>
|
||||
<form
|
||||
class="mt-6 space-y-4"
|
||||
method="POST"
|
||||
action="?/update"
|
||||
use:enhance={() => {
|
||||
isSaving = true;
|
||||
return async ({ result }) => {
|
||||
isSaving = false;
|
||||
if (result.type === 'success') {
|
||||
saveMessage = { type: 'success', text: 'Devil Fruit saved successfully!' };
|
||||
setTimeout(() => {
|
||||
location.reload();
|
||||
}, 1000);
|
||||
} else if (result.type === 'failure') {
|
||||
saveMessage = { type: 'error', text: (result.data as any)?.error || 'Failed to save devil fruit' };
|
||||
}
|
||||
setTimeout(() => {
|
||||
saveMessage = null;
|
||||
}, 3000);
|
||||
};
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="id" value={editForm.id} />
|
||||
<div>
|
||||
<label for="fruit-name" class="block text-sm font-medium text-gray-300">Name</label>
|
||||
<input
|
||||
id="fruit-name"
|
||||
type="text"
|
||||
name="name"
|
||||
bind:value={editForm.name}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="fruit-type" class="block text-sm font-medium text-gray-300">Type</label>
|
||||
<select
|
||||
id="fruit-type"
|
||||
name="type"
|
||||
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}
|
||||
<option value={type}>{type}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="fruit-url" class="block text-sm font-medium text-gray-300">URL</label>
|
||||
<input
|
||||
id="fruit-url"
|
||||
type="text"
|
||||
name="url"
|
||||
bind:value={editForm.url}
|
||||
placeholder="https://..."
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onclick={closeModal}
|
||||
disabled={isSaving}
|
||||
class="flex-1 rounded-lg border border-gray-500 px-4 py-2 text-sm font-medium text-gray-300 transition-colors hover:bg-slate-700 disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSaving}
|
||||
class="flex-1 rounded-lg bg-amber-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-amber-700 disabled:opacity-50"
|
||||
>
|
||||
{isSaving ? 'Saving...' : 'Save Changes'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{#if saveMessage}
|
||||
<div
|
||||
class={`mt-4 rounded-lg p-3 text-sm font-medium ${
|
||||
saveMessage.type === 'success'
|
||||
? 'border border-green-500/50 bg-green-500/10 text-green-300'
|
||||
: 'border border-red-500/50 bg-red-500/10 text-red-300'
|
||||
}`}
|
||||
>
|
||||
{saveMessage.text}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
67
src/routes/(admin)/admin/users/+page.server.ts
Normal file
67
src/routes/(admin)/admin/users/+page.server.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { db } from '$lib/server/db';
|
||||
import { user } from '$lib/server/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
const users = await db.select().from(user).orderBy(user.createdAt);
|
||||
|
||||
return {
|
||||
users: users.map((u) => ({
|
||||
...u,
|
||||
createdAt: new Date(u.createdAt).toLocaleDateString(),
|
||||
updatedAt: new Date(u.updatedAt).toLocaleDateString()
|
||||
}))
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
update: async ({ request, locals }) => {
|
||||
if (!locals.user?.isAdmin) {
|
||||
return fail(401, { error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const id = formData.get('id') as string;
|
||||
|
||||
if (!id) {
|
||||
return fail(400, { error: 'User ID is required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const updates: Record<string, any> = {
|
||||
name: formData.get('name') as string,
|
||||
email: formData.get('email') as string,
|
||||
isAdmin: formData.has('isAdmin'),
|
||||
emailVerified: formData.has('emailVerified')
|
||||
};
|
||||
|
||||
await db.update(user).set(updates).where(eq(user.id, id));
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('User update error:', error);
|
||||
return fail(500, { error: 'Failed to update user' });
|
||||
}
|
||||
},
|
||||
|
||||
delete: async ({ request, locals }) => {
|
||||
if (!locals.user?.isAdmin) {
|
||||
return fail(401, { error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const id = formData.get('id') as string;
|
||||
|
||||
if (!id) {
|
||||
return fail(400, { error: 'User ID is required' });
|
||||
}
|
||||
|
||||
try {
|
||||
await db.delete(user).where(eq(user.id, id));
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('User delete error:', error);
|
||||
return fail(500, { error: 'Failed to delete user' });
|
||||
}
|
||||
}};
|
||||
278
src/routes/(admin)/admin/users/+page.svelte
Normal file
278
src/routes/(admin)/admin/users/+page.svelte
Normal file
@@ -0,0 +1,278 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
interface Props {
|
||||
data: PageData;
|
||||
}
|
||||
|
||||
let { data }: Props = $props();
|
||||
|
||||
let searchQuery = $state('');
|
||||
let filterRole = $state<'all' | 'admin' | 'user'>('all');
|
||||
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: '',
|
||||
name: '',
|
||||
email: '',
|
||||
isAdmin: false,
|
||||
emailVerified: false
|
||||
});
|
||||
|
||||
const filteredUsers = $derived.by(() => {
|
||||
return data.users.filter((usr) => {
|
||||
const matchesSearch =
|
||||
usr.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
usr.email.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
const matchesFilter =
|
||||
filterRole === 'all' || (filterRole === 'admin' && usr.isAdmin) || (filterRole === 'user' && !usr.isAdmin);
|
||||
return matchesSearch && matchesFilter;
|
||||
});
|
||||
});
|
||||
|
||||
const openEditModal = (usr: any) => {
|
||||
selectedUserId = usr.id;
|
||||
editForm = { ...usr };
|
||||
isEditModalOpen = true;
|
||||
saveMessage = null;
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
isEditModalOpen = false;
|
||||
selectedUserId = null;
|
||||
editForm = {
|
||||
id: '',
|
||||
name: '',
|
||||
email: '',
|
||||
isAdmin: false,
|
||||
emailVerified: false
|
||||
};
|
||||
saveMessage = null;
|
||||
};
|
||||
|
||||
const handleDeleteUser = async (id: string) => {
|
||||
if (!confirm('Are you sure you want to delete this user?')) return;
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('id', id);
|
||||
|
||||
const response = await fetch('?/delete', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
alert('Failed to delete user');
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Error deleting user: ' + error);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Users - Admin - OnePieceDle</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-3xl font-bold text-white">User Management</h2>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="flex flex-col gap-4 rounded-lg border border-white/10 bg-slate-800/50 p-4 md:flex-row md:items-center">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search users by name or email..."
|
||||
bind:value={searchQuery}
|
||||
class="flex-1 rounded-lg bg-slate-700 px-4 py-2 text-sm text-white placeholder-gray-400 outline-none transition focus:ring-2 focus:ring-amber-600"
|
||||
/>
|
||||
<select
|
||||
bind:value={filterRole}
|
||||
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 Roles</option>
|
||||
<option value="admin">Admin</option>
|
||||
<option value="user">User</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Users Table -->
|
||||
<div class="rounded-lg border border-white/10">
|
||||
<div class="max-h-[calc(100vh-20rem)] overflow-auto">
|
||||
<table class="w-full">
|
||||
<thead class="sticky top-0 bg-slate-800 z-10">
|
||||
<tr class="border-b border-white/10">
|
||||
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-300">Name</th>
|
||||
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-300">Email</th>
|
||||
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-300">Role</th>
|
||||
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-300">Verified</th>
|
||||
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-300">Joined</th>
|
||||
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-300">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each filteredUsers as usr}
|
||||
<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>
|
||||
<td class="px-6 py-4 text-sm">
|
||||
{#if usr.isAdmin}
|
||||
<span class="inline-block rounded-full bg-amber-500/20 px-2 py-1 text-xs text-amber-300">
|
||||
Admin
|
||||
</span>
|
||||
{:else}
|
||||
<span class="inline-block rounded-full bg-blue-500/20 px-2 py-1 text-xs text-blue-300">
|
||||
User
|
||||
</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm">
|
||||
{#if usr.emailVerified}
|
||||
<span class="inline-block rounded-full bg-green-500/20 px-2 py-1 text-xs text-green-300">
|
||||
✓
|
||||
</span>
|
||||
{:else}
|
||||
<span class="inline-block rounded-full bg-red-500/20 px-2 py-1 text-xs text-red-300">
|
||||
✗
|
||||
</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm text-gray-400">{usr.createdAt}</td>
|
||||
<td class="px-6 py-4 text-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
onclick={() => openEditModal(usr)}
|
||||
class="text-amber-400 hover:text-amber-300 transition-colors"
|
||||
title="Edit user"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path></svg>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => handleDeleteUser(usr.id)}
|
||||
class="text-red-400 hover:text-red-300 transition-colors"
|
||||
title="Delete user"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if filteredUsers.length === 0}
|
||||
<div class="rounded-lg border border-white/10 bg-slate-800/50 py-12 text-center">
|
||||
<p class="text-gray-400">No users found</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Edit Modal -->
|
||||
{#if isEditModalOpen}
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||
<div class="w-full max-w-md rounded-lg border border-white/10 bg-slate-900 p-6">
|
||||
<h3 class="text-lg font-bold text-white">Edit User</h3>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/update"
|
||||
class="mt-6 space-y-4"
|
||||
use:enhance={() => {
|
||||
isSaving = true;
|
||||
saveMessage = null;
|
||||
return async ({ result }) => {
|
||||
isSaving = false;
|
||||
if (result.type === 'success') {
|
||||
saveMessage = { type: 'success', message: 'User updated successfully' };
|
||||
setTimeout(() => {
|
||||
closeModal();
|
||||
window.location.reload();
|
||||
}, 500);
|
||||
} else if (result.type === 'failure') {
|
||||
saveMessage = { type: 'error', message: String(result.data?.error) || 'Failed to update user' };
|
||||
}
|
||||
};
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="id" value={editForm.id} />
|
||||
<div>
|
||||
<label for="user-name" class="block text-sm font-medium text-gray-300">Name</label>
|
||||
<input
|
||||
id="user-name"
|
||||
type="text"
|
||||
name="name"
|
||||
bind:value={editForm.name}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="user-email" class="block text-sm font-medium text-gray-300">Email</label>
|
||||
<input
|
||||
id="user-email"
|
||||
type="email"
|
||||
name="email"
|
||||
bind:value={editForm.email}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="admin-role"
|
||||
name="isAdmin"
|
||||
bind:checked={editForm.isAdmin}
|
||||
class="rounded bg-slate-700"
|
||||
/>
|
||||
<label for="admin-role" class="text-sm font-medium text-gray-300">Admin Role</label>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="verified"
|
||||
name="emailVerified"
|
||||
bind:checked={editForm.emailVerified}
|
||||
class="rounded bg-slate-700"
|
||||
/>
|
||||
<label for="verified" class="text-sm font-medium text-gray-300">Email Verified</label>
|
||||
</div>
|
||||
{#if saveMessage}
|
||||
<div class={`rounded-lg p-3 text-sm ${
|
||||
saveMessage.type === 'success'
|
||||
? 'bg-green-500/10 text-green-400'
|
||||
: 'bg-red-500/10 text-red-400'
|
||||
}`}>
|
||||
{saveMessage.message}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onclick={closeModal}
|
||||
disabled={isSaving}
|
||||
class="flex-1 rounded-lg border border-gray-500 px-4 py-2 text-sm font-medium text-gray-300 transition-colors hover:bg-slate-700 disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSaving}
|
||||
class="flex-1 rounded-lg bg-amber-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-amber-700 disabled:opacity-50"
|
||||
>
|
||||
{isSaving ? 'Saving...' : 'Save Changes'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
19
src/routes/(game)/+layout.svelte
Normal file
19
src/routes/(game)/+layout.svelte
Normal file
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
import ProfileButton from '$lib/components/ProfileButton.svelte';
|
||||
|
||||
let { children, data } = $props();
|
||||
</script>
|
||||
|
||||
<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">
|
||||
OnePieceDle
|
||||
</a>
|
||||
<ProfileButton user={data.user} />
|
||||
</div>
|
||||
</header>
|
||||
<main class="pt-20">
|
||||
{@render children()}
|
||||
</main>
|
||||
</div>
|
||||
@@ -9,12 +9,12 @@
|
||||
</svelte:head>
|
||||
|
||||
<main
|
||||
class="relative min-h-screen overflow-hidden bg-slate-950 text-slate-100"
|
||||
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 mix-blend-screen opacity-20 bg-[radial-gradient(circle_at_top,rgba(255,215,84,0.35),transparent_55%)]"></div>
|
||||
|
||||
<div class="relative mx-auto flex min-h-screen w-full max-w-6xl flex-col items-center justify-center px-6 py-10">
|
||||
<div class="relative mx-auto flex w-full max-w-6xl flex-col items-center justify-center px-6 py-10">
|
||||
<div class="flex w-full flex-col items-center gap-8">
|
||||
<div class="text-center mb-12">
|
||||
<h1 class="text-4xl font-black uppercase tracking-[0.3em] text-amber-50 sm:text-6xl">
|
||||
@@ -37,12 +37,15 @@
|
||||
</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">Partie libre</h2>
|
||||
<p class="mt-3 text-lg font-semibold text-white">Entraine-toi avec des pirates legendaires</p>
|
||||
<p class="mt-2 text-sm text-slate-200">Choisis une epoque, regle la difficulte et vogue a ton rythme.</p>
|
||||
<button class="mt-5 w-full rounded-full border border-amber-200/40 bg-transparent px-5 py-3 text-sm font-semibold text-amber-100 transition hover:border-amber-200 hover:text-amber-50">
|
||||
En construction
|
||||
</button>
|
||||
<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>
|
||||
<a
|
||||
href="/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
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full rounded-3xl border border-white/10 bg-white/5 p-6 shadow-[0_24px_60px_rgba(0,0,0,0.45)] backdrop-blur sm:p-8">
|
||||
257
src/routes/(game)/daily/+page.svelte
Normal file
257
src/routes/(game)/daily/+page.svelte
Normal file
@@ -0,0 +1,257 @@
|
||||
<script lang="ts">
|
||||
import { 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';
|
||||
|
||||
export let data;
|
||||
|
||||
let selectedCharacters: any[] = [];
|
||||
let isLoaded = false;
|
||||
let isGeckoMoriaWin = false;
|
||||
|
||||
let wasOriginAvailable = false;
|
||||
let wasFruitAvailable = false;
|
||||
let wasAffiliationAvailable = false;
|
||||
let showOriginUnlock = false;
|
||||
let showFruitUnlock = false;
|
||||
let showAffiliationUnlock = false;
|
||||
|
||||
// Load from localStorage on mount
|
||||
onMount(() => {
|
||||
const storedDailyCharacterId = localStorage.getItem('dailyCurrentCharacterId');
|
||||
const dailyCurrentCharacterId = dailyCharacter?.id;
|
||||
|
||||
// If the daily character has changed, clear the history
|
||||
if (storedDailyCharacterId && storedDailyCharacterId !== dailyCurrentCharacterId) {
|
||||
localStorage.removeItem('dailyCharacterHistory');
|
||||
selectedCharacters = [];
|
||||
} else {
|
||||
// Load existing history if the character hasn't changed
|
||||
const stored = localStorage.getItem('dailyCharacterHistory');
|
||||
if (stored) {
|
||||
try {
|
||||
const storedIds = JSON.parse(stored);
|
||||
// Reconstruct character objects from IDs
|
||||
if (Array.isArray(storedIds)) {
|
||||
selectedCharacters = storedIds
|
||||
.map((id: string) => data.characters.find((c: any) => c.id === id))
|
||||
.filter((c: any) => c !== undefined);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to parse stored history', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Store the current daily character ID
|
||||
if (dailyCurrentCharacterId) {
|
||||
localStorage.setItem('dailyCurrentCharacterId', dailyCurrentCharacterId);
|
||||
}
|
||||
|
||||
isLoaded = true;
|
||||
});
|
||||
|
||||
// Save to localStorage whenever selectedCharacters changes (only store IDs)
|
||||
$: if (isLoaded && selectedCharacters) {
|
||||
const ids = selectedCharacters.map(char => char.id);
|
||||
localStorage.setItem('dailyCharacterHistory', JSON.stringify(ids));
|
||||
}
|
||||
|
||||
$: characters = data.characters || [];
|
||||
$: dailyCharacter = data.dailyCharacter;
|
||||
$: yesterdayCharacter = data.yesterdayCharacter;
|
||||
$: columnVisibility = data.columnVisibility || {};
|
||||
$: hasWon = selectedCharacters.some(char => char.id === dailyCharacter.id);
|
||||
|
||||
// Hint availability 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;
|
||||
selectCharacter(character);
|
||||
}
|
||||
|
||||
function selectCharacter(character: any) {
|
||||
selectedCharacters = [character, ...selectedCharacters];
|
||||
|
||||
// Check if player won
|
||||
if (character.id === dailyCharacter.id) {
|
||||
// Send request to record win in database
|
||||
fetch('/daily', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
characterId: dailyCharacter.id
|
||||
})
|
||||
}).catch(err => console.error('Failed to record win:', err));
|
||||
|
||||
// Check if it's gecko_moria for special animation
|
||||
if (dailyCharacter.id === 'gecko_moria_gecko_moria') {
|
||||
isGeckoMoriaWin = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resetHistory() {
|
||||
selectedCharacters = [];
|
||||
localStorage.removeItem('dailyCharacterHistory');
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>OnePieceDle - Mode du jour</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);
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
text-shadow: 0 0 60px rgba(0, 0, 0, 0.9), 0 0 100px rgba(30, 30, 30, 1), inset 0 0 50px rgba(0, 0, 0, 0.7);
|
||||
opacity: 0.9;
|
||||
}
|
||||
100% {
|
||||
text-shadow: 0 0 20px rgba(0, 0, 0, 0.5), 0 0 40px rgba(50, 50, 50, 0.8);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@keyframes moria-chaos {
|
||||
0% {
|
||||
transform: rotate(0deg) scale(1);
|
||||
filter: invert(0%) hue-rotate(0deg) blur(0px);
|
||||
}
|
||||
10% {
|
||||
transform: rotate(15deg) scale(1.02);
|
||||
filter: invert(30%) hue-rotate(45deg) blur(2px);
|
||||
}
|
||||
20% {
|
||||
transform: rotate(-10deg) scale(0.98);
|
||||
filter: invert(60%) hue-rotate(90deg) blur(1px);
|
||||
}
|
||||
30% {
|
||||
transform: rotate(25deg) scale(1.05);
|
||||
filter: invert(100%) hue-rotate(180deg) blur(3px);
|
||||
}
|
||||
40% {
|
||||
transform: rotate(-20deg) scale(0.95);
|
||||
filter: invert(80%) hue-rotate(270deg) blur(2px);
|
||||
}
|
||||
50% {
|
||||
transform: rotate(30deg) scale(1.08);
|
||||
filter: invert(100%) hue-rotate(0deg) blur(4px);
|
||||
}
|
||||
60% {
|
||||
transform: rotate(-25deg) scale(0.92);
|
||||
filter: invert(70%) hue-rotate(90deg) blur(2px);
|
||||
}
|
||||
70% {
|
||||
transform: rotate(20deg) scale(1.03);
|
||||
filter: invert(50%) hue-rotate(180deg) blur(3px);
|
||||
}
|
||||
80% {
|
||||
transform: rotate(-15deg) scale(1.01);
|
||||
filter: invert(80%) hue-rotate(270deg) blur(1px);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg) scale(1);
|
||||
filter: invert(0%) hue-rotate(360deg) blur(0px);
|
||||
}
|
||||
}
|
||||
.gecko-moria-effect {
|
||||
animation: shadow-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
.moria-screen-chaos {
|
||||
animation: moria-chaos 4s ease-in-out;
|
||||
}
|
||||
</style>
|
||||
</svelte:head>
|
||||
|
||||
<main
|
||||
class="relative min-h-screen overflow-hidden bg-slate-950 text-slate-100 {isGeckoMoriaWin ? 'moria-screen-chaos' : ''}"
|
||||
>
|
||||
<div class="absolute inset-0 bg-gradient-to-br from-slate-950/85 via-slate-900/60 to-slate-950/80"></div>
|
||||
<div class="absolute inset-0 mix-blend-screen opacity-20 bg-[radial-gradient(circle_at_top,rgba(255,215,84,0.35),transparent_55%)]"></div>
|
||||
|
||||
<div class="relative mx-auto flex min-h-screen w-full max-w-6xl flex-col px-6 py-8 sm:py-10">
|
||||
<header class="flex flex-col items-start gap-6 w-full">
|
||||
<div class="flex w-full items-center justify-between gap-4">
|
||||
<h1 class="text-3xl font-black uppercase tracking-[0.25em] text-amber-50 sm:text-5xl">
|
||||
Personnage du jour
|
||||
</h1>
|
||||
{#if hasWon}
|
||||
<button
|
||||
class="rounded-full border border-amber-200/40 bg-transparent px-5 py-3 text-sm font-semibold text-amber-100 transition hover:border-amber-200 hover:text-amber-50"
|
||||
onclick={resetHistory}
|
||||
>
|
||||
Recommencer
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
<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 !
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section class="mt-10 grid gap-6">
|
||||
{#if selectedCharacters.length > 0 && !hasWon}
|
||||
<HintsPanel
|
||||
{dailyCharacter}
|
||||
{selectedCharacters}
|
||||
{showOriginUnlock}
|
||||
{showFruitUnlock}
|
||||
{showAffiliationUnlock}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if hasWon}
|
||||
<WinPanel
|
||||
{dailyCharacter}
|
||||
{selectedCharacters}
|
||||
{isGeckoMoriaWin}
|
||||
/>
|
||||
{:else}
|
||||
<CharacterSearchInput
|
||||
{characters}
|
||||
{selectedCharacters}
|
||||
on:select={handleCharacterSelect}
|
||||
/>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<GuessHistoryTable
|
||||
{selectedCharacters}
|
||||
{dailyCharacter}
|
||||
{columnVisibility}
|
||||
/>
|
||||
|
||||
<YesterdayCharacter {yesterdayCharacter} />
|
||||
</div>
|
||||
</main>
|
||||
@@ -3,6 +3,7 @@ import { db } from '$lib/server/db';
|
||||
import { characterHistory } from '$lib/server/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { getDateKey } from '$lib/server/daily-character';
|
||||
|
||||
export async function POST({ request }) {
|
||||
try {
|
||||
@@ -12,9 +13,7 @@ export async function POST({ request }) {
|
||||
return json({ error: 'Missing characterId' }, { status: 400 });
|
||||
}
|
||||
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const todayDate = today.toISOString().split('T')[0];
|
||||
const todayDate = getDateKey(new Date());
|
||||
|
||||
// Increment the won counter for today's entry
|
||||
await db
|
||||
28
src/routes/(game)/infinite/+page.server.ts
Normal file
28
src/routes/(game)/infinite/+page.server.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { db } from '$lib/server/db';
|
||||
import { config } from '$lib/server/db/schema';
|
||||
import { getAllCharacters } from '$lib/server/daily-character';
|
||||
import { like } from 'drizzle-orm';
|
||||
|
||||
export async function load() {
|
||||
const characters = await getAllCharacters();
|
||||
|
||||
// Load column visibility config
|
||||
const columnConfig = await db
|
||||
.select()
|
||||
.from(config)
|
||||
.where(like(config.key, 'characterHistory.column.%.visible'));
|
||||
|
||||
// Convert to object for easier access
|
||||
const columnVisibility: Record<string, boolean> = {};
|
||||
columnConfig.forEach(row => {
|
||||
const match = row.key.match(/characterHistory\.column\.(.+)\.visible/);
|
||||
if (match) {
|
||||
columnVisibility[match[1]] = row.value === 'true';
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
characters,
|
||||
columnVisibility
|
||||
};
|
||||
}
|
||||
416
src/routes/(game)/infinite/+page.svelte
Normal file
416
src/routes/(game)/infinite/+page.svelte
Normal file
@@ -0,0 +1,416 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { formatBounty } from '$lib';
|
||||
import CharacterSearchInput from '$lib/components/CharacterSearchInput.svelte';
|
||||
import GuessHistoryTable from '$lib/components/GuessHistoryTable.svelte';
|
||||
|
||||
export let data;
|
||||
|
||||
let selectedCharacters: any[] = [];
|
||||
let currentCharacter: any = null;
|
||||
let isLoaded = false;
|
||||
let score = 0;
|
||||
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 wasOriginAvailable = false;
|
||||
let wasFruitAvailable = false;
|
||||
let wasAffiliationAvailable = false;
|
||||
let showOriginUnlock = false;
|
||||
let showFruitUnlock = false;
|
||||
let showAffiliationUnlock = false;
|
||||
|
||||
// Load from localStorage on mount
|
||||
onMount(() => {
|
||||
const storedScore = localStorage.getItem('infiniteScore');
|
||||
if (storedScore) {
|
||||
score = parseInt(storedScore, 10);
|
||||
}
|
||||
|
||||
// Load column visibility from localStorage, fallback to server defaults
|
||||
const storedColumnVisibility = localStorage.getItem('infiniteColumnVisibility');
|
||||
if (storedColumnVisibility) {
|
||||
try {
|
||||
columnVisibility = JSON.parse(storedColumnVisibility);
|
||||
} catch (e) {
|
||||
columnVisibility = data.columnVisibility || {};
|
||||
}
|
||||
} else {
|
||||
columnVisibility = data.columnVisibility || {};
|
||||
}
|
||||
|
||||
// Load current character ID and history IDs from localStorage
|
||||
const storedCharacterId = localStorage.getItem('infiniteCurrentCharacterId');
|
||||
const storedHistoryIds = localStorage.getItem('infiniteSelectedCharacterIds');
|
||||
|
||||
if (storedCharacterId && storedHistoryIds && characters.length > 0) {
|
||||
try {
|
||||
const charId = JSON.parse(storedCharacterId);
|
||||
const historyIds = JSON.parse(storedHistoryIds);
|
||||
|
||||
// Find the character object by ID
|
||||
currentCharacter = characters.find((c: any) => c.id === charId);
|
||||
|
||||
// Find all character objects by their IDs
|
||||
selectedCharacters = historyIds
|
||||
.map((id: string) => characters.find((c: any) => c.id === id))
|
||||
.filter((c: any) => c !== undefined);
|
||||
|
||||
// If character not found, generate a new one
|
||||
if (!currentCharacter) {
|
||||
generateNewCharacter();
|
||||
}
|
||||
} catch (e) {
|
||||
// If parsing fails, generate a new character
|
||||
generateNewCharacter();
|
||||
}
|
||||
} else {
|
||||
generateNewCharacter();
|
||||
}
|
||||
|
||||
isLoaded = true;
|
||||
});
|
||||
|
||||
// Save score to localStorage whenever it changes
|
||||
$: if (isLoaded) {
|
||||
localStorage.setItem('infiniteScore', score.toString());
|
||||
}
|
||||
|
||||
// Save column visibility to localStorage whenever it changes
|
||||
$: if (isLoaded) {
|
||||
localStorage.setItem('infiniteColumnVisibility', JSON.stringify(columnVisibility));
|
||||
}
|
||||
|
||||
// Save current character ID to localStorage whenever it changes
|
||||
$: if (isLoaded && currentCharacter) {
|
||||
localStorage.setItem('infiniteCurrentCharacterId', JSON.stringify(currentCharacter.id));
|
||||
}
|
||||
|
||||
// Save selected character IDs to localStorage whenever it changes
|
||||
$: if (isLoaded) {
|
||||
const selectedIds = selectedCharacters.map((c: any) => c.id);
|
||||
localStorage.setItem('infiniteSelectedCharacterIds', JSON.stringify(selectedIds));
|
||||
}
|
||||
|
||||
$: characters = data.characters || [];
|
||||
$: hasWon = currentCharacter && selectedCharacters.some(char => char.id === currentCharacter.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 generateNewCharacter() {
|
||||
if (characters.length === 0) return;
|
||||
currentCharacter = characters[Math.floor(Math.random() * characters.length)];
|
||||
selectedCharacters = [];
|
||||
}
|
||||
|
||||
function handleCharacterSelect(event: CustomEvent) {
|
||||
const character = event.detail;
|
||||
selectCharacter(character);
|
||||
}
|
||||
|
||||
function selectCharacter(character: any) {
|
||||
selectedCharacters = [character, ...selectedCharacters];
|
||||
|
||||
// Check if player won
|
||||
if (character.id === currentCharacter.id) {
|
||||
// Increment score (saved to localStorage via reactive statement)
|
||||
score++;
|
||||
// Don't auto-generate next character - wait for user to click "Recommencer"
|
||||
}
|
||||
}
|
||||
|
||||
function nextCharacter() {
|
||||
generateNewCharacter();
|
||||
}
|
||||
|
||||
function resetScore() {
|
||||
score = 0;
|
||||
selectedCharacters = [];
|
||||
generateNewCharacter();
|
||||
// Clear localStorage for current character and history
|
||||
localStorage.removeItem('infiniteCurrentCharacterId');
|
||||
localStorage.removeItem('infiniteSelectedCharacterIds');
|
||||
}
|
||||
|
||||
function toggleColumnVisibility(column: string) {
|
||||
columnVisibility[column] = !columnVisibility[column];
|
||||
columnVisibility = columnVisibility; // Trigger reactivity
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>OnePieceDle - Mode Infini</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);
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
text-shadow: 0 0 60px rgba(0, 0, 0, 0.9), 0 0 100px rgba(30, 30, 30, 1),
|
||||
inset 0 0 50px rgba(0, 0, 0, 0.7);
|
||||
opacity: 0.9;
|
||||
}
|
||||
100% {
|
||||
text-shadow: 0 0 20px rgba(0, 0, 0, 0.5), 0 0 40px rgba(50, 50, 50, 0.8);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
.gecko-moria-effect {
|
||||
animation: shadow-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
</style>
|
||||
</svelte:head>
|
||||
|
||||
<main
|
||||
class="relative min-h-screen overflow-hidden 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 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">
|
||||
<header class="flex flex-col items-start gap-6 w-full">
|
||||
<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>
|
||||
<p class="mt-2 text-2xl font-bold text-amber-300">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
|
||||
</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 !
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section class="mt-10 grid gap-6">
|
||||
{#if currentCharacter}
|
||||
{#if hasWon}
|
||||
<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">Bien joué !</h2>
|
||||
<p class="text-sm text-emerald-300">
|
||||
Vous avez trouvé le personnage en {selectedCharacters.length}
|
||||
{selectedCharacters.length > 1 ? 'tentatives' : 'tentative'} !
|
||||
</p>
|
||||
<div class="mt-3">
|
||||
{#if currentCharacter.pictureUrl}
|
||||
<a
|
||||
href={'https://onepiece.fandom.com/fr/wiki/' + currentCharacter.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-block"
|
||||
>
|
||||
<img
|
||||
src={currentCharacter.pictureUrl}
|
||||
alt={currentCharacter.name}
|
||||
class="w-20 h-20 mx-auto rounded-full border-2 border-emerald-400 shadow-lg object-cover hover:border-emerald-300 transition-colors cursor-pointer"
|
||||
/>
|
||||
</a>
|
||||
{/if}
|
||||
<p class="mt-2 text-lg font-bold text-white">{currentCharacter.name}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={nextCharacter}
|
||||
class="mt-4 rounded-full bg-emerald-500 px-6 py-2 text-sm font-semibold text-white transition hover:bg-emerald-600"
|
||||
>
|
||||
Recommencer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="rounded-3xl border border-white/10 bg-white/5 p-6 shadow-[0_24px_60px_rgba(0,0,0,0.45)] backdrop-blur"
|
||||
>
|
||||
<div class="grid gap-3 sm:grid-cols-3">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-2xl border border-white/10 px-3 py-3 flex flex-col items-center justify-center cursor-pointer hover:bg-slate-900/80 transition-colors {isOriginAvailable
|
||||
? 'bg-slate-950/60'
|
||||
: 'bg-slate-950/30 opacity-50 cursor-not-allowed hover:bg-slate-950/30'} {showOriginUnlock
|
||||
? 'hint-unlocking'
|
||||
: ''}"
|
||||
disabled={!isOriginAvailable}
|
||||
onclick={() => (showOriginUnlock = !showOriginUnlock)}
|
||||
>
|
||||
<p class="text-sm font-medium text-amber-100">Origine</p>
|
||||
{#if showOriginUnlock}
|
||||
<p class="mt-2 text-xs text-white font-semibold">
|
||||
{currentCharacter.origin || 'Inconnue'}
|
||||
</p>
|
||||
{:else if Math.max(0, 5 - selectedCharacters.length) > 0}
|
||||
<p class="mt-2 text-xs text-slate-400">
|
||||
{Math.max(0, 5 - selectedCharacters.length)} essais avant déblocage
|
||||
</p>
|
||||
{:else}
|
||||
<p class="mt-2 text-xs text-slate-400">Indice disponible !</p>
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-2xl border border-white/10 px-3 py-3 flex flex-col items-center justify-center cursor-pointer hover:bg-slate-900/80 transition-colors {isFruitAvailable
|
||||
? 'bg-slate-950/60'
|
||||
: 'bg-slate-950/30 opacity-50 cursor-not-allowed hover:bg-slate-950/30'} {showFruitUnlock
|
||||
? 'hint-unlocking'
|
||||
: ''}"
|
||||
disabled={!isFruitAvailable}
|
||||
onclick={() => (showFruitUnlock = !showFruitUnlock)}
|
||||
>
|
||||
<p class="text-sm font-medium text-amber-100">Fruit du démon</p>
|
||||
{#if showFruitUnlock}
|
||||
<p class="mt-2 text-xs text-white font-semibold">
|
||||
{currentCharacter.devilFruitName || 'Aucun'}
|
||||
</p>
|
||||
{:else if Math.max(0, 10 - selectedCharacters.length) > 0}
|
||||
<p class="mt-2 text-xs text-slate-400">
|
||||
{Math.max(0, 10 - selectedCharacters.length)} essais avant déblocage
|
||||
</p>
|
||||
{:else}
|
||||
<p class="mt-2 text-xs text-slate-400">Indice disponible !</p>
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-2xl border border-white/10 px-3 py-3 flex flex-col items-center justify-center cursor-pointer hover:bg-slate-900/80 transition-colors {isAffiliationAvailable
|
||||
? 'bg-slate-950/60'
|
||||
: 'bg-slate-950/30 opacity-50 cursor-not-allowed hover:bg-slate-950/30'} {showAffiliationUnlock
|
||||
? 'hint-unlocking'
|
||||
: ''}"
|
||||
disabled={!isAffiliationAvailable}
|
||||
onclick={() => (showAffiliationUnlock = !showAffiliationUnlock)}
|
||||
>
|
||||
<p class="text-sm font-medium text-amber-100">Affiliation</p>
|
||||
{#if showAffiliationUnlock}
|
||||
{@const affiliations = typeof currentCharacter.affiliations === 'string'
|
||||
? currentCharacter.affiliations.includes('[')
|
||||
? JSON.parse(currentCharacter.affiliations)
|
||||
: currentCharacter.affiliations
|
||||
.split(',')
|
||||
.map((a: string) => a.trim())
|
||||
: currentCharacter.affiliations}
|
||||
<p class="mt-2 text-xs text-white font-semibold">
|
||||
{Array.isArray(affiliations) ? affiliations[0] : affiliations || 'Inconnue'}
|
||||
</p>
|
||||
{:else if Math.max(0, 15 - selectedCharacters.length) > 0}
|
||||
<p class="mt-2 text-xs text-slate-400">
|
||||
{Math.max(0, 15 - selectedCharacters.length)} essais avant déblocage
|
||||
</p>
|
||||
{:else}
|
||||
<p class="mt-2 text-xs text-slate-400">Indice disponible !</p>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<CharacterSearchInput
|
||||
{characters}
|
||||
{selectedCharacters}
|
||||
on:select={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>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
{#if currentCharacter}
|
||||
<GuessHistoryTable
|
||||
{selectedCharacters}
|
||||
dailyCharacter={currentCharacter}
|
||||
{columnVisibility}
|
||||
/>
|
||||
|
||||
<!-- 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="mb-3 flex items-center justify-between gap-3">
|
||||
<h3 class="text-xs font-semibold uppercase tracking-[0.2em] text-amber-200">Colonnes</h3>
|
||||
<p class="text-xs text-slate-400">
|
||||
{Object.values(columnVisibility).filter(Boolean).length}/{Object.keys(columnVisibility).length}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each Object.entries(columnVisibility) as [column, isVisible] (column)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => toggleColumnVisibility(column)}
|
||||
class="rounded-full border px-2.5 py-1 text-xs font-medium transition-colors {isVisible
|
||||
? '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'}"
|
||||
>
|
||||
{columnDisplayNames[column] || column.replace(/([A-Z])/g, ' $1').trim()}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<style>
|
||||
@keyframes hint-unlock {
|
||||
0% {
|
||||
box-shadow: 0 0 0 rgba(251, 146, 60, 0);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 20px rgba(251, 146, 60, 0.8);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 rgba(251, 146, 60, 0);
|
||||
}
|
||||
}
|
||||
:global(.hint-unlocking) {
|
||||
animation: hint-unlock 0.6s ease-out;
|
||||
}
|
||||
</style>
|
||||
68
src/routes/(game)/login/+page.server.ts
Normal file
68
src/routes/(game)/login/+page.server.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
import type { Actions } from './$types';
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { auth } from '$lib/server/auth';
|
||||
import { APIError } from 'better-auth/api';
|
||||
|
||||
export const load: PageServerLoad = async (event) => {
|
||||
if (event.locals.user) {
|
||||
return redirect(302, '/');
|
||||
}
|
||||
return {};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
signInEmail: async (event) => {
|
||||
const formData = await event.request.formData();
|
||||
const email = formData.get('email')?.toString() ?? '';
|
||||
const password = formData.get('password')?.toString() ?? '';
|
||||
|
||||
try {
|
||||
await auth.api.signInEmail({
|
||||
body: {
|
||||
email,
|
||||
password,
|
||||
callbackURL: '/auth/verification-success'
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof APIError) {
|
||||
return fail(400, { message: error.message || 'Signin failed' });
|
||||
}
|
||||
return fail(500, { message: 'Unexpected error' });
|
||||
}
|
||||
|
||||
return redirect(302, '/');
|
||||
},
|
||||
signUpEmail: async (event) => {
|
||||
const formData = await event.request.formData();
|
||||
const email = formData.get('email')?.toString() ?? '';
|
||||
const password = formData.get('password')?.toString() ?? '';
|
||||
const name = formData.get('name')?.toString() ?? '';
|
||||
|
||||
try {
|
||||
await auth.api.signUpEmail({
|
||||
body: {
|
||||
email,
|
||||
password,
|
||||
name,
|
||||
callbackURL: '/auth/verification-success'
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof APIError) {
|
||||
return fail(400, { message: error.message || 'Registration failed' });
|
||||
}
|
||||
return fail(500, { message: 'Unexpected error' });
|
||||
}
|
||||
|
||||
return redirect(302, '/');
|
||||
},
|
||||
logout: async (event) => {
|
||||
await auth.api.signOut({
|
||||
headers: event.request.headers
|
||||
});
|
||||
|
||||
return redirect(302, '/');
|
||||
}
|
||||
};
|
||||
171
src/routes/(game)/login/+page.svelte
Normal file
171
src/routes/(game)/login/+page.svelte
Normal file
@@ -0,0 +1,171 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import type { ActionData } from './$types';
|
||||
|
||||
export let form: ActionData;
|
||||
|
||||
let isSignUp = false;
|
||||
let name = '';
|
||||
let email = '';
|
||||
let password = '';
|
||||
let confirmPassword = '';
|
||||
let isLoading = false;
|
||||
|
||||
const handleToggle = () => {
|
||||
isSignUp = !isSignUp;
|
||||
name = '';
|
||||
email = '';
|
||||
password = '';
|
||||
confirmPassword = '';
|
||||
form = null;
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>OnePieceDle - {isSignUp ? 'Inscription' : 'Connexion'}</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 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 justify-center px-6 py-10">
|
||||
<div class="w-full space-y-8">
|
||||
<!-- Header -->
|
||||
<div class="text-center">
|
||||
<h1 class="text-4xl font-black uppercase tracking-[0.3em] text-amber-50 sm:text-5xl">
|
||||
OnePieceDle
|
||||
</h1>
|
||||
<p class="mt-4 text-slate-300">
|
||||
{isSignUp ? 'Créer votre compte' : 'Bienvenue, pirate'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Form Card -->
|
||||
<div class="rounded-3xl border border-white/10 bg-white/5 p-8 shadow-[0_24px_60px_rgba(0,0,0,0.45)] backdrop-blur">
|
||||
<form
|
||||
method="POST"
|
||||
action={isSignUp ? '?/signUpEmail' : '?/signInEmail'}
|
||||
use:enhance={() => {
|
||||
isLoading = true;
|
||||
return async ({ update }) => {
|
||||
isLoading = false;
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
class="space-y-6"
|
||||
>
|
||||
<!-- Name Field (Sign Up Only) -->
|
||||
{#if isSignUp}
|
||||
<div>
|
||||
<label for="name" class="block text-sm font-semibold uppercase tracking-[0.2em] text-amber-100">
|
||||
Nom
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
type="text"
|
||||
name="name"
|
||||
bind:value={name}
|
||||
required
|
||||
placeholder="Votre nom"
|
||||
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>
|
||||
{/if}
|
||||
|
||||
<!-- Email Field -->
|
||||
<div>
|
||||
<label for="email" class="block text-sm font-semibold uppercase tracking-[0.2em] text-amber-100">
|
||||
E-mail
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
name="email"
|
||||
bind:value={email}
|
||||
required
|
||||
placeholder="votremail@email.com"
|
||||
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>
|
||||
|
||||
<!-- Password Field -->
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-semibold uppercase tracking-[0.2em] text-amber-100">
|
||||
Mot de passe
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
name="password"
|
||||
bind:value={password}
|
||||
required
|
||||
placeholder="••••••••"
|
||||
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>
|
||||
|
||||
<!-- Confirm Password Field (Sign Up Only) -->
|
||||
{#if isSignUp}
|
||||
<div>
|
||||
<label
|
||||
for="confirmPassword"
|
||||
class="block text-sm font-semibold uppercase tracking-[0.2em] text-amber-100"
|
||||
>
|
||||
Confirmer le mot de passe
|
||||
</label>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
name="confirmPassword"
|
||||
bind:value={confirmPassword}
|
||||
required
|
||||
placeholder="••••••••"
|
||||
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>
|
||||
{/if}
|
||||
|
||||
<!-- Error Message -->
|
||||
{#if form?.message}
|
||||
<div class="rounded-lg border border-red-500/30 bg-red-900/20 px-4 py-3 text-sm text-red-200">
|
||||
{form.message}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Submit Button -->
|
||||
<button
|
||||
type="submit"
|
||||
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'}
|
||||
</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 ?"}
|
||||
<button
|
||||
type="button"
|
||||
on:click={handleToggle}
|
||||
class="text-amber-300 transition hover:text-amber-200"
|
||||
>
|
||||
{isSignUp ? 'Se connecter' : "S'inscrire"}
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Back to Home -->
|
||||
<div class="text-center">
|
||||
<a href="/" class="text-sm text-slate-400 transition hover:text-slate-300">
|
||||
← Retour à l'accueil
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
119
src/routes/(game)/profile/+page.server.ts
Normal file
119
src/routes/(game)/profile/+page.server.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { auth } from '$lib/server/auth';
|
||||
import { db } from '$lib/server/db';
|
||||
import { session } from '$lib/server/db/auth.schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { APIError } from 'better-auth/api';
|
||||
|
||||
export const load: PageServerLoad = async (event) => {
|
||||
if (!event.locals.user) {
|
||||
return redirect(302, '/login');
|
||||
}
|
||||
|
||||
// Fetch all sessions for this user
|
||||
const userSessions = await db
|
||||
.select()
|
||||
.from(session)
|
||||
.where(eq(session.userId, event.locals.user.id));
|
||||
|
||||
return {
|
||||
user: event.locals.user,
|
||||
sessions: userSessions
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
updateProfile: async (event) => {
|
||||
if (!event.locals.user) {
|
||||
return redirect(302, '/login');
|
||||
}
|
||||
|
||||
const formData = await event.request.formData();
|
||||
const name = formData.get('name')?.toString() ?? '';
|
||||
|
||||
if (!name.trim()) {
|
||||
return fail(400, { message: 'Le nom ne peut pas être vide' });
|
||||
}
|
||||
|
||||
try {
|
||||
await auth.api.updateUser({
|
||||
body: {
|
||||
name: name.trim()
|
||||
},
|
||||
headers: event.request.headers
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof APIError) {
|
||||
return fail(400, { message: error.message || 'Erreur lors de la mise à jour' });
|
||||
}
|
||||
return fail(500, { message: 'Erreur inattendue' });
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
},
|
||||
changePassword: async (event) => {
|
||||
if (!event.locals.user) {
|
||||
return redirect(302, '/login');
|
||||
}
|
||||
|
||||
const formData = await event.request.formData();
|
||||
const oldPassword = formData.get('oldPassword')?.toString() ?? '';
|
||||
const newPassword = formData.get('newPassword')?.toString() ?? '';
|
||||
const confirmPassword = formData.get('confirmPassword')?.toString() ?? '';
|
||||
|
||||
if (!oldPassword.trim()) {
|
||||
return fail(400, { message: 'Le mot de passe actuel est requis' });
|
||||
}
|
||||
|
||||
if (!newPassword.trim()) {
|
||||
return fail(400, { message: 'Le nouveau mot de passe est requis' });
|
||||
}
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
return fail(400, { message: 'Les mots de passe ne correspondent pas' });
|
||||
}
|
||||
|
||||
if (newPassword.length < 8) {
|
||||
return fail(400, { message: 'Le mot de passe doit contenir au moins 8 caractères' });
|
||||
}
|
||||
|
||||
try {
|
||||
await auth.api.changePassword({
|
||||
body: {
|
||||
currentPassword: oldPassword,
|
||||
newPassword
|
||||
},
|
||||
headers: event.request.headers
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof APIError) {
|
||||
return fail(400, { message: error.message || 'Erreur lors du changement de mot de passe' });
|
||||
}
|
||||
return fail(500, { message: 'Erreur inattendue' });
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
},
|
||||
revokeSession: async (event) => {
|
||||
if (!event.locals.user) {
|
||||
return redirect(302, '/login');
|
||||
}
|
||||
|
||||
const formData = await event.request.formData();
|
||||
const sessionId = formData.get('sessionId')?.toString() ?? '';
|
||||
|
||||
if (!sessionId) {
|
||||
return fail(400, { message: 'ID de session manquant' });
|
||||
}
|
||||
|
||||
try {
|
||||
// Delete the session from database
|
||||
await db.delete(session).where(eq(session.id, sessionId));
|
||||
} catch (error) {
|
||||
return fail(500, { message: 'Erreur lors de la révocation de la session' });
|
||||
}
|
||||
|
||||
return { success: true, message: 'Session révoquée avec succès' };
|
||||
}
|
||||
};
|
||||
334
src/routes/(game)/profile/+page.svelte
Normal file
334
src/routes/(game)/profile/+page.svelte
Normal file
@@ -0,0 +1,334 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
interface Props {
|
||||
data: PageData;
|
||||
form?: { success?: boolean; message?: string } | null;
|
||||
}
|
||||
|
||||
let { data, form }: Props = $props();
|
||||
|
||||
let isLoading = $state(false);
|
||||
let activeTab = $state<'profile' | 'password' | 'sessions'>('profile');
|
||||
let name = $state('');
|
||||
let showSuccess = $state(false);
|
||||
let oldPassword = $state('');
|
||||
let newPassword = $state('');
|
||||
let confirmPassword = $state('');
|
||||
let sessions = $state<any[]>([]);
|
||||
let tabsElement: HTMLDivElement | undefined;
|
||||
|
||||
$effect(() => {
|
||||
name = data.user?.name || '';
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
sessions = (data as any).sessions || [];
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (form && form.success === true) {
|
||||
showSuccess = true;
|
||||
setTimeout(() => {
|
||||
showSuccess = false;
|
||||
}, 3000);
|
||||
}
|
||||
});
|
||||
|
||||
const handleTabChange = (tab: 'profile' | 'password' | 'sessions') => {
|
||||
activeTab = tab;
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
// Just for type purposes
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Mon Profil - 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 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">
|
||||
<div class="w-full space-y-4">
|
||||
<!-- Header -->
|
||||
<div class="text-center">
|
||||
<h1 class="text-3xl font-black uppercase tracking-[0.3em] text-amber-50 sm:text-4xl">
|
||||
Mon Profil
|
||||
</h1>
|
||||
<p class="mt-2 text-sm text-slate-300">
|
||||
Modifie les informations de ton profil
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Tabs Navigation -->
|
||||
<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'
|
||||
? 'border-b-2 border-amber-300 text-amber-100'
|
||||
: 'text-slate-400 hover:text-slate-100'}"
|
||||
>
|
||||
Profil
|
||||
</button>
|
||||
<button
|
||||
onclick={() => handleTabChange('password')}
|
||||
class="px-4 py-3 font-semibold uppercase tracking-[0.1em] transition {activeTab === 'password'
|
||||
? 'border-b-2 border-amber-300 text-amber-100'
|
||||
: 'text-slate-400 hover:text-slate-100'}"
|
||||
>
|
||||
Mot de passe
|
||||
</button>
|
||||
<button
|
||||
onclick={() => handleTabChange('sessions')}
|
||||
class="px-4 py-3 font-semibold uppercase tracking-[0.1em] transition {activeTab === 'sessions'
|
||||
? 'border-b-2 border-amber-300 text-amber-100'
|
||||
: 'text-slate-400 hover:text-slate-100'}"
|
||||
>
|
||||
Sessions
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab Content -->
|
||||
{#if activeTab === 'profile'}
|
||||
<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">
|
||||
<!-- Avatar -->
|
||||
<div class="mb-6 flex flex-col items-center gap-4">
|
||||
{#if data.user.image}
|
||||
<img
|
||||
src={data.user.image}
|
||||
alt={data.user.name || 'Profil'}
|
||||
class="h-24 w-24 rounded-full border-2 border-amber-300 object-cover"
|
||||
/>
|
||||
{:else}
|
||||
<div class="flex h-24 w-24 items-center justify-center rounded-full border-2 border-amber-300 bg-amber-300/20 text-2xl font-semibold text-amber-100">
|
||||
{data.user.name?.charAt(0).toUpperCase() || 'U'}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="text-center">
|
||||
<p class="text-sm text-slate-400">Email</p>
|
||||
<p class="font-semibold text-white">{data.user.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Form -->
|
||||
<form
|
||||
method="POST"
|
||||
action="?/updateProfile"
|
||||
use:enhance={() => {
|
||||
isLoading = true;
|
||||
return async ({ update }) => {
|
||||
isLoading = false;
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
onsubmit={handleSubmit}
|
||||
class="space-y-6"
|
||||
>
|
||||
<!-- Name Field -->
|
||||
<div>
|
||||
<label for="name" class="block text-sm font-semibold uppercase tracking-[0.2em] text-amber-100">
|
||||
Nom d'affichage
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
type="text"
|
||||
name="name"
|
||||
bind:value={name}
|
||||
required
|
||||
placeholder="Ton nom"
|
||||
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>
|
||||
|
||||
<!-- Error Message -->
|
||||
{#if form && form.message && form.success !== true}
|
||||
<div class="rounded-lg border border-red-500/30 bg-red-900/20 px-4 py-3 text-sm text-red-200">
|
||||
{form.message}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- 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 !
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Submit Button -->
|
||||
<button
|
||||
type="submit"
|
||||
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'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Password Tab -->
|
||||
{#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
|
||||
</h2>
|
||||
|
||||
<!-- Form -->
|
||||
<form
|
||||
method="POST"
|
||||
action="?/changePassword"
|
||||
use:enhance={() => {
|
||||
isLoading = true;
|
||||
return async ({ update }) => {
|
||||
isLoading = false;
|
||||
oldPassword = '';
|
||||
newPassword = '';
|
||||
confirmPassword = '';
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
class="space-y-6"
|
||||
>
|
||||
<!-- Old Password Field -->
|
||||
<div>
|
||||
<label for="oldPassword" class="block text-sm font-semibold uppercase tracking-[0.2em] text-amber-100">
|
||||
Mot de passe actuel
|
||||
</label>
|
||||
<input
|
||||
id="oldPassword"
|
||||
type="password"
|
||||
name="oldPassword"
|
||||
bind:value={oldPassword}
|
||||
required
|
||||
placeholder="••••••••"
|
||||
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>
|
||||
|
||||
<!-- New Password Field -->
|
||||
<div>
|
||||
<label for="newPassword" class="block text-sm font-semibold uppercase tracking-[0.2em] text-amber-100">
|
||||
Nouveau mot de passe
|
||||
</label>
|
||||
<input
|
||||
id="newPassword"
|
||||
type="password"
|
||||
name="newPassword"
|
||||
bind:value={newPassword}
|
||||
required
|
||||
placeholder="••••••••"
|
||||
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>
|
||||
|
||||
<!-- 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
|
||||
</label>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
name="confirmPassword"
|
||||
bind:value={confirmPassword}
|
||||
required
|
||||
placeholder="••••••••"
|
||||
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>
|
||||
|
||||
<!-- Error Message -->
|
||||
{#if form && form.message && form.success !== true}
|
||||
<div class="rounded-lg border border-red-500/30 bg-red-900/20 px-4 py-3 text-sm text-red-200">
|
||||
{form.message}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- 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 !
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Submit Button -->
|
||||
<button
|
||||
type="submit"
|
||||
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'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Sessions Tab -->
|
||||
{#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
|
||||
</h2>
|
||||
|
||||
{#if sessions.length === 0}
|
||||
<p class="text-center text-slate-400">Aucune session active</p>
|
||||
{:else}
|
||||
<div class="space-y-4">
|
||||
{#each sessions as sess}
|
||||
<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'}
|
||||
</p>
|
||||
<p class="text-xs text-slate-400">
|
||||
IP: {sess.ipAddress || 'Inconnue'}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-slate-500">
|
||||
Créée: {new Date(sess.createdAt).toLocaleDateString('fr-FR', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/revokeSession"
|
||||
style="display: inline;"
|
||||
use:enhance={() => {
|
||||
return async ({ update }) => {
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="sessionId" value={sess.id} />
|
||||
<button
|
||||
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
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Back to Home -->
|
||||
<div class="text-center">
|
||||
<a href="/" class="text-sm text-slate-400 transition hover:text-slate-300">
|
||||
← Retour à l'accueil
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
8
src/routes/+layout.server.ts
Normal file
8
src/routes/+layout.server.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { LayoutServerLoad } from './$types';
|
||||
|
||||
export const load: LayoutServerLoad = ({ locals }) => {
|
||||
return {
|
||||
user: locals.user || null,
|
||||
session: locals.session || null
|
||||
};
|
||||
};
|
||||
@@ -6,4 +6,6 @@
|
||||
</script>
|
||||
|
||||
<svelte:head><link rel="icon" href={favicon} /></svelte:head>
|
||||
|
||||
|
||||
{@render children()}
|
||||
@@ -1,788 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
export let data;
|
||||
|
||||
let searchInput = '';
|
||||
let selectedCharacters: any[] = [];
|
||||
let highlightedIndex = 0;
|
||||
let isLoaded = false;
|
||||
let isGeckoMoriaWin = false;
|
||||
let dropdownContainer: HTMLDivElement;
|
||||
let showHintOrigin = false;
|
||||
let showHintFruit = false;
|
||||
let showHintAffiliation = false;
|
||||
|
||||
let wasOriginAvailable = false;
|
||||
let wasFruitAvailable = false;
|
||||
let wasAffiliationAvailable = false;
|
||||
let showOriginUnlock = false;
|
||||
let showFruitUnlock = false;
|
||||
let showAffiliationUnlock = false;
|
||||
|
||||
// Load from localStorage on mount
|
||||
onMount(() => {
|
||||
const storedDailyCharacterId = localStorage.getItem('currentDailyCharacterId');
|
||||
const currentDailyCharacterId = dailyCharacter?.id;
|
||||
|
||||
// If the daily character has changed, clear the history
|
||||
if (storedDailyCharacterId && storedDailyCharacterId !== currentDailyCharacterId) {
|
||||
localStorage.removeItem('dailyCharacterHistory');
|
||||
selectedCharacters = [];
|
||||
} else {
|
||||
// Load existing history if the character hasn't changed
|
||||
const stored = localStorage.getItem('dailyCharacterHistory');
|
||||
if (stored) {
|
||||
try {
|
||||
const storedIds = JSON.parse(stored);
|
||||
// Reconstruct character objects from IDs
|
||||
if (Array.isArray(storedIds)) {
|
||||
selectedCharacters = storedIds
|
||||
.map((id: string) => data.characters.find((c: any) => c.id === id))
|
||||
.filter((c: any) => c !== undefined);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to parse stored history', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Store the current daily character ID
|
||||
if (currentDailyCharacterId) {
|
||||
localStorage.setItem('currentDailyCharacterId', currentDailyCharacterId);
|
||||
}
|
||||
|
||||
isLoaded = true;
|
||||
});
|
||||
|
||||
// Save to localStorage whenever selectedCharacters changes (only store IDs)
|
||||
$: if (isLoaded && selectedCharacters) {
|
||||
const ids = selectedCharacters.map(char => char.id);
|
||||
localStorage.setItem('dailyCharacterHistory', JSON.stringify(ids));
|
||||
}
|
||||
|
||||
$: characters = data.characters || [];
|
||||
$: dailyCharacter = data.dailyCharacter;
|
||||
$: yesterdayCharacter = data.yesterdayCharacter;
|
||||
$: columnVisibility = data.columnVisibility || {};
|
||||
$: hasWon = selectedCharacters.some(char => char.id === dailyCharacter.id);
|
||||
|
||||
// Hint availability - indices are available after a certain number of guesses
|
||||
$: isOriginAvailable = selectedCharacters.length >= 5; // Always available
|
||||
$: isFruitAvailable = selectedCharacters.length >= 10; // Available after 5 guesses
|
||||
$: isAffiliationAvailable = selectedCharacters.length >= 15; // Available after 10 guesses
|
||||
|
||||
// Track hint unlocks
|
||||
$: if (isLoaded) {
|
||||
if (isOriginAvailable && !wasOriginAvailable) {
|
||||
showOriginUnlock = true;
|
||||
setTimeout(() => showOriginUnlock = false, 600);
|
||||
}
|
||||
wasOriginAvailable = isOriginAvailable;
|
||||
|
||||
if (isFruitAvailable && !wasFruitAvailable) {
|
||||
showFruitUnlock = true;
|
||||
setTimeout(() => showFruitUnlock = false, 600);
|
||||
}
|
||||
wasFruitAvailable = isFruitAvailable;
|
||||
|
||||
if (isAffiliationAvailable && !wasAffiliationAvailable) {
|
||||
showAffiliationUnlock = true;
|
||||
setTimeout(() => showAffiliationUnlock = false, 600);
|
||||
}
|
||||
wasAffiliationAvailable = isAffiliationAvailable;
|
||||
}
|
||||
|
||||
$: filteredCharacters = characters.filter(char => {
|
||||
const searchTerm = searchInput.toLowerCase();
|
||||
const nameMatches = char.name.toLowerCase().includes(searchTerm);
|
||||
|
||||
let epithetsMatches = false;
|
||||
if (char.epithets) {
|
||||
try {
|
||||
const parsedEpithets = typeof char.epithets === 'string'
|
||||
? JSON.parse(char.epithets)
|
||||
: char.epithets;
|
||||
|
||||
if (Array.isArray(parsedEpithets)) {
|
||||
epithetsMatches = parsedEpithets.some((epithet: string) =>
|
||||
epithet.toLowerCase().includes(searchTerm)
|
||||
);
|
||||
} else if (typeof parsedEpithets === 'string') {
|
||||
epithetsMatches = parsedEpithets.toLowerCase().includes(searchTerm);
|
||||
}
|
||||
} catch {
|
||||
epithetsMatches = String(char.epithets).toLowerCase().includes(searchTerm);
|
||||
}
|
||||
}
|
||||
|
||||
return (nameMatches || epithetsMatches) &&
|
||||
!selectedCharacters.some(selected => selected.id === char.id);
|
||||
});
|
||||
|
||||
// Reset highlighted index when filtered list changes
|
||||
$: if (filteredCharacters) {
|
||||
highlightedIndex = 0;
|
||||
}
|
||||
|
||||
// Scroll highlighted item into view
|
||||
$: if (dropdownContainer && highlightedIndex >= 0) {
|
||||
const highlightedButton = dropdownContainer.querySelector(
|
||||
`button:nth-child(${highlightedIndex + 1})`
|
||||
) as HTMLElement;
|
||||
if (highlightedButton) {
|
||||
highlightedButton.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
||||
}
|
||||
}
|
||||
|
||||
function selectCharacter(character: any) {
|
||||
selectedCharacters = [character, ...selectedCharacters];
|
||||
searchInput = '';
|
||||
highlightedIndex = 0;
|
||||
|
||||
// Check if player won
|
||||
if (character.id === dailyCharacter.id) {
|
||||
// Send request to record win in database
|
||||
fetch('/daily', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
characterId: dailyCharacter.id
|
||||
})
|
||||
}).catch(err => console.error('Failed to record win:', err));
|
||||
|
||||
// Check if it's gecko_moria for special animation
|
||||
if (dailyCharacter.id === 'gecko_moria') {
|
||||
isGeckoMoriaWin = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resetHistory() {
|
||||
selectedCharacters = [];
|
||||
localStorage.removeItem('dailyCharacterHistory');
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (filteredCharacters.length === 0) return;
|
||||
|
||||
switch (event.key) {
|
||||
case 'ArrowDown':
|
||||
event.preventDefault();
|
||||
highlightedIndex = Math.min(highlightedIndex + 1, filteredCharacters.length - 1);
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
event.preventDefault();
|
||||
highlightedIndex = Math.max(highlightedIndex - 1, 0);
|
||||
break;
|
||||
case 'Enter':
|
||||
event.preventDefault();
|
||||
if (filteredCharacters[highlightedIndex]) {
|
||||
selectCharacter(filteredCharacters[highlightedIndex]);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function submitGuess() {
|
||||
if (filteredCharacters.length === 0) return;
|
||||
const characterToSelect =
|
||||
filteredCharacters[highlightedIndex] ?? filteredCharacters[0];
|
||||
if (characterToSelect) {
|
||||
selectCharacter(characterToSelect);
|
||||
}
|
||||
}
|
||||
|
||||
function formatBounty(bounty: number): string {
|
||||
if (bounty >= 1_000_000_000) {
|
||||
const billions = bounty / 1_000_000_000;
|
||||
return `${billions}B`;
|
||||
} else if (bounty >= 1_000_000) {
|
||||
const millions = bounty / 1_000_000;
|
||||
return `${millions}M`;
|
||||
} else if (bounty >= 1_000) {
|
||||
const thousands = bounty / 1_000;
|
||||
return `${thousands}K`;
|
||||
}
|
||||
return bounty.toString();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>OnePieceDle - Mode du jour</title>
|
||||
<style>
|
||||
@keyframes hint-unlock {
|
||||
0% {
|
||||
box-shadow: 0 0 0 rgba(251, 146, 60, 0);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 20px rgba(251, 146, 60, 0.8);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 rgba(251, 146, 60, 0);
|
||||
}
|
||||
}
|
||||
.hint-unlocking {
|
||||
animation: hint-unlock 0.6s ease-out;
|
||||
}
|
||||
@keyframes shadow-pulse {
|
||||
0% {
|
||||
text-shadow: 0 0 20px rgba(0, 0, 0, 0.5), 0 0 40px rgba(50, 50, 50, 0.8);
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
text-shadow: 0 0 60px rgba(0, 0, 0, 0.9), 0 0 100px rgba(30, 30, 30, 1), inset 0 0 50px rgba(0, 0, 0, 0.7);
|
||||
opacity: 0.9;
|
||||
}
|
||||
100% {
|
||||
text-shadow: 0 0 20px rgba(0, 0, 0, 0.5), 0 0 40px rgba(50, 50, 50, 0.8);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@keyframes moria-chaos {
|
||||
0% {
|
||||
transform: rotate(0deg) scale(1);
|
||||
filter: invert(0%) hue-rotate(0deg) blur(0px);
|
||||
}
|
||||
10% {
|
||||
transform: rotate(15deg) scale(1.02);
|
||||
filter: invert(30%) hue-rotate(45deg) blur(2px);
|
||||
}
|
||||
20% {
|
||||
transform: rotate(-10deg) scale(0.98);
|
||||
filter: invert(60%) hue-rotate(90deg) blur(1px);
|
||||
}
|
||||
30% {
|
||||
transform: rotate(25deg) scale(1.05);
|
||||
filter: invert(100%) hue-rotate(180deg) blur(3px);
|
||||
}
|
||||
40% {
|
||||
transform: rotate(-20deg) scale(0.95);
|
||||
filter: invert(80%) hue-rotate(270deg) blur(2px);
|
||||
}
|
||||
50% {
|
||||
transform: rotate(30deg) scale(1.08);
|
||||
filter: invert(100%) hue-rotate(0deg) blur(4px);
|
||||
}
|
||||
60% {
|
||||
transform: rotate(-25deg) scale(0.92);
|
||||
filter: invert(70%) hue-rotate(90deg) blur(2px);
|
||||
}
|
||||
70% {
|
||||
transform: rotate(20deg) scale(1.03);
|
||||
filter: invert(50%) hue-rotate(180deg) blur(3px);
|
||||
}
|
||||
80% {
|
||||
transform: rotate(-15deg) scale(1.01);
|
||||
filter: invert(80%) hue-rotate(270deg) blur(1px);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg) scale(1);
|
||||
filter: invert(0%) hue-rotate(360deg) blur(0px);
|
||||
}
|
||||
}
|
||||
.gecko-moria-effect {
|
||||
animation: shadow-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
.moria-screen-chaos {
|
||||
animation: moria-chaos 4s ease-in-out;
|
||||
}
|
||||
</style>
|
||||
</svelte:head>
|
||||
|
||||
<main
|
||||
class="relative min-h-screen overflow-hidden bg-slate-950 text-slate-100 {isGeckoMoriaWin ? 'moria-screen-chaos' : ''}"
|
||||
>
|
||||
<div class="absolute inset-0 bg-gradient-to-br from-slate-950/85 via-slate-900/60 to-slate-950/80"></div>
|
||||
<div class="absolute inset-0 mix-blend-screen opacity-20 bg-[radial-gradient(circle_at_top,rgba(255,215,84,0.35),transparent_55%)]"></div>
|
||||
|
||||
<div class="relative mx-auto flex min-h-screen w-full max-w-6xl flex-col px-6 py-16 sm:py-20">
|
||||
<nav class="absolute left-6 top-6 sm:left-8 sm:top-8">
|
||||
<a
|
||||
href="/"
|
||||
class="text-xl font-black uppercase tracking-[0.25em] text-amber-50 transition hover:text-amber-100"
|
||||
>
|
||||
OnePieceDle
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<header class="flex flex-col items-start gap-6 w-full">
|
||||
<div class="flex w-full items-center justify-between gap-4">
|
||||
<h1 class="text-3xl font-black uppercase tracking-[0.25em] text-amber-50 sm:text-5xl">
|
||||
Personnage du jour
|
||||
</h1>
|
||||
{#if hasWon}
|
||||
<button
|
||||
class="rounded-full border border-amber-200/40 bg-transparent px-5 py-3 text-sm font-semibold text-amber-100 transition hover:border-amber-200 hover:text-amber-50"
|
||||
onclick={resetHistory}
|
||||
>
|
||||
Recommencer
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
<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 !
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section class="mt-10 grid gap-6">
|
||||
{#if selectedCharacters.length > 0 && !hasWon}
|
||||
<div class="rounded-3xl border border-white/10 bg-white/5 p-6 shadow-[0_24px_60px_rgba(0,0,0,0.45)] backdrop-blur">
|
||||
<div class="grid gap-3 sm:grid-cols-3">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-2xl border border-white/10 px-3 py-3 flex flex-col items-center justify-center cursor-pointer hover:bg-slate-900/80 transition-colors {isOriginAvailable ? 'bg-slate-950/60' : 'bg-slate-950/30 opacity-50 cursor-not-allowed hover:bg-slate-950/30'} {showOriginUnlock ? 'hint-unlocking' : ''}"
|
||||
disabled={!isOriginAvailable}
|
||||
onclick={() => showHintOrigin = !showHintOrigin}
|
||||
>
|
||||
<p class="text-sm font-medium text-amber-100">Origine</p>
|
||||
{#if showHintOrigin}
|
||||
<p class="mt-2 text-xs text-white font-semibold">{dailyCharacter.origin || 'Inconnue'}</p>
|
||||
{:else if Math.max(0, 5 - selectedCharacters.length) > 0}
|
||||
<p class="mt-2 text-xs text-slate-400">{Math.max(0, 5 - selectedCharacters.length)} essais avant déblocage</p>
|
||||
{:else}
|
||||
<p class="mt-2 text-xs text-slate-400">Indice disponible !</p>
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-2xl border border-white/10 px-3 py-3 flex flex-col items-center justify-center cursor-pointer hover:bg-slate-900/80 transition-colors {isFruitAvailable ? 'bg-slate-950/60' : 'bg-slate-950/30 opacity-50 cursor-not-allowed hover:bg-slate-950/30'} {showFruitUnlock ? 'hint-unlocking' : ''}"
|
||||
disabled={!isFruitAvailable}
|
||||
onclick={() => showHintFruit = !showHintFruit}
|
||||
>
|
||||
<p class="text-sm font-medium text-amber-100">Fruit du démon</p>
|
||||
{#if showHintFruit}
|
||||
<p class="mt-2 text-xs text-white font-semibold">{dailyCharacter.devilFruitName || 'Aucun'}</p>
|
||||
{:else if Math.max(0, 10 - selectedCharacters.length) > 0}
|
||||
<p class="mt-2 text-xs text-slate-400">{Math.max(0, 10 - selectedCharacters.length)} essais avant déblocage</p>
|
||||
{:else}
|
||||
<p class="mt-2 text-xs text-slate-400">Indice disponible !</p>
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-2xl border border-white/10 px-3 py-3 flex flex-col items-center justify-center cursor-pointer hover:bg-slate-900/80 transition-colors {isAffiliationAvailable ? 'bg-slate-950/60' : 'bg-slate-950/30 opacity-50 cursor-not-allowed hover:bg-slate-950/30'} {showAffiliationUnlock ? 'hint-unlocking' : ''}"
|
||||
disabled={!isAffiliationAvailable}
|
||||
onclick={() => showHintAffiliation = !showHintAffiliation}
|
||||
>
|
||||
<p class="text-sm font-medium text-amber-100">Affiliation</p>
|
||||
{#if showHintAffiliation}
|
||||
{@const affiliations = typeof dailyCharacter.affiliations === 'string'
|
||||
? (dailyCharacter.affiliations.includes('[') ? JSON.parse(dailyCharacter.affiliations) : dailyCharacter.affiliations.split(',').map((a: string) => a.trim()))
|
||||
: dailyCharacter.affiliations}
|
||||
<p class="mt-2 text-xs text-white font-semibold">{Array.isArray(affiliations) ? affiliations[0] : affiliations || 'Inconnue'}</p>
|
||||
{:else if Math.max(0, 15 - selectedCharacters.length) > 0}
|
||||
<p class="mt-2 text-xs text-slate-400">{Math.max(0, 15 - selectedCharacters.length)} essais avant déblocage</p>
|
||||
{:else}
|
||||
<p class="mt-2 text-xs text-slate-400">Indice disponible !</p>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if hasWon}
|
||||
{#if isGeckoMoriaWin}
|
||||
<div class="rounded-3xl border border-slate-700/80 bg-slate-950/80 p-4 shadow-[0_24px_60px_rgba(0,0,0,0.8)] backdrop-blur gecko-moria-effect">
|
||||
<div class="text-center">
|
||||
<div class="text-3xl mb-2">🌑</div>
|
||||
<h2 class="text-xl font-bold text-slate-300 mb-1">Moria vous contrôle...</h2>
|
||||
<p class="text-sm text-slate-400">Vous avez succombé à l'ombre en {selectedCharacters.length} {selectedCharacters.length > 1 ? 'tentatives' : 'tentative'} !</p>
|
||||
<div class="mt-3">
|
||||
{#if dailyCharacter.pictureUrl}
|
||||
<a
|
||||
href={"https://onepiece.fandom.com/fr/wiki/" + dailyCharacter.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-block"
|
||||
>
|
||||
<img
|
||||
src={dailyCharacter.pictureUrl}
|
||||
alt={dailyCharacter.name}
|
||||
class="w-20 h-20 mx-auto rounded-full border-2 border-slate-600 shadow-lg object-cover hover:border-slate-500 transition-colors cursor-pointer opacity-80"
|
||||
/>
|
||||
</a>
|
||||
{/if}
|
||||
<p class="mt-2 text-lg font-bold text-slate-200">{dailyCharacter.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="rounded-3xl border border-emerald-500/50 bg-emerald-500/10 p-4 shadow-[0_24px_60px_rgba(16,185,129,0.3)] backdrop-blur">
|
||||
<div class="text-center">
|
||||
<div class="text-3xl mb-2">🎉</div>
|
||||
<h2 class="text-xl font-bold text-emerald-400 mb-1">Félicitations !</h2>
|
||||
<p class="text-sm text-emerald-300">Vous avez trouvé le personnage en {selectedCharacters.length} {selectedCharacters.length > 1 ? 'tentatives' : 'tentative'} !</p>
|
||||
<div class="mt-3">
|
||||
{#if dailyCharacter.pictureUrl}
|
||||
<a
|
||||
href={"https://onepiece.fandom.com/fr/wiki/" + dailyCharacter.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-block"
|
||||
>
|
||||
<img
|
||||
src={dailyCharacter.pictureUrl}
|
||||
alt={dailyCharacter.name}
|
||||
class="w-20 h-20 mx-auto rounded-full border-2 border-emerald-400 shadow-lg object-cover hover:border-emerald-300 transition-colors cursor-pointer"
|
||||
/>
|
||||
</a>
|
||||
{/if}
|
||||
<p class="mt-2 text-lg font-bold text-white">{dailyCharacter.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="rounded-3xl border border-white/10 bg-white/5 p-6 shadow-[0_24px_60px_rgba(0,0,0,0.45)] backdrop-blur z-10">
|
||||
<h2 class="text-sm font-semibold uppercase tracking-[0.2em] text-amber-100">Entrer une supposition</h2>
|
||||
<div class="mt-4 flex flex-col gap-3 sm:flex-row">
|
||||
<div class="relative w-full">
|
||||
<input
|
||||
bind:value={searchInput}
|
||||
class="w-full rounded-full border border-amber-200/30 bg-slate-900/60 px-5 py-3 text-sm text-slate-100 placeholder:text-slate-400 focus:border-amber-200/70 focus:outline-none"
|
||||
placeholder="Nom du personnage"
|
||||
type="text"
|
||||
onkeydown={handleKeydown}
|
||||
/>
|
||||
{#if searchInput.length > 0 && filteredCharacters.length > 0}
|
||||
<div bind:this={dropdownContainer} class="absolute top-full left-0 right-0 mt-2 max-h-96 overflow-y-auto rounded-2xl border border-amber-200/30 bg-slate-900/90 backdrop-blur z-10">
|
||||
{#each filteredCharacters as character, index (character.id)}
|
||||
<button
|
||||
class="w-full px-5 py-4 text-left text-base text-slate-100 transition border-b border-slate-800/50 last:border-b-0 flex items-center gap-4 {index === highlightedIndex ? 'bg-slate-700' : 'hover:bg-slate-800/70'}"
|
||||
type="button"
|
||||
onmouseenter={() => highlightedIndex = index}
|
||||
onclick={() => selectCharacter(character)}
|
||||
>
|
||||
{#if character.pictureUrl}
|
||||
<img
|
||||
src={character.pictureUrl}
|
||||
alt={character.name}
|
||||
class="w-12 h-12 rounded-full object-cover border border-amber-200/30"
|
||||
/>
|
||||
{:else}
|
||||
<div class="w-12 h-12 rounded-full bg-slate-800 border border-amber-200/30 flex items-center justify-center">
|
||||
<span class="text-xs text-slate-400">?</span>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex-1">
|
||||
<span class="font-semibold text-amber-100">{character.name}</span>
|
||||
{#if character.epithets}
|
||||
{@const parsedEpithets = typeof character.epithets === 'string'
|
||||
? JSON.parse(character.epithets)
|
||||
: character.epithets}
|
||||
{#if Array.isArray(parsedEpithets) && parsedEpithets.length > 0}
|
||||
<span class="ml-2 text-xs text-slate-400">
|
||||
• {parsedEpithets.join(', ')}
|
||||
</span>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={submitGuess}
|
||||
disabled={filteredCharacters.length === 0}
|
||||
class="rounded-full bg-amber-300 px-6 py-3 text-sm font-semibold text-slate-900 transition hover:bg-amber-200 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Valider
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="mt-8 rounded-3xl border border-white/10 bg-white/5 p-6 shadow-[0_24px_60px_rgba(0,0,0,0.45)] backdrop-blur">
|
||||
<div class="flex flex-col gap-4">
|
||||
<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>
|
||||
</div>
|
||||
{#if selectedCharacters.length === 0}
|
||||
<p class="text-sm text-slate-200 text-center">Aucune tentative pour le moment.</p>
|
||||
{:else}
|
||||
<div class="overflow-x-auto pb-2 -mx-6 px-6 sm:mx-0 sm:px-0">
|
||||
<div class="w-max min-w-max mx-auto">
|
||||
<!-- Header -->
|
||||
<div class="flex gap-2 mb-2">
|
||||
<div class="w-24 shrink-0 rounded-lg border border-amber-200/30 bg-amber-900/30 p-2 text-center flex items-center justify-center">
|
||||
<p class="text-xs font-semibold uppercase tracking-wider text-amber-100">Personnage</p>
|
||||
</div>
|
||||
{#if columnVisibility.status !== false}
|
||||
<div class="w-24 shrink-0 rounded-lg border border-amber-200/30 bg-amber-900/30 p-2 text-center flex items-center justify-center">
|
||||
<p class="text-xs font-semibold uppercase tracking-wider text-amber-100">Statut</p>
|
||||
</div>
|
||||
{/if}
|
||||
{#if columnVisibility.gender !== false}
|
||||
<div class="w-24 shrink-0 rounded-lg border border-amber-200/30 bg-amber-900/30 p-2 text-center flex items-center justify-center">
|
||||
<p class="text-xs font-semibold uppercase tracking-wider text-amber-100">Genre</p>
|
||||
</div>
|
||||
{/if}
|
||||
{#if columnVisibility.affiliations !== false}
|
||||
<div class="w-24 shrink-0 rounded-lg border border-amber-200/30 bg-amber-900/30 p-2 text-center flex items-center justify-center">
|
||||
<p class="text-xs font-semibold uppercase tracking-wider text-amber-100">Affiliations</p>
|
||||
</div>
|
||||
{/if}
|
||||
{#if columnVisibility.devilFruitType !== false}
|
||||
<div class="w-24 shrink-0 rounded-lg border border-amber-200/30 bg-amber-900/30 p-2 text-center flex items-center justify-center">
|
||||
<p class="text-xs font-semibold uppercase tracking-wider text-amber-100">Fruit</p>
|
||||
</div>
|
||||
{/if}
|
||||
{#if columnVisibility.haki !== false}
|
||||
<div class="w-24 shrink-0 rounded-lg border border-amber-200/30 bg-amber-900/30 p-2 text-center flex items-center justify-center">
|
||||
<p class="text-xs font-semibold uppercase tracking-wider text-amber-100">Haki</p>
|
||||
</div>
|
||||
{/if}
|
||||
{#if columnVisibility.bounty !== false}
|
||||
<div class="w-24 shrink-0 rounded-lg border border-amber-200/30 bg-amber-900/30 p-2 text-center flex items-center justify-center">
|
||||
<p class="text-xs font-semibold uppercase tracking-wider text-amber-100">Prime</p>
|
||||
</div>
|
||||
{/if}
|
||||
{#if columnVisibility.height !== false}
|
||||
<div class="w-24 shrink-0 rounded-lg border border-amber-200/30 bg-amber-900/30 p-2 text-center flex items-center justify-center">
|
||||
<p class="text-xs font-semibold uppercase tracking-wider text-amber-100">Taille</p>
|
||||
</div>
|
||||
{/if}
|
||||
{#if columnVisibility.origin !== false}
|
||||
<div class="w-24 shrink-0 rounded-lg border border-amber-200/30 bg-amber-900/30 p-2 text-center flex items-center justify-center">
|
||||
<p class="text-xs font-semibold uppercase tracking-wider text-amber-100">Origine</p>
|
||||
</div>
|
||||
{/if}
|
||||
{#if columnVisibility.arc !== false}
|
||||
<div class="w-24 shrink-0 rounded-lg border border-amber-200/30 bg-amber-900/30 p-2 text-center flex items-center justify-center">
|
||||
<p class="text-xs font-semibold uppercase tracking-wider text-amber-100">Arc</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Rows -->
|
||||
{#each selectedCharacters as character (character.id)}
|
||||
<div class="flex gap-2 mb-2">
|
||||
<!-- Personnage -->
|
||||
<div class="w-24 h-24 shrink-0 rounded-lg border border-white/10 bg-slate-950/60 overflow-hidden">
|
||||
{#if character.pictureUrl}
|
||||
<a
|
||||
href={"https://onepiece.fandom.com/fr/wiki/" + character.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="block w-full h-full"
|
||||
>
|
||||
<img
|
||||
src={character.pictureUrl}
|
||||
alt={character.name}
|
||||
class="w-full h-full object-cover hover:opacity-80 transition-opacity cursor-pointer"
|
||||
/>
|
||||
</a>
|
||||
{:else}
|
||||
<div class="w-full h-full bg-slate-800 flex items-center justify-center p-2">
|
||||
<span class="text-xl text-center font-semibold line-clamp-3">{character.name}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Vivant / Mort -->
|
||||
{#if columnVisibility.status !== false}
|
||||
<div class="w-24 h-24 shrink-0 rounded-lg border border-white/10 {character.status === dailyCharacter.status ? 'bg-emerald-600/90' : 'bg-red-900/60'} p-2 flex items-center justify-center">
|
||||
<p class="text-sm font-bold text-white text-center">
|
||||
{character.status === 'Alive' ? 'Vivant' : character.status === 'Deceased' || character.status === 'Dead' ? 'Mort' : character.status || 'Inconnu'}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Genre -->
|
||||
{#if columnVisibility.gender !== false}
|
||||
<div class="w-24 h-24 shrink-0 rounded-lg border border-white/10 {character.gender === dailyCharacter.gender ? 'bg-emerald-600/90' : 'bg-red-900/60'} p-2 flex items-center justify-center">
|
||||
<p class="text-base font-bold text-white text-center">
|
||||
{character.gender === 'Male' ? 'Homme' : character.gender === 'Female' ? 'Femme' : character.gender || 'Inconnu'}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Affiliations -->
|
||||
{#if columnVisibility.affiliations !== false}
|
||||
<div class="w-24 h-24 shrink-0 rounded-lg border border-white/10 {(() => {
|
||||
try {
|
||||
const charAff = typeof character.affiliations === 'string'
|
||||
? ((character.affiliations as string).includes('[') ? JSON.parse(character.affiliations) : (character.affiliations as string).split(',').map((a: string) => a.trim()))
|
||||
: character.affiliations;
|
||||
const dailyAff = typeof dailyCharacter.affiliations === 'string'
|
||||
? ((dailyCharacter.affiliations as string).includes('[') ? JSON.parse(dailyCharacter.affiliations) : (dailyCharacter.affiliations as string).split(',').map((a: string) => a.trim()))
|
||||
: dailyCharacter.affiliations;
|
||||
const charFirstAff = Array.isArray(charAff) ? charAff[0] : charAff;
|
||||
const dailyFirstAff = Array.isArray(dailyAff) ? dailyAff[0] : dailyAff;
|
||||
return charFirstAff && dailyFirstAff && charFirstAff === dailyFirstAff ? 'bg-emerald-600/90' : 'bg-red-900/60';
|
||||
} catch (e) {
|
||||
return 'bg-slate-950/60';
|
||||
}
|
||||
})()} p-2 flex items-center justify-center overflow-hidden">
|
||||
{#if character.affiliations}
|
||||
{@const parsedAffiliations = typeof character.affiliations === 'string'
|
||||
? (character.affiliations.includes('[') ? JSON.parse(character.affiliations) : character.affiliations.split(',').map((a: string) => a.trim()))
|
||||
: character.affiliations}
|
||||
{#if Array.isArray(parsedAffiliations) && parsedAffiliations.length > 0}
|
||||
<p class="w-full text-sm font-bold text-white text-center whitespace-normal break-words leading-tight">{parsedAffiliations[0]}</p>
|
||||
{:else}
|
||||
<p class="w-full text-sm font-bold text-white text-center whitespace-normal break-words leading-tight">{parsedAffiliations}</p>
|
||||
{/if}
|
||||
{:else}
|
||||
<p class="text-base font-bold text-slate-400 text-center">-</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Fruit -->
|
||||
{#if columnVisibility.devilFruitType !== false}
|
||||
<div class="w-24 h-24 shrink-0 rounded-lg border border-white/10 {character.devilFruitType === dailyCharacter.devilFruitType ? 'bg-emerald-600/90' : 'bg-red-900/60'} p-2 flex items-center justify-center">
|
||||
{#if character.devilFruitType}
|
||||
<p class="text-sm font-bold text-white text-center">{character.devilFruitType}</p>
|
||||
{:else}
|
||||
<p class="text-5xl font-bold text-white text-center">✕</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Haki -->
|
||||
{#if columnVisibility.haki !== false}
|
||||
<div class="w-24 h-24 shrink-0 rounded-lg border border-white/10 {(() => {
|
||||
if (character.hakiObservation === dailyCharacter.hakiObservation && character.hakiArmament === dailyCharacter.hakiArmament && character.hakiConqueror === dailyCharacter.hakiConqueror) {
|
||||
return 'bg-emerald-600/90';
|
||||
} else if ((character.hakiObservation && dailyCharacter.hakiObservation) ||
|
||||
(character.hakiArmament && dailyCharacter.hakiArmament) ||
|
||||
(character.hakiConqueror && dailyCharacter.hakiConqueror)) {
|
||||
return 'bg-yellow-600/80';
|
||||
} else {
|
||||
return 'bg-red-900/60';
|
||||
}
|
||||
})()} p-2 flex items-center justify-center">
|
||||
<p class="text-2xl font-bold text-white text-center">
|
||||
{#if character.hakiObservation}<span title="Haki de l'Observation">👁️</span>{/if}
|
||||
{#if character.hakiArmament}<span title="Haki de l'Armement">🦾</span>{/if}
|
||||
{#if character.hakiConqueror}<span title="Haki des Rois">👑</span>{/if}
|
||||
{#if !character.hakiObservation && !character.hakiArmament && !character.hakiConqueror}
|
||||
<span class="text-5xl">✕</span>
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Prime -->
|
||||
{#if columnVisibility.bounty !== false}
|
||||
<div class="w-24 h-24 shrink-0 rounded-lg border border-white/10 {character.bounty === dailyCharacter.bounty ? 'bg-emerald-600/90' : 'bg-red-900/60'} p-2 flex items-center justify-center relative overflow-hidden">
|
||||
{#if character.bounty != null && dailyCharacter.bounty != null && character.bounty !== dailyCharacter.bounty}
|
||||
<div class="absolute w-full h-full opacity-30 pointer-events-none" style="
|
||||
background-color: rgb(203, 213, 225);
|
||||
clip-path: {character.bounty > dailyCharacter.bounty
|
||||
? 'polygon(97% 60%,80% 60%,80% 5%,20% 5%,20% 60%,3% 60%,50% 95%)'
|
||||
: 'polygon(97% 40%,80% 40%,80% 95%,20% 95%,20% 40%,3% 40%,50% 5%)'};
|
||||
"></div>
|
||||
{/if}
|
||||
{#if character.bounty != null}
|
||||
<p class="text-sm font-bold text-white text-center relative z-10">{formatBounty(character.bounty)} ฿</p>
|
||||
{:else}
|
||||
<p class="text-sm font-bold text-white text-center relative z-10">Inconnue</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Taille -->
|
||||
{#if columnVisibility.height !== false}
|
||||
<div class="w-24 h-24 shrink-0 rounded-lg border border-white/10 {character.height === dailyCharacter.height ? 'bg-emerald-600/90' : 'bg-red-900/60'} p-2 flex items-center justify-center relative overflow-hidden">
|
||||
{#if character.height && dailyCharacter.height && character.height !== dailyCharacter.height}
|
||||
<div class="absolute w-full h-full opacity-30 pointer-events-none" style="
|
||||
background-color: rgb(203, 213, 225);
|
||||
clip-path: {character.height > dailyCharacter.height
|
||||
? 'polygon(97% 60%,80% 60%,80% 5%,20% 5%,20% 60%,3% 60%,50% 95%)'
|
||||
: 'polygon(97% 40%,80% 40%,80% 95%,20% 95%,20% 40%,3% 40%,50% 5%)'};
|
||||
"></div>
|
||||
{/if}
|
||||
{#if character.height}
|
||||
<p class="text-sm font-bold text-white text-center relative z-10">{character.height} m</p>
|
||||
{:else}
|
||||
<p class="text-sm font-bold text-white text-center relative z-10">Inconnue</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Origine -->
|
||||
{#if columnVisibility.origin !== false}
|
||||
<div class="w-24 h-24 shrink-0 rounded-lg border border-white/10 {character.origin === dailyCharacter.origin ? 'bg-emerald-600/90' : 'bg-red-900/60'} p-2 flex items-center justify-center">
|
||||
<p class="text-sm font-bold text-white text-center">{character.origin || 'Inconnue'}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Arc -->
|
||||
{#if columnVisibility.arc !== false}
|
||||
<div class="w-24 h-24 shrink-0 rounded-lg border border-white/10 {character.arcName === dailyCharacter.arcName ? 'bg-emerald-600/90' : 'bg-red-900/60'} p-2 flex items-center justify-center relative overflow-hidden">
|
||||
{#if character.arcName !== dailyCharacter.arcName && character.firstAppearance && dailyCharacter.firstAppearance && character.firstAppearance !== dailyCharacter.firstAppearance}
|
||||
<div class="absolute w-full h-full opacity-30 pointer-events-none" style="
|
||||
background-color: rgb(203, 213, 225);
|
||||
clip-path: {character.firstAppearance > dailyCharacter.firstAppearance
|
||||
? 'polygon(97% 60%,80% 60%,80% 5%,20% 5%,20% 60%,3% 60%,50% 95%)'
|
||||
: 'polygon(97% 40%,80% 40%,80% 95%,20% 95%,20% 40%,3% 40%,50% 5%)'};
|
||||
"></div>
|
||||
{/if}
|
||||
<p class="text-sm font-bold text-white text-center relative z-10">{character.arcName || 'Inconnu'}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mt-8 rounded-3xl border border-white/10 bg-white/5 p-6 shadow-[0_24px_60px_rgba(0,0,0,0.45)] backdrop-blur">
|
||||
{#if yesterdayCharacter}
|
||||
<div class="flex flex-col items-center gap-5 text-center sm:flex-row sm:text-left">
|
||||
{#if yesterdayCharacter.pictureUrl}
|
||||
<img
|
||||
src={yesterdayCharacter.pictureUrl}
|
||||
alt={yesterdayCharacter.name}
|
||||
class="h-20 w-20 rounded-full border border-amber-200/40 object-cover"
|
||||
/>
|
||||
{:else}
|
||||
<div class="flex h-20 w-20 items-center justify-center rounded-full border border-amber-200/40 bg-slate-900/70 text-xs uppercase tracking-[0.25em] text-amber-100">
|
||||
Photo
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex-1">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.28em] text-amber-100">Personnage d'hier</p>
|
||||
<p class="mt-2 text-lg font-semibold text-white">{yesterdayCharacter.name}</p>
|
||||
{#if yesterdayCharacter.epithets}
|
||||
<p class="mt-1 text-sm text-slate-400">
|
||||
{typeof yesterdayCharacter.epithets === 'string'
|
||||
? JSON.parse(yesterdayCharacter.epithets).join(', ')
|
||||
: (yesterdayCharacter.epithets as string[]).join(', ')}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
<a
|
||||
href={"https://onepiece.fandom.com/fr/wiki/" + yesterdayCharacter.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="w-full rounded-full border border-amber-200/40 bg-transparent px-5 py-3 text-sm font-semibold text-amber-100 transition hover:border-amber-200 hover:text-amber-50 sm:w-auto"
|
||||
>
|
||||
Voir la page
|
||||
</a>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-col items-center gap-5 text-center sm:flex-row sm:text-left">
|
||||
<div class="flex h-20 w-20 items-center justify-center rounded-full border border-amber-200/40 bg-slate-900/70 text-xs uppercase tracking-[0.25em] text-amber-100">
|
||||
Photo
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.28em] text-amber-100">Personnage d'hier</p>
|
||||
<p class="mt-2 text-lg font-semibold text-white">Aucun personnage</p>
|
||||
<p class="mt-1 text-sm text-slate-200">Aucun personnage d'hier disponible</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
BIN
static/characters/rodriguez_zoro.png
Normal file
BIN
static/characters/rodriguez_zoro.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
Reference in New Issue
Block a user