Files
kickkingsadmin/src/components/admin-settings-editor.tsx
T
2026-07-12 17:55:44 +00:00

449 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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;
readOnly?: boolean;
};
function ReadOnlySettingRow({ row }: { row: DbSetting }) {
return (
<div className="rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<p className="font-mono text-sm text-zinc-900 dark:text-zinc-100">
{row.key}
</p>
<p className="mt-2 break-all text-sm text-zinc-700 dark:text-zinc-300">
{row.value?.trim() ? row.value : "—"}
</p>
</div>
);
}
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">
0100 (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 03).",
);
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 03 (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,
readOnly = false,
}: Props) {
const [newKey, setNewKey] = useState("");
const [newValue, setNewValue] = useState("");
if (readOnly) {
return (
<div className="mx-auto w-full max-w-[900px] space-y-8">
<div
className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-950 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-100"
role="status"
>
Settings are view-only for your account. Contact an admin to make
changes.
</div>
<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.
</p>
) : (
<div className="space-y-3">
{rows.map((row) => (
<ReadOnlySettingRow key={row.key} row={row} />
))}
</div>
)}
</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>
);
}
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>
);
}