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.
This commit is contained in:
@@ -65,7 +65,7 @@
|
|||||||
<svg
|
<svg
|
||||||
class="h-4 w-4 transition {isMenuOpen ? 'rotate-180' : ''}"
|
class="h-4 w-4 transition {isMenuOpen ? 'rotate-180' : ''}"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="white"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
>
|
>
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 14l-7 7m0 0l-7-7m7 7V3" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 14l-7 7m0 0l-7-7m7 7V3" />
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
13
src/lib/utils.ts
Normal file
13
src/lib/utils.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
export function formatBounty(bounty: number): string {
|
||||||
|
if (bounty >= 1_000_000_000) {
|
||||||
|
const billions = bounty / 1_000_000_000;
|
||||||
|
return `${billions}B`;
|
||||||
|
} else if (bounty >= 1_000_000) {
|
||||||
|
const millions = bounty / 1_000_000;
|
||||||
|
return `${millions}M`;
|
||||||
|
} else if (bounty >= 1_000) {
|
||||||
|
const thousands = bounty / 1_000;
|
||||||
|
return `${thousands}K`;
|
||||||
|
}
|
||||||
|
return bounty.toString();
|
||||||
|
}
|
||||||
12
src/routes/(admin)/admin/+layout.server.ts
Normal file
12
src/routes/(admin)/admin/+layout.server.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
import type { LayoutServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: LayoutServerLoad = async ({ locals }) => {
|
||||||
|
if (!locals.user) {
|
||||||
|
redirect(302, '/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!locals.user.isAdmin) {
|
||||||
|
redirect(302, '/');
|
||||||
|
}
|
||||||
|
};
|
||||||
57
src/routes/(admin)/admin/+layout.svelte
Normal file
57
src/routes/(admin)/admin/+layout.svelte
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
<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="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="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>
|
||||||
|
</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>
|
||||||
37
src/routes/(admin)/admin/+page.server.ts
Normal file
37
src/routes/(admin)/admin/+page.server.ts
Normal 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
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
121
src/routes/(admin)/admin/+page.svelte
Normal file
121
src/routes/(admin)/admin/+page.svelte
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
data: PageData;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { data }: Props = $props();
|
||||||
|
|
||||||
|
const statCards = $derived.by(() => [
|
||||||
|
{
|
||||||
|
label: 'Total Characters',
|
||||||
|
value: data.stats.totalCharacters,
|
||||||
|
icon: '🗣️',
|
||||||
|
bgColor: 'bg-blue-500/10 border-blue-500/20'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'In Daily Mode',
|
||||||
|
value: data.stats.charactersInDaily,
|
||||||
|
icon: '📅',
|
||||||
|
bgColor: 'bg-green-500/10 border-green-500/20'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Devil Fruits',
|
||||||
|
value: data.stats.totalDevilFruits,
|
||||||
|
icon: '🍎',
|
||||||
|
bgColor: 'bg-red-500/10 border-red-500/20'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Arcs',
|
||||||
|
value: data.stats.totalArcs,
|
||||||
|
icon: '📚',
|
||||||
|
bgColor: 'bg-purple-500/10 border-purple-500/20'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Total Users',
|
||||||
|
value: data.stats.totalUsers,
|
||||||
|
icon: '👥',
|
||||||
|
bgColor: 'bg-yellow-500/10 border-yellow-500/20'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Admin Users',
|
||||||
|
value: data.stats.adminUsers,
|
||||||
|
icon: '🔑',
|
||||||
|
bgColor: 'bg-orange-500/10 border-orange-500/20'
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Admin Dashboard - OnePieceDle</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="space-y-8">
|
||||||
|
<!-- Welcome Section -->
|
||||||
|
<div class="rounded-lg border border-white/10 bg-gradient-to-r from-amber-600/20 to-amber-700/10 p-6">
|
||||||
|
<h2 class="text-2xl font-bold text-white">Welcome Back!</h2>
|
||||||
|
<p class="mt-2 text-gray-400">
|
||||||
|
{#if data.stats.dailyCharacterWins > 0}
|
||||||
|
<strong class="text-amber-400">{data.stats.dailyCharacterWins}</strong>
|
||||||
|
{data.stats.dailyCharacterWins === 1 ? 'person has' : 'people have'} found today's daily character!
|
||||||
|
{:else}
|
||||||
|
No one has found today's daily character yet.
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stats Grid -->
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{#each statCards as card}
|
||||||
|
<div
|
||||||
|
class={`rounded-lg border p-6 transition-all hover:shadow-lg hover:shadow-white/5 ${card.bgColor}`}
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-400">{card.label}</p>
|
||||||
|
<p class="mt-2 text-3xl font-bold text-white">{card.value}</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-4xl">{card.icon}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick Actions -->
|
||||||
|
<div class="rounded-lg border border-white/10 bg-slate-800/50 p-6">
|
||||||
|
<h3 class="mb-4 text-lg font-bold text-white">Quick Actions</h3>
|
||||||
|
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<a
|
||||||
|
href="/admin/characters"
|
||||||
|
class="rounded-lg bg-slate-700 px-4 py-3 text-sm font-medium text-white transition-colors hover:bg-slate-600"
|
||||||
|
>
|
||||||
|
↳ Manage Characters
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="/admin/devil-fruits"
|
||||||
|
class="rounded-lg bg-slate-700 px-4 py-3 text-sm font-medium text-white transition-colors hover:bg-slate-600"
|
||||||
|
>
|
||||||
|
↳ Manage Devil Fruits
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="/admin/arcs"
|
||||||
|
class="rounded-lg bg-slate-700 px-4 py-3 text-sm font-medium text-white transition-colors hover:bg-slate-600"
|
||||||
|
>
|
||||||
|
↳ Manage Arcs
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="/admin/users"
|
||||||
|
class="rounded-lg bg-slate-700 px-4 py-3 text-sm font-medium text-white transition-colors hover:bg-slate-600"
|
||||||
|
>
|
||||||
|
↳ Manage Users
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="/admin/config"
|
||||||
|
class="rounded-lg bg-slate-700 px-4 py-3 text-sm font-medium text-white transition-colors hover:bg-slate-600"
|
||||||
|
>
|
||||||
|
↳ App Settings
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
69
src/routes/(admin)/admin/arcs/+page.server.ts
Normal file
69
src/routes/(admin)/admin/arcs/+page.server.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { arc } from '$lib/server/db/schema';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { fail } from '@sveltejs/kit';
|
||||||
|
import type { PageServerLoad, Actions } from './$types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async () => {
|
||||||
|
const arcs = await db.select().from(arc).orderBy(arc.name);
|
||||||
|
|
||||||
|
return {
|
||||||
|
arcs
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
update: async ({ request, locals }) => {
|
||||||
|
if (!locals.user?.isAdmin) {
|
||||||
|
return fail(401, { error: 'Unauthorized' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await request.formData();
|
||||||
|
const id = formData.get('id') as string;
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return fail(400, { error: 'Arc ID is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updates: Record<string, any> = {};
|
||||||
|
formData.forEach((value, key) => {
|
||||||
|
if (key !== 'id') {
|
||||||
|
if (key === 'startChapter' || key === 'endChapter') {
|
||||||
|
updates[key] = value ? parseInt(value as string) : null;
|
||||||
|
} else {
|
||||||
|
updates[key] = value || null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.update(arc).set(updates).where(eq(arc.id, id));
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Arc update error:', error);
|
||||||
|
return fail(500, { error: 'Failed to update arc' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
delete: async ({ request, locals }) => {
|
||||||
|
if (!locals.user?.isAdmin) {
|
||||||
|
return fail(401, { error: 'Unauthorized' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await request.formData();
|
||||||
|
const id = formData.get('id') as string;
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return fail(400, { error: 'Arc ID is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db.delete(arc).where(eq(arc.id, id));
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Arc delete error:', error);
|
||||||
|
return fail(500, { error: 'Failed to delete arc' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
246
src/routes/(admin)/admin/arcs/+page.svelte
Normal file
246
src/routes/(admin)/admin/arcs/+page.svelte
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
data: PageData;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { data }: Props = $props();
|
||||||
|
|
||||||
|
let searchQuery = $state('');
|
||||||
|
let isEditModalOpen = $state(false);
|
||||||
|
let isSaving = $state(false);
|
||||||
|
let saveMessage = $state<{ type: 'success' | 'error'; message: string } | null>(null);
|
||||||
|
let selectedArcId = $state<string | null>(null);
|
||||||
|
|
||||||
|
let editForm = $state<any>({
|
||||||
|
id: '',
|
||||||
|
name: '',
|
||||||
|
startChapter: 1,
|
||||||
|
endChapter: null,
|
||||||
|
url: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredArcs = $derived.by(() => {
|
||||||
|
return data.arcs.filter((arc) => {
|
||||||
|
return arc.name.toLowerCase().includes(searchQuery.toLowerCase());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const openEditModal = (arc: any) => {
|
||||||
|
selectedArcId = arc.id;
|
||||||
|
editForm = { ...arc };
|
||||||
|
isEditModalOpen = true;
|
||||||
|
saveMessage = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
isEditModalOpen = false;
|
||||||
|
selectedArcId = null;
|
||||||
|
editForm = {
|
||||||
|
id: '',
|
||||||
|
name: '',
|
||||||
|
startChapter: 1,
|
||||||
|
endChapter: null,
|
||||||
|
url: ''
|
||||||
|
};
|
||||||
|
saveMessage = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteArc = async (id: string) => {
|
||||||
|
if (!confirm('Are you sure you want to delete this arc?')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('id', id);
|
||||||
|
|
||||||
|
const response = await fetch('?/delete', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
window.location.reload();
|
||||||
|
} else {
|
||||||
|
alert('Failed to delete arc');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert('Error deleting arc: ' + error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Arcs - Admin - OnePieceDle</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h2 class="text-3xl font-bold text-white">Arc Management</h2>
|
||||||
|
<button
|
||||||
|
class="rounded-lg bg-amber-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-amber-700"
|
||||||
|
>
|
||||||
|
+ Add Arc
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filters -->
|
||||||
|
<div class="flex flex-col gap-4 rounded-lg border border-white/10 bg-slate-800/50 p-4 md:flex-row md:items-center">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search arcs..."
|
||||||
|
bind:value={searchQuery}
|
||||||
|
class="flex-1 rounded-lg bg-slate-700 px-4 py-2 text-sm text-white placeholder-gray-400 outline-none transition focus:ring-2 focus:ring-amber-600"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Arcs Table -->
|
||||||
|
<div class="rounded-lg border border-white/10">
|
||||||
|
<div class="max-h-[calc(100vh-20rem)] overflow-auto">
|
||||||
|
<table class="w-full">
|
||||||
|
<thead class="sticky top-0 bg-slate-800 z-10">
|
||||||
|
<tr class="border-b border-white/10">
|
||||||
|
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-300">Name</th>
|
||||||
|
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-300">Start Chapter</th>
|
||||||
|
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-300">End Chapter</th>
|
||||||
|
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-300">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each filteredArcs as arc}
|
||||||
|
<tr class="border-b border-white/5 hover:bg-slate-800/50">
|
||||||
|
<td class="px-6 py-4 text-sm text-white">{arc.name}</td>
|
||||||
|
<td class="px-6 py-4 text-sm text-gray-400">{arc.startChapter}</td>
|
||||||
|
<td class="px-6 py-4 text-sm text-gray-400">{arc.endChapter || '-'}</td>
|
||||||
|
<td class="px-6 py-4 text-sm">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onclick={() => openEditModal(arc)}
|
||||||
|
class="text-amber-400 hover:text-amber-300 transition-colors"
|
||||||
|
title="Edit arc"
|
||||||
|
>
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path></svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={() => handleDeleteArc(arc.id)}
|
||||||
|
class="text-red-400 hover:text-red-300 transition-colors"
|
||||||
|
title="Delete arc"
|
||||||
|
>
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div> </div>
|
||||||
|
{#if filteredArcs.length === 0}
|
||||||
|
<div class="rounded-lg border border-white/10 bg-slate-800/50 py-12 text-center">
|
||||||
|
<p class="text-gray-400">No arcs found</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Edit Modal -->
|
||||||
|
{#if isEditModalOpen}
|
||||||
|
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||||
|
<div class="w-full max-w-md rounded-lg border border-white/10 bg-slate-900 p-6">
|
||||||
|
<h3 class="text-lg font-bold text-white">Edit Arc</h3>
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/update"
|
||||||
|
class="mt-6 space-y-4"
|
||||||
|
use:enhance={() => {
|
||||||
|
isSaving = true;
|
||||||
|
saveMessage = null;
|
||||||
|
return async ({ result }) => {
|
||||||
|
isSaving = false;
|
||||||
|
if (result.type === 'success') {
|
||||||
|
saveMessage = { type: 'success', message: 'Arc updated successfully' };
|
||||||
|
setTimeout(() => {
|
||||||
|
closeModal();
|
||||||
|
window.location.reload();
|
||||||
|
}, 500);
|
||||||
|
} else if (result.type === 'failure') {
|
||||||
|
saveMessage = { type: 'error', message: (result.data as any)?.error || 'Failed to update arc' };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="id" value={editForm.id} />
|
||||||
|
<div>
|
||||||
|
<label for="arc-name" class="block text-sm font-medium text-gray-300">Name</label>
|
||||||
|
<input
|
||||||
|
id="arc-name"
|
||||||
|
type="text"
|
||||||
|
name="name"
|
||||||
|
bind:value={editForm.name}
|
||||||
|
class="mt-1 w-full rounded-lg bg-slate-700 px-4 py-2 text-sm text-white outline-none transition focus:ring-2 focus:ring-amber-600"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label for="arc-start" class="block text-sm font-medium text-gray-300">Start Chapter</label>
|
||||||
|
<input
|
||||||
|
id="arc-start"
|
||||||
|
type="number"
|
||||||
|
name="startChapter"
|
||||||
|
bind:value={editForm.startChapter}
|
||||||
|
class="mt-1 w-full rounded-lg bg-slate-700 px-4 py-2 text-sm text-white outline-none transition focus:ring-2 focus:ring-amber-600"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="arc-end" class="block text-sm font-medium text-gray-300">End Chapter</label>
|
||||||
|
<input
|
||||||
|
id="arc-end"
|
||||||
|
type="number"
|
||||||
|
name="endChapter"
|
||||||
|
bind:value={editForm.endChapter}
|
||||||
|
class="mt-1 w-full rounded-lg bg-slate-700 px-4 py-2 text-sm text-white outline-none transition focus:ring-2 focus:ring-amber-600"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="arc-url" class="block text-sm font-medium text-gray-300">URL</label>
|
||||||
|
<input
|
||||||
|
id="arc-url"
|
||||||
|
type="text"
|
||||||
|
name="url"
|
||||||
|
bind:value={editForm.url}
|
||||||
|
placeholder="https://..."
|
||||||
|
class="mt-1 w-full rounded-lg bg-slate-700 px-4 py-2 text-sm text-white outline-none transition focus:ring-2 focus:ring-amber-600"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{#if saveMessage}
|
||||||
|
<div class={`rounded-lg p-3 text-sm ${
|
||||||
|
saveMessage.type === 'success'
|
||||||
|
? 'bg-green-500/10 text-green-400'
|
||||||
|
: 'bg-red-500/10 text-red-400'
|
||||||
|
}`}>
|
||||||
|
{saveMessage.message}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="flex gap-3 pt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={closeModal}
|
||||||
|
disabled={isSaving}
|
||||||
|
class="flex-1 rounded-lg border border-gray-500 px-4 py-2 text-sm font-medium text-gray-300 transition-colors hover:bg-slate-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSaving}
|
||||||
|
class="flex-1 rounded-lg bg-amber-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-amber-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isSaving ? 'Saving...' : 'Save Changes'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
171
src/routes/(admin)/admin/characters/+page.server.ts
Normal file
171
src/routes/(admin)/admin/characters/+page.server.ts
Normal 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' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
828
src/routes/(admin)/admin/characters/+page.svelte
Normal file
828
src/routes/(admin)/admin/characters/+page.svelte
Normal file
@@ -0,0 +1,828 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import { formatBounty } from '$lib';
|
||||||
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
data: PageData;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { data }: Props = $props();
|
||||||
|
|
||||||
|
let searchQuery = $state('');
|
||||||
|
let filterDaily = $state<'all' | 'daily' | 'not-daily'>('all');
|
||||||
|
let filterStatus = $state('all');
|
||||||
|
let filterGender = $state('all');
|
||||||
|
let filterArc = $state('all');
|
||||||
|
let filterHaki = $state<'all' | 'observation' | 'armament' | 'conqueror' | 'none'>('all');
|
||||||
|
let selectedCharacterId = $state<string | null>(null);
|
||||||
|
let isEditModalOpen = $state(false);
|
||||||
|
let isSaving = $state(false);
|
||||||
|
let saveMessage = $state<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||||
|
let dailyModeToast = $state<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||||
|
let selectedChar = $state<any>(null);
|
||||||
|
let showOriginalValue = $state<Record<string, boolean>>({});
|
||||||
|
|
||||||
|
const showDailyModeToast = (type: 'success' | 'error', text: string) => {
|
||||||
|
dailyModeToast = { type, text };
|
||||||
|
setTimeout(() => {
|
||||||
|
dailyModeToast = null;
|
||||||
|
}, 3000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFandomUrl = (url: string | null | undefined) => {
|
||||||
|
if (!url) return null;
|
||||||
|
if (url.startsWith('http://') || url.startsWith('https://')) return url;
|
||||||
|
return `https://onepiece.fandom.com/fr/wiki/${url}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
let editForm = $state<any>({
|
||||||
|
id: '',
|
||||||
|
name: '',
|
||||||
|
gender: '',
|
||||||
|
age: null,
|
||||||
|
bounty: 0,
|
||||||
|
height: 0,
|
||||||
|
origin: '',
|
||||||
|
affiliations: '',
|
||||||
|
epithets: '',
|
||||||
|
pictureUrl: '',
|
||||||
|
url: '',
|
||||||
|
devilFruitId: null,
|
||||||
|
hakiObservation: false,
|
||||||
|
hakiArmament: false,
|
||||||
|
hakiConqueror: false,
|
||||||
|
firstAppearance: '',
|
||||||
|
arcId: null,
|
||||||
|
status: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
const availableStatuses = $derived.by(() => {
|
||||||
|
const statuses = new Set<string>();
|
||||||
|
for (const char of data.characters) {
|
||||||
|
const status = char.displayValues.status;
|
||||||
|
if (status && String(status).trim() !== '') {
|
||||||
|
statuses.add(String(status));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(statuses).sort((a, b) => a.localeCompare(b));
|
||||||
|
});
|
||||||
|
|
||||||
|
const availableGenders = $derived.by(() => {
|
||||||
|
const genders = new Set<string>();
|
||||||
|
for (const char of data.characters) {
|
||||||
|
const gender = char.displayValues.gender;
|
||||||
|
if (gender && String(gender).trim() !== '') {
|
||||||
|
genders.add(String(gender));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(genders).sort((a, b) => a.localeCompare(b));
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredCharacters = $derived.by(() => {
|
||||||
|
return data.characters.filter((char) => {
|
||||||
|
const normalizedQuery = searchQuery.toLowerCase().trim();
|
||||||
|
let epithetsText = '';
|
||||||
|
|
||||||
|
if (char.displayValues.epithets) {
|
||||||
|
if (typeof char.displayValues.epithets === 'string') {
|
||||||
|
if (char.displayValues.epithets.includes('[')) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(char.displayValues.epithets);
|
||||||
|
epithetsText = Array.isArray(parsed) ? parsed.join(' ') : String(parsed);
|
||||||
|
} catch {
|
||||||
|
epithetsText = char.displayValues.epithets;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
epithetsText = char.displayValues.epithets;
|
||||||
|
}
|
||||||
|
} else if (Array.isArray(char.displayValues.epithets)) {
|
||||||
|
epithetsText = char.displayValues.epithets.join(' ');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchesSearch =
|
||||||
|
normalizedQuery === '' ||
|
||||||
|
char.displayValues.name.toLowerCase().includes(normalizedQuery) ||
|
||||||
|
epithetsText.toLowerCase().includes(normalizedQuery);
|
||||||
|
const matchesDaily =
|
||||||
|
filterDaily === 'all' ||
|
||||||
|
(filterDaily === 'daily' && char.displayValues.isInDailyMode) ||
|
||||||
|
(filterDaily === 'not-daily' && !char.displayValues.isInDailyMode);
|
||||||
|
const matchesStatus = filterStatus === 'all' || (char.displayValues.status || '') === filterStatus;
|
||||||
|
const matchesGender = filterGender === 'all' || (char.displayValues.gender || '') === filterGender;
|
||||||
|
const matchesArc =
|
||||||
|
filterArc === 'all' ||
|
||||||
|
String(char.displayValues.arcId ?? '') === filterArc;
|
||||||
|
const matchesHaki =
|
||||||
|
filterHaki === 'all' ||
|
||||||
|
(filterHaki === 'observation' && !!char.displayValues.hakiObservation) ||
|
||||||
|
(filterHaki === 'armament' && !!char.displayValues.hakiArmament) ||
|
||||||
|
(filterHaki === 'conqueror' && !!char.displayValues.hakiConqueror) ||
|
||||||
|
(filterHaki === 'none' && !char.displayValues.hakiObservation && !char.displayValues.hakiArmament && !char.displayValues.hakiConqueror);
|
||||||
|
|
||||||
|
return matchesSearch && matchesDaily && matchesStatus && matchesGender && matchesArc && matchesHaki;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const isFieldOverridden = (char: any, field: string) => {
|
||||||
|
return char.override && char.override[field] !== null && char.override[field] !== undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEditModal = (char: any) => {
|
||||||
|
selectedCharacterId = char.id;
|
||||||
|
selectedChar = char;
|
||||||
|
|
||||||
|
const override = char.override || {};
|
||||||
|
|
||||||
|
editForm = {
|
||||||
|
id: char.id,
|
||||||
|
name: override.name ?? '',
|
||||||
|
gender: override.gender ?? '',
|
||||||
|
age: override.age ?? null,
|
||||||
|
bounty: override.bounty ?? null,
|
||||||
|
height: override.height ?? null,
|
||||||
|
origin: override.origin ?? '',
|
||||||
|
affiliations: override.affiliations ?? '',
|
||||||
|
epithets: override.epithets ?? '',
|
||||||
|
pictureUrl: override.pictureUrl ?? '',
|
||||||
|
url: override.url ?? '',
|
||||||
|
devilFruitId: override.devilFruitId !== null && override.devilFruitId !== undefined ? override.devilFruitId : '',
|
||||||
|
hakiObservation: override.hakiObservation ?? 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>
|
||||||
62
src/routes/(admin)/admin/config/+page.server.ts
Normal file
62
src/routes/(admin)/admin/config/+page.server.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { config } from '$lib/server/db/schema';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { fail } from '@sveltejs/kit';
|
||||||
|
import type { PageServerLoad, Actions } from './$types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async () => {
|
||||||
|
const configEntries = await db.select().from(config);
|
||||||
|
|
||||||
|
return {
|
||||||
|
config: configEntries
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
update: async ({ request, locals }) => {
|
||||||
|
if (!locals.user?.isAdmin) {
|
||||||
|
return fail(401, { error: 'Unauthorized' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await request.formData();
|
||||||
|
const key = formData.get('key') as string;
|
||||||
|
const value = formData.get('value') as string;
|
||||||
|
|
||||||
|
if (!key) {
|
||||||
|
return fail(400, { error: 'Config key is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db
|
||||||
|
.insert(config)
|
||||||
|
.values({ key, value })
|
||||||
|
.onConflictDoUpdate({ target: config.key, set: { value } });
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Config update error:', error);
|
||||||
|
return fail(500, { error: 'Failed to update configuration' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
delete: async ({ request, locals }) => {
|
||||||
|
if (!locals.user?.isAdmin) {
|
||||||
|
return fail(401, { error: 'Unauthorized' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await request.formData();
|
||||||
|
const key = formData.get('key') as string;
|
||||||
|
|
||||||
|
if (!key) {
|
||||||
|
return fail(400, { error: 'Config key is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db.delete(config).where(eq(config.key, key));
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Config delete error:', error);
|
||||||
|
return fail(500, { error: 'Failed to delete configuration' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
266
src/routes/(admin)/admin/config/+page.svelte
Normal file
266
src/routes/(admin)/admin/config/+page.svelte
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
data: PageData;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConfigItem {
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { data }: Props = $props();
|
||||||
|
|
||||||
|
let configItems = $state<ConfigItem[]>([]);
|
||||||
|
let newKey = $state('');
|
||||||
|
let newValue = $state('');
|
||||||
|
let editingKey = $state<string | null>(null);
|
||||||
|
let editingValue = $state('');
|
||||||
|
let isSaving = $state(false);
|
||||||
|
let saveMessage = $state<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
configItems = data.config.map((item) => ({
|
||||||
|
key: item.key,
|
||||||
|
value: item.value ?? ''
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
const startEdit = (item: ConfigItem) => {
|
||||||
|
editingKey = item.key;
|
||||||
|
editingValue = item.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelEdit = () => {
|
||||||
|
editingKey = null;
|
||||||
|
editingValue = '';
|
||||||
|
saveMessage = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddNew = async () => {
|
||||||
|
if (!newKey || !newValue) {
|
||||||
|
saveMessage = { type: 'error', text: 'Both key and value are required' };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (configItems.some((item) => item.key === newKey)) {
|
||||||
|
saveMessage = { type: 'error', text: 'A config with this key already exists' };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isSaving = true;
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('key', newKey);
|
||||||
|
formData.append('value', newValue);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('?/update', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
configItems = [...configItems, { key: newKey, value: newValue }];
|
||||||
|
newKey = '';
|
||||||
|
newValue = '';
|
||||||
|
saveMessage = { type: 'success', text: 'Config added successfully' };
|
||||||
|
} else {
|
||||||
|
saveMessage = { type: 'error', text: 'Failed to add config' };
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
saveMessage = { type: 'error', text: 'Error adding config' };
|
||||||
|
} finally {
|
||||||
|
isSaving = false;
|
||||||
|
setTimeout(() => {
|
||||||
|
saveMessage = null;
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (key: string) => {
|
||||||
|
if (!confirm(`Are you sure you want to delete "${key}"?`)) return;
|
||||||
|
|
||||||
|
isSaving = true;
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('key', key);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('?/delete', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
configItems = configItems.filter((item) => item.key !== key);
|
||||||
|
saveMessage = { type: 'success', text: 'Config deleted successfully' };
|
||||||
|
} else {
|
||||||
|
saveMessage = { type: 'error', text: 'Failed to delete config' };
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
saveMessage = { type: 'error', text: 'Error deleting config' };
|
||||||
|
} finally {
|
||||||
|
isSaving = false;
|
||||||
|
setTimeout(() => {
|
||||||
|
saveMessage = null;
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Settings - Admin - OnePieceDle</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Header -->
|
||||||
|
<h2 class="text-3xl font-bold text-white">Configuration</h2>
|
||||||
|
|
||||||
|
<!-- Add New Config -->
|
||||||
|
<div class="rounded-lg border border-white/10 bg-slate-800/50 p-6">
|
||||||
|
<h3 class="mb-4 text-lg font-semibold text-white">Add New Configuration</h3>
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Key name"
|
||||||
|
bind:value={newKey}
|
||||||
|
class="flex-1 rounded-lg bg-slate-700 px-4 py-2 text-sm text-white placeholder-gray-400 outline-none transition focus:ring-2 focus:ring-amber-600"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Value"
|
||||||
|
bind:value={newValue}
|
||||||
|
class="flex-1 rounded-lg bg-slate-700 px-4 py-2 text-sm text-white placeholder-gray-400 outline-none transition focus:ring-2 focus:ring-amber-600"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onclick={handleAddNew}
|
||||||
|
disabled={isSaving}
|
||||||
|
class="rounded-lg bg-amber-600 px-6 py-2 text-sm font-medium text-white transition-colors hover:bg-amber-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Config Table -->
|
||||||
|
<div class="rounded-lg border border-white/10">
|
||||||
|
<div class="max-h-[calc(100vh-20rem)] overflow-auto">
|
||||||
|
<table class="w-full">
|
||||||
|
<thead class="sticky top-0 bg-slate-800 z-10">
|
||||||
|
<tr class="border-b border-white/10">
|
||||||
|
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-300">Key</th>
|
||||||
|
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-300">Value</th>
|
||||||
|
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-300">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each configItems as item}
|
||||||
|
{#if editingKey === item.key}
|
||||||
|
<tr class="border-b border-white/5 bg-slate-800/50">
|
||||||
|
<td class="px-6 py-4 text-sm text-white">{item.key}</td>
|
||||||
|
<td class="px-6 py-4 text-sm">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={editingValue}
|
||||||
|
class="w-full rounded-lg bg-slate-700 px-3 py-1 text-sm text-white outline-none transition focus:ring-2 focus:ring-amber-600"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 text-sm">
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/update"
|
||||||
|
use:enhance={() => {
|
||||||
|
isSaving = true;
|
||||||
|
return async ({ result }) => {
|
||||||
|
isSaving = false;
|
||||||
|
if (result.type === 'success') {
|
||||||
|
const idx = configItems.findIndex((i) => i.key === item.key);
|
||||||
|
if (idx !== -1) {
|
||||||
|
configItems[idx].value = editingValue;
|
||||||
|
}
|
||||||
|
editingKey = null;
|
||||||
|
saveMessage = { type: 'success', text: 'Config updated' };
|
||||||
|
} else if (result.type === 'failure') {
|
||||||
|
saveMessage = { type: 'error', text: (result.data?.error as string) || 'Failed to update' };
|
||||||
|
} else {
|
||||||
|
saveMessage = { type: 'error', text: 'Failed to update' };
|
||||||
|
}
|
||||||
|
setTimeout(() => {
|
||||||
|
saveMessage = null;
|
||||||
|
}, 3000);
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="key" value={item.key} />
|
||||||
|
<input type="hidden" name="value" value={editingValue} />
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSaving}
|
||||||
|
class="rounded bg-green-600 px-3 py-1 text-xs font-medium text-white hover:bg-green-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={cancelEdit}
|
||||||
|
disabled={isSaving}
|
||||||
|
class="rounded bg-gray-600 px-3 py-1 text-xs font-medium text-white hover:bg-gray-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{:else}
|
||||||
|
<tr class="border-b border-white/5 hover:bg-slate-800/50">
|
||||||
|
<td class="px-6 py-4 text-sm font-medium text-white">{item.key}</td>
|
||||||
|
<td class="px-6 py-4 text-sm text-gray-400">
|
||||||
|
<code class="rounded bg-slate-800/50 px-2 py-1">{item.value}</code>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 text-sm">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onclick={() => startEdit(item)}
|
||||||
|
class="text-amber-400 hover:text-amber-300 transition-colors"
|
||||||
|
title="Edit config"
|
||||||
|
>
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path></svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={() => handleDelete(item.key)}
|
||||||
|
disabled={isSaving}
|
||||||
|
class="text-red-400 hover:text-red-300 transition-colors disabled:opacity-50"
|
||||||
|
title="Delete config"
|
||||||
|
>
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table> </div> </div>
|
||||||
|
|
||||||
|
{#if configItems.length === 0}
|
||||||
|
<div class="rounded-lg border border-white/10 bg-slate-800/50 py-12 text-center">
|
||||||
|
<p class="text-gray-400">No configuration entries yet</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Save Message -->
|
||||||
|
{#if saveMessage}
|
||||||
|
<div
|
||||||
|
class={`rounded-lg p-4 text-sm font-medium ${
|
||||||
|
saveMessage.type === 'success'
|
||||||
|
? 'border border-green-500/50 bg-green-500/10 text-green-300'
|
||||||
|
: 'border border-red-500/50 bg-red-500/10 text-red-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{saveMessage.text}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
65
src/routes/(admin)/admin/devil-fruits/+page.server.ts
Normal file
65
src/routes/(admin)/admin/devil-fruits/+page.server.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { devilFruit } from '$lib/server/db/schema';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { fail } from '@sveltejs/kit';
|
||||||
|
import type { PageServerLoad, Actions } from './$types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async () => {
|
||||||
|
const devilFruits = await db.select().from(devilFruit).orderBy(devilFruit.name);
|
||||||
|
|
||||||
|
return {
|
||||||
|
devilFruits
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
update: async ({ request, locals }) => {
|
||||||
|
if (!locals.user?.isAdmin) {
|
||||||
|
return fail(401, { error: 'Unauthorized' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await request.formData();
|
||||||
|
const id = formData.get('id') as string;
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return fail(400, { error: 'Devil Fruit ID is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updates: Record<string, any> = {};
|
||||||
|
formData.forEach((value, key) => {
|
||||||
|
if (key !== 'id') {
|
||||||
|
updates[key] = value || null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.update(devilFruit).set(updates).where(eq(devilFruit.id, id));
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Devil Fruit update error:', error);
|
||||||
|
return fail(500, { error: 'Failed to update devil fruit' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
delete: async ({ request, locals }) => {
|
||||||
|
if (!locals.user?.isAdmin) {
|
||||||
|
return fail(401, { error: 'Unauthorized' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await request.formData();
|
||||||
|
const id = formData.get('id') as string;
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return fail(400, { error: 'Devil Fruit ID is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db.delete(devilFruit).where(eq(devilFruit.id, id));
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Devil Fruit delete error:', error);
|
||||||
|
return fail(500, { error: 'Failed to delete devil fruit' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
284
src/routes/(admin)/admin/devil-fruits/+page.svelte
Normal file
284
src/routes/(admin)/admin/devil-fruits/+page.svelte
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
data: PageData;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { data }: Props = $props();
|
||||||
|
|
||||||
|
let searchQuery = $state('');
|
||||||
|
let filterType = $state<'all' | 'Paramecia' | 'Zoan' | 'Logia' | 'Unknown'>('all');
|
||||||
|
let isEditModalOpen = $state(false);
|
||||||
|
let selectedFruitId = $state<string | null>(null);
|
||||||
|
let isSaving = $state(false);
|
||||||
|
let saveMessage = $state<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||||
|
|
||||||
|
const fruitTypes = ['Paramecia', 'Zoan', 'Logia', 'Unknown'] as const;
|
||||||
|
|
||||||
|
let editForm = $state<any>({
|
||||||
|
id: '',
|
||||||
|
name: '',
|
||||||
|
type: 'Paramecia',
|
||||||
|
url: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredFruits = $derived.by(() => {
|
||||||
|
return data.devilFruits.filter((fruit) => {
|
||||||
|
const matchesSearch = fruit.name.toLowerCase().includes(searchQuery.toLowerCase());
|
||||||
|
const matchesFilter = filterType === 'all' || fruit.type === filterType;
|
||||||
|
return matchesSearch && matchesFilter;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const openEditModal = (fruit: any) => {
|
||||||
|
selectedFruitId = fruit.id;
|
||||||
|
editForm = { ...fruit };
|
||||||
|
isEditModalOpen = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
isEditModalOpen = false;
|
||||||
|
selectedFruitId = null;
|
||||||
|
editForm = {
|
||||||
|
id: '',
|
||||||
|
name: '',
|
||||||
|
type: 'Paramecia',
|
||||||
|
url: ''
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTypeColor = (type: string) => {
|
||||||
|
switch (type) {
|
||||||
|
case 'Paramecia':
|
||||||
|
return 'bg-blue-500/20 text-blue-300';
|
||||||
|
case 'Zoan':
|
||||||
|
return 'bg-green-500/20 text-green-300';
|
||||||
|
case 'Logia':
|
||||||
|
return 'bg-red-500/20 text-red-300';
|
||||||
|
default:
|
||||||
|
return 'bg-gray-500/20 text-gray-300';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteFruit = async (id: string) => {
|
||||||
|
if (!confirm('Are you sure you want to delete this devil fruit?')) return;
|
||||||
|
|
||||||
|
isSaving = true;
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('id', id);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('?/delete', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
location.reload();
|
||||||
|
} else {
|
||||||
|
const error = await response.json();
|
||||||
|
saveMessage = {
|
||||||
|
type: 'error',
|
||||||
|
text: error.error || 'Failed to delete devil fruit'
|
||||||
|
};
|
||||||
|
setTimeout(() => {
|
||||||
|
saveMessage = null;
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
saveMessage = {
|
||||||
|
type: 'error',
|
||||||
|
text: 'Error deleting devil fruit'
|
||||||
|
};
|
||||||
|
setTimeout(() => {
|
||||||
|
saveMessage = null;
|
||||||
|
}, 3000);
|
||||||
|
} finally {
|
||||||
|
isSaving = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Devil Fruits - Admin - OnePieceDle</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h2 class="text-3xl font-bold text-white">Devil Fruit Management</h2>
|
||||||
|
<button
|
||||||
|
class="rounded-lg bg-amber-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-amber-700"
|
||||||
|
>
|
||||||
|
+ Add Devil Fruit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filters -->
|
||||||
|
<div class="flex flex-col gap-4 rounded-lg border border-white/10 bg-slate-800/50 p-4 md:flex-row md:items-center">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search devil fruits..."
|
||||||
|
bind:value={searchQuery}
|
||||||
|
class="flex-1 rounded-lg bg-slate-700 px-4 py-2 text-sm text-white placeholder-gray-400 outline-none transition focus:ring-2 focus:ring-amber-600"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
bind:value={filterType}
|
||||||
|
class="rounded-lg bg-slate-700 px-4 py-2 text-sm text-white outline-none transition focus:ring-2 focus:ring-amber-600"
|
||||||
|
>
|
||||||
|
<option value="all">All Types</option>
|
||||||
|
<option value="Paramecia">Paramecia</option>
|
||||||
|
<option value="Zoan">Zoan</option>
|
||||||
|
<option value="Logia">Logia</option>
|
||||||
|
<option value="Unknown">Unknown</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Devil Fruits Table -->
|
||||||
|
<div class="rounded-lg border border-white/10">
|
||||||
|
<div class="max-h-[calc(100vh-20rem)] overflow-auto">
|
||||||
|
<table class="w-full">
|
||||||
|
<thead class="sticky top-0 bg-slate-800 z-10">
|
||||||
|
<tr class="border-b border-white/10">
|
||||||
|
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-300">Name</th>
|
||||||
|
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-300">Type</th>
|
||||||
|
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-300">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each filteredFruits as fruit}
|
||||||
|
<tr class="border-b border-white/5 hover:bg-slate-800/50">
|
||||||
|
<td class="px-6 py-4 text-sm text-white">{fruit.name}</td>
|
||||||
|
<td class="px-6 py-4 text-sm">
|
||||||
|
<span class={`inline-block rounded-full px-2 py-1 text-xs ${getTypeColor(fruit.type || 'Unknown')}`}>
|
||||||
|
{fruit.type || 'Unknown'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 text-sm">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onclick={() => openEditModal(fruit)}
|
||||||
|
class="text-amber-400 hover:text-amber-300 transition-colors"
|
||||||
|
title="Edit devil fruit"
|
||||||
|
>
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path></svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={() => handleDeleteFruit(fruit.id)}
|
||||||
|
class="text-red-400 hover:text-red-300 transition-colors"
|
||||||
|
title="Delete devil fruit"
|
||||||
|
>
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div> </div>
|
||||||
|
{#if filteredFruits.length === 0}
|
||||||
|
<div class="rounded-lg border border-white/10 bg-slate-800/50 py-12 text-center">
|
||||||
|
<p class="text-gray-400">No devil fruits found</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Edit Modal -->
|
||||||
|
{#if isEditModalOpen}
|
||||||
|
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||||
|
<div class="w-full max-w-md rounded-lg border border-white/10 bg-slate-900 p-6">
|
||||||
|
<h3 class="text-lg font-bold text-white">Edit Devil Fruit</h3>
|
||||||
|
<form
|
||||||
|
class="mt-6 space-y-4"
|
||||||
|
method="POST"
|
||||||
|
action="?/update"
|
||||||
|
use:enhance={() => {
|
||||||
|
isSaving = true;
|
||||||
|
return async ({ result }) => {
|
||||||
|
isSaving = false;
|
||||||
|
if (result.type === 'success') {
|
||||||
|
saveMessage = { type: 'success', text: 'Devil Fruit saved successfully!' };
|
||||||
|
setTimeout(() => {
|
||||||
|
location.reload();
|
||||||
|
}, 1000);
|
||||||
|
} else if (result.type === 'failure') {
|
||||||
|
saveMessage = { type: 'error', text: (result.data as any)?.error || 'Failed to save devil fruit' };
|
||||||
|
}
|
||||||
|
setTimeout(() => {
|
||||||
|
saveMessage = null;
|
||||||
|
}, 3000);
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="id" value={editForm.id} />
|
||||||
|
<div>
|
||||||
|
<label for="fruit-name" class="block text-sm font-medium text-gray-300">Name</label>
|
||||||
|
<input
|
||||||
|
id="fruit-name"
|
||||||
|
type="text"
|
||||||
|
name="name"
|
||||||
|
bind:value={editForm.name}
|
||||||
|
class="mt-1 w-full rounded-lg bg-slate-700 px-4 py-2 text-sm text-white outline-none transition focus:ring-2 focus:ring-amber-600"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="fruit-type" class="block text-sm font-medium text-gray-300">Type</label>
|
||||||
|
<select
|
||||||
|
id="fruit-type"
|
||||||
|
name="type"
|
||||||
|
bind:value={editForm.type}
|
||||||
|
class="mt-1 w-full rounded-lg bg-slate-700 px-4 py-2 text-sm text-white outline-none transition focus:ring-2 focus:ring-amber-600"
|
||||||
|
>
|
||||||
|
{#each fruitTypes as type}
|
||||||
|
<option value={type}>{type}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="fruit-url" class="block text-sm font-medium text-gray-300">URL</label>
|
||||||
|
<input
|
||||||
|
id="fruit-url"
|
||||||
|
type="text"
|
||||||
|
name="url"
|
||||||
|
bind:value={editForm.url}
|
||||||
|
placeholder="https://..."
|
||||||
|
class="mt-1 w-full rounded-lg bg-slate-700 px-4 py-2 text-sm text-white outline-none transition focus:ring-2 focus:ring-amber-600"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-3 pt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={closeModal}
|
||||||
|
disabled={isSaving}
|
||||||
|
class="flex-1 rounded-lg border border-gray-500 px-4 py-2 text-sm font-medium text-gray-300 transition-colors hover:bg-slate-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSaving}
|
||||||
|
class="flex-1 rounded-lg bg-amber-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-amber-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isSaving ? 'Saving...' : 'Save Changes'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{#if saveMessage}
|
||||||
|
<div
|
||||||
|
class={`mt-4 rounded-lg p-3 text-sm font-medium ${
|
||||||
|
saveMessage.type === 'success'
|
||||||
|
? 'border border-green-500/50 bg-green-500/10 text-green-300'
|
||||||
|
: 'border border-red-500/50 bg-red-500/10 text-red-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{saveMessage.text}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
67
src/routes/(admin)/admin/users/+page.server.ts
Normal file
67
src/routes/(admin)/admin/users/+page.server.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { user } from '$lib/server/db/schema';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { fail } from '@sveltejs/kit';
|
||||||
|
import type { PageServerLoad, Actions } from './$types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async () => {
|
||||||
|
const users = await db.select().from(user).orderBy(user.createdAt);
|
||||||
|
|
||||||
|
return {
|
||||||
|
users: users.map((u) => ({
|
||||||
|
...u,
|
||||||
|
createdAt: new Date(u.createdAt).toLocaleDateString(),
|
||||||
|
updatedAt: new Date(u.updatedAt).toLocaleDateString()
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
update: async ({ request, locals }) => {
|
||||||
|
if (!locals.user?.isAdmin) {
|
||||||
|
return fail(401, { error: 'Unauthorized' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await request.formData();
|
||||||
|
const id = formData.get('id') as string;
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return fail(400, { error: 'User ID is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updates: Record<string, any> = {
|
||||||
|
name: formData.get('name') as string,
|
||||||
|
email: formData.get('email') as string,
|
||||||
|
isAdmin: formData.has('isAdmin'),
|
||||||
|
emailVerified: formData.has('emailVerified')
|
||||||
|
};
|
||||||
|
|
||||||
|
await db.update(user).set(updates).where(eq(user.id, id));
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('User update error:', error);
|
||||||
|
return fail(500, { error: 'Failed to update user' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
delete: async ({ request, locals }) => {
|
||||||
|
if (!locals.user?.isAdmin) {
|
||||||
|
return fail(401, { error: 'Unauthorized' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await request.formData();
|
||||||
|
const id = formData.get('id') as string;
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return fail(400, { error: 'User ID is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db.delete(user).where(eq(user.id, id));
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('User delete error:', error);
|
||||||
|
return fail(500, { error: 'Failed to delete user' });
|
||||||
|
}
|
||||||
|
}};
|
||||||
278
src/routes/(admin)/admin/users/+page.svelte
Normal file
278
src/routes/(admin)/admin/users/+page.svelte
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
data: PageData;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { data }: Props = $props();
|
||||||
|
|
||||||
|
let searchQuery = $state('');
|
||||||
|
let filterRole = $state<'all' | 'admin' | 'user'>('all');
|
||||||
|
let isEditModalOpen = $state(false);
|
||||||
|
let isSaving = $state(false);
|
||||||
|
let saveMessage = $state<{ type: 'success' | 'error'; message: string } | null>(null);
|
||||||
|
let selectedUserId = $state<string | null>(null);
|
||||||
|
|
||||||
|
let editForm = $state<any>({
|
||||||
|
id: '',
|
||||||
|
name: '',
|
||||||
|
email: '',
|
||||||
|
isAdmin: false,
|
||||||
|
emailVerified: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredUsers = $derived.by(() => {
|
||||||
|
return data.users.filter((usr) => {
|
||||||
|
const matchesSearch =
|
||||||
|
usr.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
usr.email.toLowerCase().includes(searchQuery.toLowerCase());
|
||||||
|
const matchesFilter =
|
||||||
|
filterRole === 'all' || (filterRole === 'admin' && usr.isAdmin) || (filterRole === 'user' && !usr.isAdmin);
|
||||||
|
return matchesSearch && matchesFilter;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const openEditModal = (usr: any) => {
|
||||||
|
selectedUserId = usr.id;
|
||||||
|
editForm = { ...usr };
|
||||||
|
isEditModalOpen = true;
|
||||||
|
saveMessage = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
isEditModalOpen = false;
|
||||||
|
selectedUserId = null;
|
||||||
|
editForm = {
|
||||||
|
id: '',
|
||||||
|
name: '',
|
||||||
|
email: '',
|
||||||
|
isAdmin: false,
|
||||||
|
emailVerified: false
|
||||||
|
};
|
||||||
|
saveMessage = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteUser = async (id: string) => {
|
||||||
|
if (!confirm('Are you sure you want to delete this user?')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('id', id);
|
||||||
|
|
||||||
|
const response = await fetch('?/delete', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
window.location.reload();
|
||||||
|
} else {
|
||||||
|
alert('Failed to delete user');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert('Error deleting user: ' + error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Users - Admin - OnePieceDle</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h2 class="text-3xl font-bold text-white">User Management</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filters -->
|
||||||
|
<div class="flex flex-col gap-4 rounded-lg border border-white/10 bg-slate-800/50 p-4 md:flex-row md:items-center">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search users by name or email..."
|
||||||
|
bind:value={searchQuery}
|
||||||
|
class="flex-1 rounded-lg bg-slate-700 px-4 py-2 text-sm text-white placeholder-gray-400 outline-none transition focus:ring-2 focus:ring-amber-600"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
bind:value={filterRole}
|
||||||
|
class="rounded-lg bg-slate-700 px-4 py-2 text-sm text-white outline-none transition focus:ring-2 focus:ring-amber-600"
|
||||||
|
>
|
||||||
|
<option value="all">All Roles</option>
|
||||||
|
<option value="admin">Admin</option>
|
||||||
|
<option value="user">User</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Users Table -->
|
||||||
|
<div class="rounded-lg border border-white/10">
|
||||||
|
<div class="max-h-[calc(100vh-20rem)] overflow-auto">
|
||||||
|
<table class="w-full">
|
||||||
|
<thead class="sticky top-0 bg-slate-800 z-10">
|
||||||
|
<tr class="border-b border-white/10">
|
||||||
|
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-300">Name</th>
|
||||||
|
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-300">Email</th>
|
||||||
|
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-300">Role</th>
|
||||||
|
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-300">Verified</th>
|
||||||
|
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-300">Joined</th>
|
||||||
|
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-300">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each filteredUsers as usr}
|
||||||
|
<tr class="border-b border-white/5 hover:bg-slate-800/50">
|
||||||
|
<td class="px-6 py-4 text-sm text-white">{usr.name}</td>
|
||||||
|
<td class="px-6 py-4 text-sm text-gray-400">{usr.email}</td>
|
||||||
|
<td class="px-6 py-4 text-sm">
|
||||||
|
{#if usr.isAdmin}
|
||||||
|
<span class="inline-block rounded-full bg-amber-500/20 px-2 py-1 text-xs text-amber-300">
|
||||||
|
Admin
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
<span class="inline-block rounded-full bg-blue-500/20 px-2 py-1 text-xs text-blue-300">
|
||||||
|
User
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 text-sm">
|
||||||
|
{#if usr.emailVerified}
|
||||||
|
<span class="inline-block rounded-full bg-green-500/20 px-2 py-1 text-xs text-green-300">
|
||||||
|
✓
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
<span class="inline-block rounded-full bg-red-500/20 px-2 py-1 text-xs text-red-300">
|
||||||
|
✗
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 text-sm text-gray-400">{usr.createdAt}</td>
|
||||||
|
<td class="px-6 py-4 text-sm">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onclick={() => openEditModal(usr)}
|
||||||
|
class="text-amber-400 hover:text-amber-300 transition-colors"
|
||||||
|
title="Edit user"
|
||||||
|
>
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path></svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={() => handleDeleteUser(usr.id)}
|
||||||
|
class="text-red-400 hover:text-red-300 transition-colors"
|
||||||
|
title="Delete user"
|
||||||
|
>
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if filteredUsers.length === 0}
|
||||||
|
<div class="rounded-lg border border-white/10 bg-slate-800/50 py-12 text-center">
|
||||||
|
<p class="text-gray-400">No users found</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Edit Modal -->
|
||||||
|
{#if isEditModalOpen}
|
||||||
|
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||||
|
<div class="w-full max-w-md rounded-lg border border-white/10 bg-slate-900 p-6">
|
||||||
|
<h3 class="text-lg font-bold text-white">Edit User</h3>
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/update"
|
||||||
|
class="mt-6 space-y-4"
|
||||||
|
use:enhance={() => {
|
||||||
|
isSaving = true;
|
||||||
|
saveMessage = null;
|
||||||
|
return async ({ result }) => {
|
||||||
|
isSaving = false;
|
||||||
|
if (result.type === 'success') {
|
||||||
|
saveMessage = { type: 'success', message: 'User updated successfully' };
|
||||||
|
setTimeout(() => {
|
||||||
|
closeModal();
|
||||||
|
window.location.reload();
|
||||||
|
}, 500);
|
||||||
|
} else if (result.type === 'failure') {
|
||||||
|
saveMessage = { type: 'error', message: String(result.data?.error) || 'Failed to update user' };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="id" value={editForm.id} />
|
||||||
|
<div>
|
||||||
|
<label for="user-name" class="block text-sm font-medium text-gray-300">Name</label>
|
||||||
|
<input
|
||||||
|
id="user-name"
|
||||||
|
type="text"
|
||||||
|
name="name"
|
||||||
|
bind:value={editForm.name}
|
||||||
|
class="mt-1 w-full rounded-lg bg-slate-700 px-4 py-2 text-sm text-white outline-none transition focus:ring-2 focus:ring-amber-600"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="user-email" class="block text-sm font-medium text-gray-300">Email</label>
|
||||||
|
<input
|
||||||
|
id="user-email"
|
||||||
|
type="email"
|
||||||
|
name="email"
|
||||||
|
bind:value={editForm.email}
|
||||||
|
class="mt-1 w-full rounded-lg bg-slate-700 px-4 py-2 text-sm text-white outline-none transition focus:ring-2 focus:ring-amber-600"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="admin-role"
|
||||||
|
name="isAdmin"
|
||||||
|
bind:checked={editForm.isAdmin}
|
||||||
|
class="rounded bg-slate-700"
|
||||||
|
/>
|
||||||
|
<label for="admin-role" class="text-sm font-medium text-gray-300">Admin Role</label>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="verified"
|
||||||
|
name="emailVerified"
|
||||||
|
bind:checked={editForm.emailVerified}
|
||||||
|
class="rounded bg-slate-700"
|
||||||
|
/>
|
||||||
|
<label for="verified" class="text-sm font-medium text-gray-300">Email Verified</label>
|
||||||
|
</div>
|
||||||
|
{#if saveMessage}
|
||||||
|
<div class={`rounded-lg p-3 text-sm ${
|
||||||
|
saveMessage.type === 'success'
|
||||||
|
? 'bg-green-500/10 text-green-400'
|
||||||
|
: 'bg-red-500/10 text-red-400'
|
||||||
|
}`}>
|
||||||
|
{saveMessage.message}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="flex gap-3 pt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={closeModal}
|
||||||
|
disabled={isSaving}
|
||||||
|
class="flex-1 rounded-lg border border-gray-500 px-4 py-2 text-sm font-medium text-gray-300 transition-colors hover:bg-slate-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSaving}
|
||||||
|
class="flex-1 rounded-lg bg-amber-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-amber-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isSaving ? 'Saving...' : 'Save Changes'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
19
src/routes/(game)/+layout.svelte
Normal file
19
src/routes/(game)/+layout.svelte
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import ProfileButton from '$lib/components/ProfileButton.svelte';
|
||||||
|
|
||||||
|
let { children, data } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="min-h-screen bg-slate-950">
|
||||||
|
<header class="fixed top-0 right-0 left-0 z-50 border-b border-white/5 bg-slate-950/95 backdrop-blur">
|
||||||
|
<div class="mx-auto flex max-w-7xl items-center justify-between px-6 py-4">
|
||||||
|
<a href="/" class="text-lg font-black uppercase tracking-[0.15em] text-amber-50 transition hover:text-amber-100">
|
||||||
|
OnePieceDle
|
||||||
|
</a>
|
||||||
|
<ProfileButton user={data.user} />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main class="pt-20">
|
||||||
|
{@render children()}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
@@ -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;
|
||||||
|
|
||||||
@@ -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>
|
||||||
@@ -1,23 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import './layout.css';
|
import './layout.css';
|
||||||
import favicon from '$lib/assets/favicon.svg';
|
import favicon from '$lib/assets/favicon.svg';
|
||||||
import ProfileButton from '$lib/components/ProfileButton.svelte';
|
|
||||||
|
let { children } = $props();
|
||||||
let { children, data } = $props();
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head><link rel="icon" href={favicon} /></svelte:head>
|
<svelte:head><link rel="icon" href={favicon} /></svelte:head>
|
||||||
|
|
||||||
<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">
|
{@render children()}
|
||||||
<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>
|
|
||||||
Reference in New Issue
Block a user