267 lines
8.3 KiB
TypeScript
267 lines
8.3 KiB
TypeScript
'use client';
|
|
|
|
import { useCallback, useEffect, useState } from 'react';
|
|
|
|
import Link from 'next/link';
|
|
|
|
import {
|
|
btn,
|
|
btnDanger,
|
|
card,
|
|
error as errorClass,
|
|
formWide,
|
|
hr,
|
|
input,
|
|
label,
|
|
metaLine,
|
|
rowActions,
|
|
sectionBlock,
|
|
sectionTitle,
|
|
textarea,
|
|
title,
|
|
} from '@/lib/adminUi';
|
|
|
|
type Char = { id: number; name_id: string; name: string; name_jp: string };
|
|
type PlayerRow = {
|
|
id: number;
|
|
player_key: string;
|
|
player_name: string;
|
|
description: string | null;
|
|
image: string | null;
|
|
character_id: string | null;
|
|
name_id?: string;
|
|
name_jp?: string;
|
|
};
|
|
|
|
const field = `${input} ${textarea}`;
|
|
|
|
export function PlayersAdminClient() {
|
|
const [players, setPlayers] = useState<PlayerRow[]>([]);
|
|
const [characters, setCharacters] = useState<Char[]>([]);
|
|
const [error, setError] = useState('');
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
const [playerKey, setPlayerKey] = useState('');
|
|
const [playerName, setPlayerName] = useState('');
|
|
const [description, setDescription] = useState('');
|
|
const [image, setImage] = useState('');
|
|
const [characterId, setCharacterId] = useState('');
|
|
|
|
const load = useCallback(async () => {
|
|
setError('');
|
|
const [pr, cr] = await Promise.all([
|
|
fetch('/api/admin/players', { credentials: 'include' }),
|
|
fetch('/api/admin/characters', { credentials: 'include' }),
|
|
]);
|
|
const pj = await pr.json().catch(() => ({}));
|
|
const cj = await cr.json().catch(() => ({}));
|
|
if (!pr.ok) {
|
|
setError((pj as { error?: string }).error ?? 'Failed to load players');
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
if (!cr.ok) {
|
|
setError((cj as { error?: string }).error ?? 'Failed to load characters');
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
setPlayers((pj as { players: PlayerRow[] }).players ?? []);
|
|
setCharacters((cj as { characters: Char[] }).characters ?? []);
|
|
setLoading(false);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
load();
|
|
}, [load]);
|
|
|
|
async function addPlayer(e: React.FormEvent) {
|
|
e.preventDefault();
|
|
setError('');
|
|
const res = await fetch('/api/admin/players', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
credentials: 'include',
|
|
body: JSON.stringify({
|
|
player_key: playerKey,
|
|
player_name: playerName,
|
|
description,
|
|
image: image || null,
|
|
character_id: characterId,
|
|
}),
|
|
});
|
|
const data = await res.json().catch(() => ({}));
|
|
if (!res.ok) {
|
|
setError((data as { error?: string }).error ?? 'Create failed');
|
|
return;
|
|
}
|
|
setPlayerKey('');
|
|
setPlayerName('');
|
|
setDescription('');
|
|
setImage('');
|
|
setCharacterId('');
|
|
await load();
|
|
}
|
|
|
|
function updateLocal(id: number, patch: Partial<PlayerRow>) {
|
|
setPlayers((prev) => prev.map((r) => (r.id === id ? { ...r, ...patch } : r)));
|
|
}
|
|
|
|
async function save(row: PlayerRow) {
|
|
setError('');
|
|
const cid = row.character_id != null && row.character_id !== '' ? String(row.character_id) : '';
|
|
if (!cid) {
|
|
setError('Character is required');
|
|
return;
|
|
}
|
|
const res = await fetch(`/api/admin/players/${row.id}`, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
credentials: 'include',
|
|
body: JSON.stringify({
|
|
player_key: row.player_key,
|
|
player_name: row.player_name,
|
|
description: row.description ?? '',
|
|
image: row.image === '' || row.image == null ? null : row.image,
|
|
character_id: cid,
|
|
}),
|
|
});
|
|
const data = await res.json().catch(() => ({}));
|
|
if (!res.ok) {
|
|
setError((data as { error?: string }).error ?? 'Save failed');
|
|
return;
|
|
}
|
|
await load();
|
|
}
|
|
|
|
async function remove(id: number) {
|
|
if (!window.confirm('Delete this player?')) return;
|
|
setError('');
|
|
const res = await fetch(`/api/admin/players/${id}`, { method: 'DELETE', credentials: 'include' });
|
|
const data = await res.json().catch(() => ({}));
|
|
if (!res.ok) {
|
|
setError((data as { error?: string }).error ?? 'Delete failed');
|
|
return;
|
|
}
|
|
await load();
|
|
}
|
|
|
|
return (
|
|
<div className={card}>
|
|
<p>
|
|
<Link href="/admin">← Dashboard</Link>
|
|
</p>
|
|
<h1 className={title}>Players</h1>
|
|
{error ? <p className={errorClass}>{error}</p> : null}
|
|
{loading ? <p>Loading…</p> : null}
|
|
{!loading && (
|
|
<>
|
|
{players.map((p) => (
|
|
<div key={p.id} className={sectionBlock}>
|
|
<p className={metaLine}>
|
|
id: {p.id}
|
|
{p.name_jp ? ` · ${p.name_jp}` : ''}
|
|
</p>
|
|
<label className={label}>
|
|
player_key
|
|
<input
|
|
className={input}
|
|
value={p.player_key}
|
|
onChange={(e) => updateLocal(p.id, { player_key: e.target.value })}
|
|
/>
|
|
</label>
|
|
<label className={label}>
|
|
player_name
|
|
<input
|
|
className={input}
|
|
value={p.player_name}
|
|
onChange={(e) => updateLocal(p.id, { player_name: e.target.value })}
|
|
/>
|
|
</label>
|
|
<label className={label}>
|
|
description
|
|
<textarea
|
|
className={field}
|
|
value={p.description ?? ''}
|
|
onChange={(e) => updateLocal(p.id, { description: e.target.value })}
|
|
/>
|
|
</label>
|
|
<label className={label}>
|
|
image URL (optional)
|
|
<input
|
|
className={input}
|
|
value={p.image ?? ''}
|
|
onChange={(e) => updateLocal(p.id, { image: e.target.value || null })}
|
|
/>
|
|
</label>
|
|
<label className={label}>
|
|
character
|
|
<select
|
|
className={input}
|
|
value={p.character_id != null && p.character_id !== '' ? String(p.character_id) : ''}
|
|
onChange={(e) => updateLocal(p.id, { character_id: e.target.value })}
|
|
required
|
|
>
|
|
<option value="">—</option>
|
|
{characters.map((c) => (
|
|
<option key={c.id} value={c.id}>
|
|
{c.name_jp} ({c.name_id})
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<div className={rowActions}>
|
|
<button type="button" className={btn} onClick={() => save(p)}>
|
|
Save
|
|
</button>
|
|
<button type="button" className={btnDanger} onClick={() => remove(p.id)}>
|
|
Delete
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
|
|
<hr className={hr} />
|
|
<h2 className={sectionTitle}>Add player</h2>
|
|
<form className={formWide} onSubmit={addPlayer}>
|
|
<label className={label}>
|
|
player_key
|
|
<input className={input} value={playerKey} onChange={(e) => setPlayerKey(e.target.value)} required />
|
|
</label>
|
|
<label className={label}>
|
|
player_name
|
|
<input className={input} value={playerName} onChange={(e) => setPlayerName(e.target.value)} required />
|
|
</label>
|
|
<label className={label}>
|
|
description
|
|
<textarea className={field} value={description} onChange={(e) => setDescription(e.target.value)} />
|
|
</label>
|
|
<label className={label}>
|
|
image URL (optional)
|
|
<input className={input} value={image} onChange={(e) => setImage(e.target.value)} />
|
|
</label>
|
|
<label className={label}>
|
|
character
|
|
<select
|
|
className={input}
|
|
value={characterId}
|
|
onChange={(e) => setCharacterId(e.target.value)}
|
|
required
|
|
>
|
|
<option value="">—</option>
|
|
{characters.map((c) => (
|
|
<option key={c.id} value={c.id}>
|
|
{c.name_jp} ({c.name_id})
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<button className={btn} type="submit">
|
|
Add
|
|
</button>
|
|
</form>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|