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:
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>
|
||||
Reference in New Issue
Block a user