393 lines
15 KiB
TypeScript
393 lines
15 KiB
TypeScript
"use client";
|
||
|
||
import Link from "next/link";
|
||
import { useMemo, useState } from "react";
|
||
import {
|
||
insertSetting,
|
||
deleteSetting,
|
||
updateSetting,
|
||
} from "@/app/actions/settings-actions";
|
||
import {
|
||
coinsToRc,
|
||
parseStoredCoins,
|
||
rcToCoins,
|
||
} from "@/lib/coins-rc";
|
||
import type { DbSetting } from "@/types/database";
|
||
|
||
type Props = {
|
||
rows: DbSetting[];
|
||
saveError: boolean;
|
||
addError: string | null;
|
||
};
|
||
|
||
function DefaultSettingRow({ row, index }: { row: DbSetting; index: number }) {
|
||
const [value, setValue] = useState(row.value ?? "");
|
||
const saveDisabled = value.trim() === "";
|
||
|
||
return (
|
||
<div className="rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
|
||
<form action={updateSetting}>
|
||
<input type="hidden" name="key" value={row.key} />
|
||
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||
<div className="min-w-0 flex-1">
|
||
<label
|
||
className="block text-xs font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400"
|
||
htmlFor={`setting-value-${index}`}
|
||
>
|
||
<span className="font-mono text-sm normal-case tracking-normal text-zinc-900 dark:text-zinc-100">
|
||
{row.key}
|
||
</span>
|
||
</label>
|
||
<input
|
||
id={`setting-value-${index}`}
|
||
name="value"
|
||
type="text"
|
||
value={value}
|
||
onChange={(e) => setValue(e.target.value)}
|
||
autoComplete="off"
|
||
className="mt-1.5 w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100"
|
||
/>
|
||
</div>
|
||
<button
|
||
type="submit"
|
||
disabled={saveDisabled}
|
||
className="shrink-0 rounded-md bg-zinc-900 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-zinc-800 disabled:cursor-not-allowed disabled:bg-zinc-600 disabled:hover:bg-zinc-600 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200 dark:disabled:bg-zinc-700"
|
||
>
|
||
Save
|
||
</button>
|
||
</div>
|
||
</form>
|
||
|
||
<form action={deleteSetting} className="mt-4 flex justify-end">
|
||
<input type="hidden" name="key" value={row.key} />
|
||
<button
|
||
type="submit"
|
||
className="rounded-md border border-red-300 bg-white px-4 py-2 text-sm font-medium text-red-700 shadow-sm transition hover:bg-red-50 dark:border-red-900 dark:bg-zinc-950 dark:text-red-300 dark:hover:bg-red-950/40"
|
||
onClick={(e) => {
|
||
if (!window.confirm(`Delete setting "${row.key}"?`)) {
|
||
e.preventDefault();
|
||
}
|
||
}}
|
||
>
|
||
Delete
|
||
</button>
|
||
</form>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function BetFeeRow({ row, index }: { row: DbSetting; index: number }) {
|
||
const initial = Math.min(
|
||
100,
|
||
Math.max(0, Math.round(Number(row.value ?? 0))),
|
||
);
|
||
const [v, setV] = useState(initial);
|
||
|
||
return (
|
||
<div className="rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
|
||
<form action={updateSetting}>
|
||
<input type="hidden" name="key" value={row.key} />
|
||
<input type="hidden" name="value" value={String(v)} />
|
||
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||
<div className="min-w-0 flex-1 space-y-3">
|
||
<div>
|
||
<label
|
||
className="block text-xs font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400"
|
||
htmlFor={`bet-fee-${index}`}
|
||
>
|
||
<span className="font-mono text-sm normal-case tracking-normal text-zinc-900 dark:text-zinc-100">
|
||
{row.key}
|
||
</span>
|
||
</label>
|
||
<p className="mt-1 text-xs text-zinc-500 dark:text-zinc-400">
|
||
0–100 (saved as the number shown).
|
||
</p>
|
||
</div>
|
||
<div className="flex flex-wrap items-center gap-4">
|
||
<input
|
||
id={`bet-fee-${index}`}
|
||
type="range"
|
||
min={0}
|
||
max={100}
|
||
value={v}
|
||
onChange={(e) => setV(Number(e.target.value))}
|
||
className="h-2 w-full min-w-[200px] max-w-md cursor-pointer accent-zinc-900 dark:accent-zinc-100"
|
||
/>
|
||
<span className="min-w-[3ch] tabular-nums text-sm font-semibold text-zinc-900 dark:text-zinc-50">
|
||
{v}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<button
|
||
type="submit"
|
||
className="shrink-0 rounded-md bg-zinc-900 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-zinc-800 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200"
|
||
>
|
||
Save
|
||
</button>
|
||
</div>
|
||
</form>
|
||
|
||
<form action={deleteSetting} className="mt-4 flex justify-end">
|
||
<input type="hidden" name="key" value={row.key} />
|
||
<button
|
||
type="submit"
|
||
className="rounded-md border border-red-300 bg-white px-4 py-2 text-sm font-medium text-red-700 shadow-sm transition hover:bg-red-50 dark:border-red-900 dark:bg-zinc-950 dark:text-red-300 dark:hover:bg-red-950/40"
|
||
onClick={(e) => {
|
||
if (!window.confirm(`Delete setting "${row.key}"?`)) {
|
||
e.preventDefault();
|
||
}
|
||
}}
|
||
>
|
||
Delete
|
||
</button>
|
||
</form>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function EntryFeeRow({ row, index }: { row: DbSetting; index: number }) {
|
||
const initialCoins = parseStoredCoins(row.value);
|
||
const initialRc = coinsToRc(initialCoins);
|
||
const [rcText, setRcText] = useState(() => initialRc.toFixed(1));
|
||
const [localError, setLocalError] = useState<string | null>(null);
|
||
|
||
const coinsPreview = useMemo(() => {
|
||
const t = rcText.trim();
|
||
if (t === "") return null;
|
||
return rcToCoins(Number(t));
|
||
}, [rcText]);
|
||
|
||
const saveDisabled = rcText.trim() === "";
|
||
|
||
return (
|
||
<div className="rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
|
||
<form
|
||
action={updateSetting}
|
||
onSubmit={(e) => {
|
||
setLocalError(null);
|
||
const trimmed = rcText.trim();
|
||
if (trimmed === "") {
|
||
e.preventDefault();
|
||
setLocalError(
|
||
"Enter an RC value (one decimal place; tenths digit 0–3).",
|
||
);
|
||
return;
|
||
}
|
||
const rc = Number(trimmed);
|
||
const coins = rcToCoins(rc);
|
||
if (coins === null) {
|
||
e.preventDefault();
|
||
setLocalError(
|
||
"Enter a valid RC with one decimal place; the tenths digit must be 0–3 (e.g. 5.1).",
|
||
);
|
||
}
|
||
}}
|
||
>
|
||
<input type="hidden" name="key" value={row.key} />
|
||
<input
|
||
type="hidden"
|
||
name="value"
|
||
value={coinsPreview === null ? "" : String(coinsPreview)}
|
||
/>
|
||
<div className="flex flex-col gap-4 sm:flex-row sm:items-end">
|
||
<div className="min-w-0 flex-1">
|
||
<label
|
||
className="block text-xs font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400"
|
||
htmlFor={`entry-fee-rc-${index}`}
|
||
>
|
||
<span className="font-mono text-sm normal-case tracking-normal text-zinc-900 dark:text-zinc-100">
|
||
{row.key}
|
||
</span>
|
||
<span className="ml-2 font-normal normal-case text-zinc-500 dark:text-zinc-400">
|
||
(edit as RC; stored as coins)
|
||
</span>
|
||
</label>
|
||
<input
|
||
id={`entry-fee-rc-${index}`}
|
||
type="text"
|
||
inputMode="decimal"
|
||
autoComplete="off"
|
||
value={rcText}
|
||
onChange={(e) => setRcText(e.target.value)}
|
||
className="mt-1.5 w-full max-w-xs rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100"
|
||
/>
|
||
{localError ? (
|
||
<p
|
||
className="mt-2 text-sm text-red-600 dark:text-red-400"
|
||
role="alert"
|
||
>
|
||
{localError}
|
||
</p>
|
||
) : coinsPreview !== null ? (
|
||
<p className="mt-2 text-xs text-zinc-500 dark:text-zinc-400">
|
||
Saves as {coinsPreview} coin{coinsPreview === 1 ? "" : "s"} in
|
||
the database.
|
||
</p>
|
||
) : rcText.trim() !== "" ? (
|
||
<p className="mt-2 text-xs text-amber-700 dark:text-amber-300">
|
||
Not a valid RC encoding yet — fix the value to save.
|
||
</p>
|
||
) : null}
|
||
</div>
|
||
<button
|
||
type="submit"
|
||
disabled={saveDisabled}
|
||
className="shrink-0 rounded-md bg-zinc-900 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-zinc-800 disabled:cursor-not-allowed disabled:bg-zinc-600 disabled:hover:bg-zinc-600 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200 dark:disabled:bg-zinc-700"
|
||
>
|
||
Save
|
||
</button>
|
||
</div>
|
||
</form>
|
||
|
||
<form action={deleteSetting} className="mt-4 flex justify-end">
|
||
<input type="hidden" name="key" value={row.key} />
|
||
<button
|
||
type="submit"
|
||
className="rounded-md border border-red-300 bg-white px-4 py-2 text-sm font-medium text-red-700 shadow-sm transition hover:bg-red-50 dark:border-red-900 dark:bg-zinc-950 dark:text-red-300 dark:hover:bg-red-950/40"
|
||
onClick={(e) => {
|
||
if (!window.confirm(`Delete setting "${row.key}"?`)) {
|
||
e.preventDefault();
|
||
}
|
||
}}
|
||
>
|
||
Delete
|
||
</button>
|
||
</form>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function SettingRow({ row, index }: { row: DbSetting; index: number }) {
|
||
if (row.key === "bet_fee") {
|
||
return <BetFeeRow row={row} index={index} />;
|
||
}
|
||
if (row.key === "entry_fee") {
|
||
return <EntryFeeRow row={row} index={index} />;
|
||
}
|
||
return <DefaultSettingRow row={row} index={index} />;
|
||
}
|
||
|
||
export function AdminSettingsEditor({
|
||
rows,
|
||
saveError,
|
||
addError,
|
||
}: Props) {
|
||
const [newKey, setNewKey] = useState("");
|
||
const [newValue, setNewValue] = useState("");
|
||
|
||
return (
|
||
<div className="mx-auto w-full max-w-[900px] space-y-8">
|
||
{saveError ? (
|
||
<div
|
||
className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-900 dark:border-red-900 dark:bg-red-950/40 dark:text-red-100"
|
||
role="alert"
|
||
>
|
||
Could not save that row. Try again or check Supabase connectivity.
|
||
</div>
|
||
) : null}
|
||
|
||
<section className="space-y-3">
|
||
<h2 className="text-sm font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
|
||
Existing keys
|
||
</h2>
|
||
{rows.length === 0 ? (
|
||
<p className="rounded-xl border border-zinc-200 bg-white px-4 py-8 text-center text-sm text-zinc-500 shadow-sm dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-400">
|
||
No settings rows yet. Add one below.
|
||
</p>
|
||
) : (
|
||
<div className="space-y-3">
|
||
{rows.map((row, i) => (
|
||
<SettingRow key={row.key} row={row} index={i} />
|
||
))}
|
||
</div>
|
||
)}
|
||
</section>
|
||
|
||
<section className="rounded-xl border border-zinc-200 bg-white p-5 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
|
||
<h2 className="text-sm font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
|
||
Add setting
|
||
</h2>
|
||
|
||
{addError === "duplicate" ? (
|
||
<p className="mt-3 text-sm text-red-600 dark:text-red-400">
|
||
That key already exists. Edit it in the list above instead.
|
||
</p>
|
||
) : addError === "missing" ? (
|
||
<p className="mt-3 text-sm text-red-600 dark:text-red-400">
|
||
Enter a non-empty key.
|
||
</p>
|
||
) : addError === "missingValue" ? (
|
||
<p className="mt-3 text-sm text-red-600 dark:text-red-400">
|
||
Enter a non-empty value.
|
||
</p>
|
||
) : addError === "config" ? (
|
||
<p className="mt-3 text-sm text-red-600 dark:text-red-400">
|
||
Supabase admin client is not configured.
|
||
</p>
|
||
) : addError === "other" ? (
|
||
<p className="mt-3 text-sm text-red-600 dark:text-red-400">
|
||
Could not insert. Check constraints and retry.
|
||
</p>
|
||
) : null}
|
||
|
||
<form action={insertSetting} className="mt-4 flex flex-col gap-4">
|
||
<div>
|
||
<label
|
||
htmlFor="new-setting-key"
|
||
className="block text-sm font-medium text-zinc-700 dark:text-zinc-300"
|
||
>
|
||
Key
|
||
</label>
|
||
<input
|
||
id="new-setting-key"
|
||
name="newKey"
|
||
type="text"
|
||
value={newKey}
|
||
onChange={(e) => setNewKey(e.target.value)}
|
||
autoComplete="off"
|
||
placeholder="e.g. maintenance_message"
|
||
className="mt-1 w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label
|
||
htmlFor="new-setting-value"
|
||
className="block text-sm font-medium text-zinc-700 dark:text-zinc-300"
|
||
>
|
||
Value
|
||
</label>
|
||
<input
|
||
id="new-setting-value"
|
||
name="newValue"
|
||
type="text"
|
||
value={newValue}
|
||
onChange={(e) => setNewValue(e.target.value)}
|
||
autoComplete="off"
|
||
className="mt-1 w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100"
|
||
/>
|
||
</div>
|
||
<div className="flex justify-end">
|
||
<button
|
||
type="submit"
|
||
disabled={newKey.trim() === "" || newValue.trim() === ""}
|
||
className="rounded-md bg-zinc-900 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-zinc-800 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200"
|
||
>
|
||
Add row
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</section>
|
||
|
||
<p className="text-center text-xs text-zinc-500 dark:text-zinc-400">
|
||
<Link
|
||
href="/"
|
||
className="font-medium text-sky-700 underline decoration-sky-400/60 underline-offset-2 hover:text-sky-900 dark:text-sky-300 dark:hover:text-sky-100"
|
||
scroll={false}
|
||
>
|
||
Back to dashboard
|
||
</Link>
|
||
</p>
|
||
</div>
|
||
);
|
||
}
|