Compare commits

...

5 Commits

Author SHA1 Message Date
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
38 changed files with 4536 additions and 43 deletions

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -15,6 +15,13 @@
"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
} }
] ]
} }

2
package-lock.json generated
View File

@@ -23,7 +23,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

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

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

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

@@ -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(),

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,37 @@
import { db } from '$lib/server/db';
import { character, devilFruit, arc, user, config, characterHistory } from '$lib/server/db/schema';
import { eq } from 'drizzle-orm';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async () => {
const [characters, devilFruits, arcs, users, configEntries, history] = await Promise.all([
db.select().from(character),
db.select().from(devilFruit),
db.select().from(arc),
db.select().from(user),
db.select().from(config),
db.select().from(characterHistory)
]);
// Get daily character ID from config
const dailyCharIdEntry = configEntries.find((c) => c.key === 'dailyCharacterId');
const dailyCharId = dailyCharIdEntry?.value;
// Count how many times today's daily character was won/found
const today = new Date().toISOString().split('T')[0];
const dailyCharacterWins = dailyCharId
? history.filter((h) => h.characterId === dailyCharId && h.date === today && h.won === 1).length
: 0;
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,171 @@
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 updates: Record<string, any> = {
// Initialize boolean fields to false (they'll be set to true if present in formData)
hakiObservation: false,
hakiArmament: false,
hakiConqueror: false
};
formData.forEach((value, key) => {
if (key !== 'id') {
// Handle checkboxes (haki fields)
if (key === 'hakiObservation' || key === 'hakiArmament' || key === 'hakiConqueror') {
updates[key] = value === 'on';
}
// Handle integers (age, bounty, height, devilFruitId, arcId)
else if (key === 'age' || key === 'bounty' || key === 'height' || key === 'devilFruitId' || key === 'arcId') {
const strValue = value as string;
updates[key] = strValue && strValue !== '' ? parseInt(strValue) : null;
}
// Handle strings (name, gender, status, origin, affiliations, epithets, pictureUrl, url, firstAppearance)
else {
updates[key] = value || null;
}
}
});
// 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 ?? false,
hakiArmament: override.hakiArmament ?? false,
hakiConqueror: override.hakiConqueror ?? false,
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

@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { formatBounty } from '$lib';
export let data; export let data;
@@ -154,7 +155,7 @@
}).catch(err => console.error('Failed to record win:', err)); }).catch(err => console.error('Failed to record win:', err));
// Check if it's gecko_moria for special animation // Check if it's gecko_moria for special animation
if (dailyCharacter.id === 'gecko_moria') { if (dailyCharacter.id === 'gecko_moria_gecko_moria') {
isGeckoMoriaWin = true; isGeckoMoriaWin = true;
} }
} }
@@ -194,20 +195,6 @@
selectCharacter(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> </script>
<svelte:head> <svelte:head>
@@ -298,16 +285,7 @@
<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 px-6 py-16 sm:py-20"> <div class="relative mx-auto flex min-h-screen w-full max-w-6xl flex-col px-6 py-8 sm:py-10">
<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"> <header class="flex flex-col items-start gap-6 w-full">
<div class="flex w-full items-center justify-between gap-4"> <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"> <h1 class="text-3xl font-black uppercase tracking-[0.25em] text-amber-50 sm:text-5xl">
@@ -370,7 +348,7 @@
<p class="text-sm font-medium text-amber-100">Affiliation</p> <p class="text-sm font-medium text-amber-100">Affiliation</p>
{#if showHintAffiliation} {#if showHintAffiliation}
{@const affiliations = typeof dailyCharacter.affiliations === 'string' {@const affiliations = typeof dailyCharacter.affiliations === 'string'
? (dailyCharacter.affiliations.includes('[') ? JSON.parse(dailyCharacter.affiliations) : dailyCharacter.affiliations.split(',').map((a: string) => a.trim())) ? ((dailyCharacter.affiliations as string).includes('[') ? JSON.parse(dailyCharacter.affiliations) : (dailyCharacter.affiliations as string).split(',').map((a: string) => a.trim()))
: dailyCharacter.affiliations} : dailyCharacter.affiliations}
<p class="mt-2 text-xs text-white font-semibold">{Array.isArray(affiliations) ? affiliations[0] : affiliations || 'Inconnue'}</p> <p class="mt-2 text-xs text-white font-semibold">{Array.isArray(affiliations) ? affiliations[0] : affiliations || 'Inconnue'}</p>
{:else if Math.max(0, 15 - selectedCharacters.length) > 0} {:else if Math.max(0, 15 - selectedCharacters.length) > 0}

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-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-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-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-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()}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB