Compare commits

...

19 Commits

Author SHA1 Message Date
2c4730487d feat: update GuessHistoryTable styles for improved layout and readability
All checks were successful
Build Docker Image / build (push) Successful in 1m22s
2026-03-02 20:54:48 +01:00
c40ec7e91a feat: add userCharacterHistory table schema and update journal with new version
All checks were successful
Build Docker Image / build (push) Successful in 1m22s
2026-03-02 20:42:44 +01:00
fc99dc826e feat: add deployment webhook call to build image workflow
All checks were successful
Build Docker Image / build (push) Successful in 1m20s
2026-03-02 20:28:42 +01:00
e11fd63ebb feat: update Docker image build workflow to lowercase repository name in output
All checks were successful
Build Docker Image / build (push) Successful in 1m21s
2026-03-02 20:13:49 +01:00
201c4759b6 feat: remove daily character seed from environment and implement random seed generation for selection 2026-03-02 12:41:18 +01:00
485d96026a feat: implement daily character seed from environment variable and enhance selection logic with hashing 2026-03-02 12:26:11 +01:00
a339498879 feat: refactor character history schema and related logic for improved date handling and data integrity 2026-03-02 12:20:13 +01:00
b5d53d69c1 feat: refactor daily character logic to improve win count retrieval and streamline character selection 2026-03-02 11:50:41 +01:00
f31f49aec7 feat: enhance column visibility toggle with localized display names and improve layout 2026-03-02 11:27:40 +01:00
8aac371c32 feat: implement infinite mode with character selection and scoring; refactor daily character storage logic 2026-03-02 11:23:52 +01:00
c3bc429af2 feat: add WinPanel and YesterdayCharacter components; refactor daily game page
- Introduced WinPanel component to display win/loss messages based on game outcome.
- Added YesterdayCharacter component to show the character from the previous day.
- Refactored daily game page to utilize new components for better code organization and readability.
- Updated server-side logic to fetch daily character ID and count wins more efficiently.
- Cleaned up character selection logic and hint availability tracking.
2026-03-02 11:05:02 +01:00
7157e8c5a6 feat: add promote admin script to manage user roles 2026-03-01 23:38:39 +01:00
bbce1ff136 feat: improve character update logic and handle haki fields more effectively 2026-03-01 23:35:20 +01:00
a80e977e87 fix: adjust main component height for consistent layout across game, login, and profile pages 2026-03-01 23:30:53 +01:00
b4aa5e1a73 feat: enhance admin layout with navigation and return link; add name field for sign-up in login 2026-03-01 23:08:28 +01:00
b849e6c4dc feat: add daily win recording endpoint, user authentication, and profile management
- Implemented a POST endpoint for recording daily wins in the game.
- Created login and signup functionality with email and password.
- Developed a profile page allowing users to update their profile information, change passwords, and manage active sessions.
- Added a toggle feature for switching between login and signup forms.
- Enhanced the layout by removing the profile button and adjusting the header structure.
2026-03-01 23:01:44 +01:00
40bdc80773 fix: update @types/node version and improve type handling in daily character affiliations 2026-03-01 20:23:28 +01:00
b183b5877b feat: add user admin status and profile management
- Updated user schema to include isAdmin field.
- Enhanced authentication hooks to fetch and set user admin status.
- Created ProfileButton component for user profile actions.
- Implemented profile and password update functionality.
- Added session management for user accounts.
- Developed login and signup pages with form handling.
- Introduced layout server for user session data.
- Updated daily page to reflect character changes.
2026-03-01 19:52:54 +01:00
ce9ffd2736 feat: add new character image for Rodriguez Zoro 2026-03-01 17:54:05 +01:00
54 changed files with 8358 additions and 835 deletions

View File

@@ -0,0 +1,70 @@
name: Build Docker Image
on:
push:
branches:
- main
tags:
- '*'
env:
GITEA_REGISTRY: ${{ gitea.server_url }}
GITEA_USERNAME: ${{ gitea.actor }}
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Determine image tag
id: tag
run: |
if [[ "${{ github.ref }}" == refs/tags/* ]]; then
TAG=${GITHUB_REF#refs/tags/}
elif [[ "${{ github.ref }}" == "refs/heads/main" ]]; then
TAG=main
else
echo "Not on main branch or a tag, skipping build"
exit 1
fi
echo "tag=$TAG" >> $GITHUB_OUTPUT
# Remove protocol from registry URL
REGISTRY="${{ env.GITEA_REGISTRY }}"
REGISTRY="${REGISTRY#https://}"
REGISTRY="${REGISTRY#http://}"
echo "registry=$REGISTRY" >> $GITHUB_OUTPUT
# Lowercase repository name
REPO="${{ gitea.repository }}"
REPO="${REPO,,}"
echo "repository=$REPO" >> $GITHUB_OUTPUT
- name: Login to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: ${{ steps.tag.outputs.registry }}
username: ${{ vars.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile
push: true
tags: |
${{ steps.tag.outputs.registry }}/${{ steps.tag.outputs.repository }}:${{ steps.tag.outputs.tag }}
${{ steps.tag.outputs.registry }}/${{ steps.tag.outputs.repository }}:latest
- name: Call deployment webhook
if: ${{ secrets.DEPLOYMENT_WEBHOOK != '' }}
run: |
curl -k -X POST ${{ secrets.DEPLOYMENT_WEBHOOK }} \
-H 'Content-Type: application/json'

View File

@@ -0,0 +1 @@
ALTER TABLE `user` ADD `is_admin` integer DEFAULT false NOT NULL;

View 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`);

View File

@@ -0,0 +1,9 @@
CREATE TABLE `userCharacterHistory` (
`id` text PRIMARY KEY NOT NULL,
`userId` text,
`characterId` text,
`tryCount` integer NOT NULL,
`createdAt` integer NOT NULL,
FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`characterId`) REFERENCES `character`(`id`) ON UPDATE no action ON DELETE no action
);

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -15,6 +15,27 @@
"when": 1772383366179, "when": 1772383366179,
"tag": "0001_nostalgic_hercules", "tag": "0001_nostalgic_hercules",
"breakpoints": true "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
},
{
"idx": 4,
"version": "6",
"when": 1772480377099,
"tag": "0004_unique_lorna_dane",
"breakpoints": true
} }
] ]
} }

3
package-lock.json generated
View File

@@ -6,7 +6,6 @@
"packages": { "packages": {
"": { "": {
"name": "onepiecedle", "name": "onepiecedle",
"version": "0.0.1",
"dependencies": { "dependencies": {
"@libsql/client": "^0.17.0", "@libsql/client": "^0.17.0",
"@sveltejs/adapter-node": "^5.5.4", "@sveltejs/adapter-node": "^5.5.4",
@@ -23,7 +22,7 @@
"@tailwindcss/forms": "^0.5.11", "@tailwindcss/forms": "^0.5.11",
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.18", "@tailwindcss/vite": "^4.1.18",
"@types/node": "^24", "@types/node": "^24.11.0",
"cheerio": "^1.0.0-rc.12", "cheerio": "^1.0.0-rc.12",
"csv-writer": "^1.6.0", "csv-writer": "^1.6.0",
"eslint": "^9.39.2", "eslint": "^9.39.2",

View File

@@ -1,7 +1,6 @@
{ {
"name": "onepiecedle", "name": "onepiecedle",
"private": true, "private": true,
"version": "0.0.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
@@ -18,6 +17,7 @@
"db:studio": "drizzle-kit studio", "db:studio": "drizzle-kit studio",
"db:import": "npx tsx scripts/import-json.ts", "db:import": "npx tsx scripts/import-json.ts",
"db:set-daily-mode": "npx tsx scripts/set-daily-mode.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", "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" "scrape": "npx tsx scripts/scrape-onepiece.ts"
}, },
@@ -29,7 +29,7 @@
"@tailwindcss/forms": "^0.5.11", "@tailwindcss/forms": "^0.5.11",
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.18", "@tailwindcss/vite": "^4.1.18",
"@types/node": "^24", "@types/node": "^24.11.0",
"cheerio": "^1.0.0-rc.12", "cheerio": "^1.0.0-rc.12",
"csv-writer": "^1.6.0", "csv-writer": "^1.6.0",
"eslint": "^9.39.2", "eslint": "^9.39.2",
@@ -47,11 +47,11 @@
"vite": "^7.3.1" "vite": "^7.3.1"
}, },
"dependencies": { "dependencies": {
"tsx": "^4.21.0", "@libsql/client": "^0.17.0",
"drizzle-orm": "^0.45.1",
"drizzle-kit": "^0.31.8",
"better-auth": "^1.4.18",
"@sveltejs/adapter-node": "^5.5.4", "@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
View 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
View File

@@ -1,18 +1,11 @@
import type { User, Session } from 'better-auth/minimal'; 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 { declare global {
namespace App { namespace App {
interface Locals { interface Locals {
user?: User; user?: User & { isAdmin?: boolean };
session?: Session; session?: Session;
} }
// interface Error {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
} }
} }

View File

@@ -1,6 +1,9 @@
import type { Handle } from '@sveltejs/kit'; import type { Handle } from '@sveltejs/kit';
import { building } from '$app/environment'; import { building } from '$app/environment';
import { auth } from '$lib/server/auth'; 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'; import { svelteKitHandler } from 'better-auth/svelte-kit';
const handleBetterAuth: Handle = async ({ event, resolve }) => { const handleBetterAuth: Handle = async ({ event, resolve }) => {
@@ -9,6 +12,12 @@ const handleBetterAuth: Handle = async ({ event, resolve }) => {
if (session) { if (session) {
event.locals.session = session.session; event.locals.session = session.session;
event.locals.user = session.user; 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 }); return svelteKitHandler({ event, resolve, auth, building });

View 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>

View 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-1 sm:gap-2 mb-2">
<div class="w-16 sm:w-20 md:w-24 shrink-0 rounded-lg border border-amber-200/30 bg-amber-900/30 p-1 sm:p-2 text-center flex items-center justify-center">
<p class="text-[9px] sm:text-xs font-semibold uppercase tracking-wider text-amber-100">Personnage</p>
</div>
{#if columnVisibility.status !== false}
<div class="w-16 sm:w-20 md:w-24 shrink-0 rounded-lg border border-amber-200/30 bg-amber-900/30 p-1 sm:p-2 text-center flex items-center justify-center">
<p class="text-[9px] sm:text-xs font-semibold uppercase tracking-wider text-amber-100">Statut</p>
</div>
{/if}
{#if columnVisibility.gender !== false}
<div class="w-16 sm:w-20 md:w-24 shrink-0 rounded-lg border border-amber-200/30 bg-amber-900/30 p-1 sm:p-2 text-center flex items-center justify-center">
<p class="text-[9px] sm:text-xs font-semibold uppercase tracking-wider text-amber-100">Genre</p>
</div>
{/if}
{#if columnVisibility.affiliations !== false}
<div class="w-16 sm:w-20 md:w-24 shrink-0 rounded-lg border border-amber-200/30 bg-amber-900/30 p-1 sm:p-2 text-center flex items-center justify-center">
<p class="text-[9px] sm:text-xs font-semibold uppercase tracking-wider text-amber-100">Affiliations</p>
</div>
{/if}
{#if columnVisibility.devilFruitType !== false}
<div class="w-16 sm:w-20 md:w-24 shrink-0 rounded-lg border border-amber-200/30 bg-amber-900/30 p-1 sm:p-2 text-center flex items-center justify-center">
<p class="text-[9px] sm:text-xs font-semibold uppercase tracking-wider text-amber-100">Fruit</p>
</div>
{/if}
{#if columnVisibility.haki !== false}
<div class="w-16 sm:w-20 md:w-24 shrink-0 rounded-lg border border-amber-200/30 bg-amber-900/30 p-1 sm:p-2 text-center flex items-center justify-center">
<p class="text-[9px] sm:text-xs font-semibold uppercase tracking-wider text-amber-100">Haki</p>
</div>
{/if}
{#if columnVisibility.bounty !== false}
<div class="w-16 sm:w-20 md:w-24 shrink-0 rounded-lg border border-amber-200/30 bg-amber-900/30 p-1 sm:p-2 text-center flex items-center justify-center">
<p class="text-[9px] sm:text-xs font-semibold uppercase tracking-wider text-amber-100">Prime</p>
</div>
{/if}
{#if columnVisibility.height !== false}
<div class="w-16 sm:w-20 md:w-24 shrink-0 rounded-lg border border-amber-200/30 bg-amber-900/30 p-1 sm:p-2 text-center flex items-center justify-center">
<p class="text-[9px] sm:text-xs font-semibold uppercase tracking-wider text-amber-100">Taille</p>
</div>
{/if}
{#if columnVisibility.origin !== false}
<div class="w-16 sm:w-20 md:w-24 shrink-0 rounded-lg border border-amber-200/30 bg-amber-900/30 p-1 sm:p-2 text-center flex items-center justify-center">
<p class="text-[9px] sm:text-xs font-semibold uppercase tracking-wider text-amber-100">Origine</p>
</div>
{/if}
{#if columnVisibility.arc !== false}
<div class="w-16 sm:w-20 md:w-24 shrink-0 rounded-lg border border-amber-200/30 bg-amber-900/30 p-1 sm:p-2 text-center flex items-center justify-center">
<p class="text-[9px] sm:text-xs font-semibold uppercase tracking-wider text-amber-100">Arc</p>
</div>
{/if}
</div>
<!-- Rows -->
{#each selectedCharacters as character (character.id)}
<div class="flex gap-1 sm:gap-2 mb-2">
<!-- Personnage -->
<div class="w-16 h-16 sm:w-20 sm:h-20 md:w-24 md:h-24 shrink-0 rounded-lg border border-white/10 bg-slate-950/60 overflow-hidden">
{#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-1 sm:p-2">
<span class="text-xs sm:text-sm md:text-xl text-center font-semibold line-clamp-3">{character.name}</span>
</div>
{/if}
</div>
<!-- Vivant / Mort -->
{#if columnVisibility.status !== false}
<div class="w-16 h-16 sm:w-20 sm:h-20 md:w-24 md:h-24 shrink-0 rounded-lg border border-white/10 {character.status === dailyCharacter.status ? 'bg-emerald-600/90' : 'bg-red-900/60'} p-1 sm:p-2 flex items-center justify-center">
<p class="text-[10px] sm:text-xs md:text-sm font-bold text-white text-center">
{character.status === 'Alive' ? 'Vivant' : character.status === 'Deceased' || character.status === 'Dead' ? 'Mort' : character.status || 'Inconnu'}
</p>
</div>
{/if}
<!-- Genre -->
{#if columnVisibility.gender !== false}
<div class="w-16 h-16 sm:w-20 sm:h-20 md:w-24 md:h-24 shrink-0 rounded-lg border border-white/10 {character.gender === dailyCharacter.gender ? 'bg-emerald-600/90' : 'bg-red-900/60'} p-1 sm:p-2 flex items-center justify-center">
<p class="text-xs sm:text-sm md:text-base font-bold text-white text-center">
{character.gender === 'Male' ? 'Homme' : character.gender === 'Female' ? 'Femme' : character.gender || 'Inconnu'}
</p>
</div>
{/if}
<!-- Affiliations -->
{#if columnVisibility.affiliations !== false}
<div class="w-16 h-16 sm:w-20 sm:h-20 md:w-24 md:h-24 shrink-0 rounded-lg border border-white/10 {(() => {
try {
const charAff = typeof character.affiliations === 'string'
? ((character.affiliations as string).includes('[') ? JSON.parse(character.affiliations) : (character.affiliations as string).split(',').map((a: string) => a.trim()))
: character.affiliations;
const dailyAff = typeof dailyCharacter.affiliations === 'string'
? ((dailyCharacter.affiliations as string).includes('[') ? JSON.parse(dailyCharacter.affiliations) : (dailyCharacter.affiliations as string).split(',').map((a: string) => a.trim()))
: dailyCharacter.affiliations;
const charFirstAff = Array.isArray(charAff) ? charAff[0] : charAff;
const dailyFirstAff = Array.isArray(dailyAff) ? dailyAff[0] : dailyAff;
return charFirstAff && dailyFirstAff && charFirstAff === dailyFirstAff ? 'bg-emerald-600/90' : 'bg-red-900/60';
} catch (e) {
return 'bg-slate-950/60';
}
})()} p-1 sm:p-2 flex items-center justify-center overflow-hidden">
{#if character.affiliations}
{@const parsedAffiliations = typeof character.affiliations === 'string'
? (character.affiliations.includes('[') ? JSON.parse(character.affiliations) : character.affiliations.split(',').map((a: string) => a.trim()))
: character.affiliations}
{#if Array.isArray(parsedAffiliations) && parsedAffiliations.length > 0}
<p class="w-full text-[10px] sm:text-xs md:text-sm font-bold text-white text-center whitespace-normal break-words leading-tight">{parsedAffiliations[0]}</p>
{:else}
<p class="w-full text-[10px] sm:text-xs md:text-sm font-bold text-white text-center whitespace-normal break-words leading-tight">{parsedAffiliations}</p>
{/if}
{:else}
<p class="text-xs sm:text-sm md:text-base font-bold text-slate-400 text-center">-</p>
{/if}
</div>
{/if}
<!-- Fruit -->
{#if columnVisibility.devilFruitType !== false}
<div class="w-16 h-16 sm:w-20 sm:h-20 md:w-24 md:h-24 shrink-0 rounded-lg border border-white/10 {character.devilFruitType === dailyCharacter.devilFruitType ? 'bg-emerald-600/90' : 'bg-red-900/60'} p-1 sm:p-2 flex items-center justify-center">
{#if character.devilFruitType}
<p class="text-[10px] sm:text-xs md:text-sm font-bold text-white text-center">{character.devilFruitType}</p>
{:else}
<p class="text-2xl sm:text-3xl md:text-5xl font-bold text-white text-center"></p>
{/if}
</div>
{/if}
<!-- Haki -->
{#if columnVisibility.haki !== false}
<div class="w-16 h-16 sm:w-20 sm:h-20 md:w-24 md:h-24 shrink-0 rounded-lg border border-white/10 {(() => {
if (character.hakiObservation === dailyCharacter.hakiObservation && character.hakiArmament === dailyCharacter.hakiArmament && character.hakiConqueror === dailyCharacter.hakiConqueror) {
return 'bg-emerald-600/90';
} else if ((character.hakiObservation && dailyCharacter.hakiObservation) ||
(character.hakiArmament && dailyCharacter.hakiArmament) ||
(character.hakiConqueror && dailyCharacter.hakiConqueror)) {
return 'bg-yellow-600/80';
} else {
return 'bg-red-900/60';
}
})()} p-1 sm:p-2 flex items-center justify-center">
<p class="text-sm sm:text-lg md:text-2xl font-bold text-white text-center">
{#if character.hakiObservation}<span title="Haki de l'Observation">👁️</span>{/if}
{#if character.hakiArmament}<span title="Haki de l'Armement">🦾</span>{/if}
{#if character.hakiConqueror}<span title="Haki des Rois">👑</span>{/if}
{#if !character.hakiObservation && !character.hakiArmament && !character.hakiConqueror}
<span class="text-2xl sm:text-3xl md:text-5xl"></span>
{/if}
</p>
</div>
{/if}
<!-- Prime -->
{#if columnVisibility.bounty !== false}
<div class="w-16 h-16 sm:w-20 sm:h-20 md:w-24 md:h-24 shrink-0 rounded-lg border border-white/10 {character.bounty === dailyCharacter.bounty ? 'bg-emerald-600/90' : 'bg-red-900/60'} p-1 sm:p-2 flex items-center justify-center relative overflow-hidden">
{#if character.bounty != null && dailyCharacter.bounty != null && character.bounty !== dailyCharacter.bounty}
<div class="absolute w-full h-full opacity-30 pointer-events-none" style="
background-color: rgb(203, 213, 225);
clip-path: {character.bounty > dailyCharacter.bounty
? 'polygon(97% 60%,80% 60%,80% 5%,20% 5%,20% 60%,3% 60%,50% 95%)'
: 'polygon(97% 40%,80% 40%,80% 95%,20% 95%,20% 40%,3% 40%,50% 5%)'};
"></div>
{/if}
{#if character.bounty != null}
<p class="text-[10px] sm:text-xs md:text-sm font-bold text-white text-center relative z-10">{formatBounty(character.bounty)} ฿</p>
{:else}
<p class="text-[10px] sm:text-xs md:text-sm font-bold text-white text-center relative z-10">Inconnue</p>
{/if}
</div>
{/if}
<!-- Taille -->
{#if columnVisibility.height !== false}
<div class="w-16 h-16 sm:w-20 sm:h-20 md:w-24 md:h-24 shrink-0 rounded-lg border border-white/10 {character.height === dailyCharacter.height ? 'bg-emerald-600/90' : 'bg-red-900/60'} p-1 sm:p-2 flex items-center justify-center relative overflow-hidden">
{#if character.height && dailyCharacter.height && character.height !== dailyCharacter.height}
<div class="absolute w-full h-full opacity-30 pointer-events-none" style="
background-color: rgb(203, 213, 225);
clip-path: {character.height > dailyCharacter.height
? 'polygon(97% 60%,80% 60%,80% 5%,20% 5%,20% 60%,3% 60%,50% 95%)'
: 'polygon(97% 40%,80% 40%,80% 95%,20% 95%,20% 40%,3% 40%,50% 5%)'};
"></div>
{/if}
{#if character.height}
<p class="text-[10px] sm:text-xs md:text-sm font-bold text-white text-center relative z-10">{character.height} m</p>
{:else}
<p class="text-[10px] sm:text-xs md:text-sm font-bold text-white text-center relative z-10">Inconnue</p>
{/if}
</div>
{/if}
<!-- Origine -->
{#if columnVisibility.origin !== false}
<div class="w-16 h-16 sm:w-20 sm:h-20 md:w-24 md:h-24 shrink-0 rounded-lg border border-white/10 {character.origin === dailyCharacter.origin ? 'bg-emerald-600/90' : 'bg-red-900/60'} p-1 sm:p-2 flex items-center justify-center">
<p class="text-[10px] sm:text-xs md:text-sm font-bold text-white text-center">{character.origin || 'Inconnue'}</p>
</div>
{/if}
<!-- Arc -->
{#if columnVisibility.arc !== false}
<div class="w-16 h-16 sm:w-20 sm:h-20 md:w-24 md:h-24 shrink-0 rounded-lg border border-white/10 {character.arcName === dailyCharacter.arcName ? 'bg-emerald-600/90' : 'bg-red-900/60'} p-1 sm:p-2 flex items-center justify-center relative overflow-hidden">
{#if character.arcName !== dailyCharacter.arcName && character.firstAppearance && dailyCharacter.firstAppearance && character.firstAppearance !== dailyCharacter.firstAppearance}
<div class="absolute w-full h-full opacity-30 pointer-events-none" style="
background-color: rgb(203, 213, 225);
clip-path: {character.firstAppearance > dailyCharacter.firstAppearance
? 'polygon(97% 60%,80% 60%,80% 5%,20% 5%,20% 60%,3% 60%,50% 95%)'
: 'polygon(97% 40%,80% 40%,80% 95%,20% 95%,20% 40%,3% 40%,50% 5%)'};
"></div>
{/if}
<p class="text-[10px] sm:text-xs md:text-sm font-bold text-white text-center relative z-10">{character.arcName || 'Inconnu'}</p>
</div>
{/if}
</div>
{/each}
</div>
</div>
{/if}
</div>
</section>

View 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>

View 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>

View 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}

View 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>

View File

@@ -1 +1,3 @@
// place files you want to import through the `$lib` alias in this folder. // place files you want to import through the `$lib` alias in this folder.
export { formatBounty } from './utils';

View File

@@ -1,6 +1,9 @@
import { db } from '$lib/server/db'; import { db } from '$lib/server/db';
import { arc, character, characterHistory, characterOverride, devilFruit } from '$lib/server/db/schema'; 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 = { const characterWithRelationsSelect = {
id: character.id, id: character.id,
@@ -139,21 +142,22 @@ async function applyCharacterOverrides(
); );
} }
function getDateKey(date: Date): string { export function getDateKey(date: Date): number {
return date.toISOString().split('T')[0]; return normalizeDay(date).getTime();
} }
function normalizeDay(date: Date = new Date()): Date { export function normalizeDay(date: Date = new Date()): Date {
const normalized = new Date(date); const normalized = new Date(date);
normalized.setHours(1, 0, 0, 0); normalized.setHours(1, 0, 0, 0);
return normalized; return normalized;
} }
function pickDailyCharacter(characters: CharacterWithRelations[], date: Date): CharacterWithRelations { function pickDailyCharacter(characters: CharacterWithRelations[], date: Date): CharacterWithRelations {
const dateStr = getDateKey(date); const timestamp = getDateKey(date);
const seed = dateStr.split('-').reduce((acc, value) => acc + parseInt(value), 0); const daysSinceEpoch = Math.floor(timestamp / 1000 / 60 / 60 / 24);
const index = seed % characters.length; // Combine timestamp with random seed to avoid predictable results
return characters[index]; const combinedSeed = (daysSinceEpoch + Math.floor(RANDOM_SEED * 1000000)) % characters.length;
return characters[combinedSeed];
} }
export async function getDailyModeCharacters(): Promise<CharacterWithRelations[]> { export async function getDailyModeCharacters(): Promise<CharacterWithRelations[]> {
@@ -168,6 +172,17 @@ export async function getDailyModeCharacters(): Promise<CharacterWithRelations[]
return applyCharacterOverrides(characters); 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> { export async function getCharacterById(characterId: string): Promise<CharacterWithRelations | null> {
const [found] = await db const [found] = await db
.select(characterWithRelationsSelect) .select(characterWithRelationsSelect)
@@ -186,10 +201,12 @@ export async function getCharacterById(characterId: string): Promise<CharacterWi
} }
export async function getOrCreateTodayCharacter( export async function getOrCreateTodayCharacter(
characters: CharacterWithRelations[], characters?: CharacterWithRelations[],
date: Date = new Date() date: Date = new Date()
): Promise<CharacterWithRelations | null> { ): Promise<CharacterWithRelations | null> {
if (characters.length === 0) { const dailyCharacters = characters ?? (await getDailyModeCharacters());
if (dailyCharacters.length === 0) {
return null; return null;
} }
@@ -204,7 +221,7 @@ export async function getOrCreateTodayCharacter(
if (existingEntry?.characterId) { if (existingEntry?.characterId) {
return ( return (
characters.find((currentCharacter) => currentCharacter.id === existingEntry.characterId) ?? dailyCharacters.find((currentCharacter) => currentCharacter.id === existingEntry.characterId) ??
(await getCharacterById(existingEntry.characterId)) (await getCharacterById(existingEntry.characterId))
); );
} }
@@ -216,10 +233,10 @@ export async function getOrCreateTodayCharacter(
.limit(100); .limit(100);
const excludedIds = new Set(recentHistory.map((entry) => entry.characterId)); 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( const dailyCharacter = pickDailyCharacter(
availableCharacters.length > 0 ? availableCharacters : characters, availableCharacters.length > 0 ? availableCharacters : dailyCharacters,
today today
); );
@@ -241,9 +258,9 @@ export async function getYesterdayCharacter(
date: Date = new Date(), date: Date = new Date(),
characters?: CharacterWithRelations[] characters?: CharacterWithRelations[]
): Promise<CharacterWithRelations | null> { ): Promise<CharacterWithRelations | null> {
const baseDate = normalizeDay(date); const yesterday = new Date(date);
baseDate.setDate(baseDate.getDate() - 1); yesterday.setDate(yesterday.getDate() - 1);
const yesterdayDate = getDateKey(baseDate); const yesterdayDate = getDateKey(yesterday);
const [yesterdayEntry] = await db const [yesterdayEntry] = await db
.select() .select()
@@ -264,3 +281,23 @@ export async function getYesterdayCharacter(
return getCharacterById(yesterdayEntry.characterId); 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;
}

View File

@@ -9,6 +9,7 @@ export const user = sqliteTable("user", {
.default(false) .default(false)
.notNull(), .notNull(),
image: text("image"), image: text("image"),
isAdmin: integer("is_admin", { mode: "boolean" }).default(false).notNull(),
createdAt: integer("created_at", { mode: "timestamp_ms" }) createdAt: integer("created_at", { mode: "timestamp_ms" })
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`) .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
.notNull(), .notNull(),

View File

@@ -1,4 +1,5 @@
import { integer, sqliteTable, text, real } from 'drizzle-orm/sqlite-core'; import { integer, sqliteTable, text, real } from 'drizzle-orm/sqlite-core';
import { user } from './auth.schema';
// Define devil fruit types // Define devil fruit types
export type DevilFruitType = 'Paramecia' | 'Zoan' | 'Logia' | 'Unknown'; export type DevilFruitType = 'Paramecia' | 'Zoan' | 'Logia' | 'Unknown';
@@ -100,11 +101,22 @@ export const characterHistory = sqliteTable('characterHistory', {
.primaryKey() .primaryKey()
.$defaultFn(() => crypto.randomUUID()), .$defaultFn(() => crypto.randomUUID()),
characterId: text('characterId').references(() => character.id), characterId: text('characterId').references(() => character.id),
date: text('date'), date: integer('date').notNull().unique(),
won: integer('won').notNull().default(0), won: integer('won').notNull().default(0),
createdAt: integer('createdAt').notNull().$default(() => Date.now()), createdAt: integer('createdAt').notNull().$default(() => Date.now()),
updatedAt: integer('updatedAt').notNull().$default(() => Date.now()), updatedAt: integer('updatedAt').notNull().$default(() => Date.now()),
}); });
// Define the user character history table schema
export const userCharacterHistory = sqliteTable('userCharacterHistory', {
id: text('id')
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
userId: text('userId').references(() => user.id),
characterHistoryId: text('characterHistoryId').references(() => characterHistory.id),
tryCount: integer('tryCount').notNull(),
createdAt: integer('createdAt').notNull().$default(() => Date.now())
});
export * from './auth.schema'; export * from './auth.schema';

13
src/lib/utils.ts Normal file
View 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();
}

View 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, '/');
}
};

View 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>

View 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
}
};
};

View 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>

View 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' });
}
}
};

View 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>

View 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' });
}
}
};

View 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>

View 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' });
}
}
};

View 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>

View 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' });
}
}
};

View 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>

View 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' });
}
}};

View 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>

View 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>

View File

@@ -9,12 +9,12 @@
</svelte:head> </svelte:head>
<main <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 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="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="flex w-full flex-col items-center gap-8">
<div class="text-center mb-12"> <div class="text-center mb-12">
<h1 class="text-4xl font-black uppercase tracking-[0.3em] text-amber-50 sm:text-6xl"> <h1 class="text-4xl font-black uppercase tracking-[0.3em] text-amber-50 sm:text-6xl">
@@ -37,12 +37,15 @@
</a> </a>
</div> </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"> <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> <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">Entraine-toi avec des pirates legendaires</p> <p class="mt-3 text-lg font-semibold text-white">Des defis sans fin</p>
<p class="mt-2 text-sm text-slate-200">Choisis une epoque, regle la difficulte et vogue a ton rythme.</p> <p class="mt-2 text-sm text-slate-200">Enchaine les personnages et croise ton score. Pas de limite, que du plaisir.</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"> <a
En construction href="/infinite"
</button> 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> </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"> <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">

View 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>

View File

@@ -3,6 +3,7 @@ import { db } from '$lib/server/db';
import { characterHistory } from '$lib/server/db/schema'; import { characterHistory } from '$lib/server/db/schema';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { sql } from 'drizzle-orm'; import { sql } from 'drizzle-orm';
import { getDateKey } from '$lib/server/daily-character';
export async function POST({ request }) { export async function POST({ request }) {
try { try {
@@ -12,9 +13,7 @@ export async function POST({ request }) {
return json({ error: 'Missing characterId' }, { status: 400 }); return json({ error: 'Missing characterId' }, { status: 400 });
} }
const today = new Date(); const todayDate = getDateKey(new Date());
today.setHours(0, 0, 0, 0);
const todayDate = today.toISOString().split('T')[0];
// Increment the won counter for today's entry // Increment the won counter for today's entry
await db await db

View 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
};
}

View 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>

View 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, '/');
}
};

View 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>

View 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' };
}
};

View 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>

View File

@@ -0,0 +1,8 @@
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = ({ locals }) => {
return {
user: locals.user || null,
session: locals.session || null
};
};

View File

@@ -6,4 +6,6 @@
</script> </script>
<svelte:head><link rel="icon" href={favicon} /></svelte:head> <svelte:head><link rel="icon" href={favicon} /></svelte:head>
{@render children()} {@render children()}

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB