Fix rooms list crash and rebuild CMS settings UI.
Rooms queried the wrong Prisma model and a non-existent owner relation. Settings now use grouped managed fields plus a searchable advanced key/value panel. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
1 parent
c186f51ae0
commit
e101ea474e
6 files changed
+796
-104
No files matched your search
@@ -1,11 +1,46 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { MANAGED_SETTING_KEYS } from "@/app/admin/settings/cms-settings-config";
|
||||||
import { requirePermissionRateLimited } from "@/lib/admin/guard";
|
import { requirePermissionRateLimited } from "@/lib/admin/guard";
|
||||||
|
import { actionOk, adminAction } from "@/lib/foundation/action";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { siteSettings } from "@/lib/services/site-settings";
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
|
|
||||||
|
const managedKeySet = new Set(MANAGED_SETTING_KEYS);
|
||||||
|
|
||||||
|
const saveManagedSchema = z.object({
|
||||||
|
settings: z.record(z.string(), z.string()),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const saveManagedSettings = adminAction(
|
||||||
|
{
|
||||||
|
permission: PERMS.SETTINGS_EDIT,
|
||||||
|
schema: saveManagedSchema,
|
||||||
|
rateLimitKey: "admin-settings-save",
|
||||||
|
rateLimitMax: 30,
|
||||||
|
},
|
||||||
|
async (ctx) => {
|
||||||
|
const entries = Object.entries(ctx.data.settings).filter(([key]) =>
|
||||||
|
managedKeySet.has(key),
|
||||||
|
);
|
||||||
|
await Promise.all(
|
||||||
|
entries.map(([key, value]) =>
|
||||||
|
prisma.websiteSetting.upsert({
|
||||||
|
where: { key },
|
||||||
|
update: { value },
|
||||||
|
create: { key, value },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
siteSettings.reload();
|
||||||
|
revalidatePath("/admin/settings");
|
||||||
|
return actionOk({ saved: entries.length });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
export async function updateSetting(formData: FormData): Promise<void> {
|
export async function updateSetting(formData: FormData): Promise<void> {
|
||||||
await requirePermissionRateLimited(PERMS.SETTINGS_EDIT);
|
await requirePermissionRateLimited(PERMS.SETTINGS_EDIT);
|
||||||
const key = String(formData.get("key") ?? "")
|
const key = String(formData.get("key") ?? "")
|
||||||
|
|||||||
@@ -6,27 +6,41 @@ export default async function RoomsPage({
|
|||||||
params: paramsPromise,
|
params: paramsPromise,
|
||||||
searchParams,
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
params: Promise<{ locale: string }>;
|
params: Promise<{ locale?: string }>;
|
||||||
searchParams: Promise<Record<string, string>>;
|
searchParams: Promise<Record<string, string>>;
|
||||||
}) {
|
}) {
|
||||||
const result = await fetchAdminList(
|
const result = await fetchAdminList(
|
||||||
{
|
{
|
||||||
permission: PERMS.ROOMS_VIEW,
|
permission: PERMS.ROOMS_VIEW,
|
||||||
model: "room",
|
// Prisma model is `Rooms` → client accessor `rooms`.
|
||||||
searchFields: ["name", "owner.username"],
|
// Owner is denormalized on the row (ownerId / ownerName), not a relation.
|
||||||
include: { owner: { select: { username: true } } },
|
model: "rooms",
|
||||||
|
searchFields: ["name", "ownerName"],
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
ownerId: true,
|
||||||
|
ownerName: true,
|
||||||
|
state: true,
|
||||||
|
users: true,
|
||||||
|
usersMax: true,
|
||||||
|
description: true,
|
||||||
|
},
|
||||||
|
filterMap: {
|
||||||
|
filter_state: (v) => ({ state: v }),
|
||||||
|
},
|
||||||
mapRow: (r) => ({
|
mapRow: (r) => ({
|
||||||
id: r.id,
|
id: r.id as number,
|
||||||
name: r.name,
|
name: r.name as string,
|
||||||
ownerName: r.owner.username,
|
ownerName: (r.ownerName as string) || "—",
|
||||||
ownerId: r.owner?.id ?? 0,
|
ownerId: (r.ownerId as number) ?? 0,
|
||||||
state: r.state,
|
state: r.state as string,
|
||||||
users: r.users,
|
users: r.users as number,
|
||||||
usersMax: r.usersMax,
|
usersMax: r.usersMax as number,
|
||||||
description: r.description,
|
description: (r.description as string) || "",
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
paramsPromise,
|
paramsPromise as Promise<{ locale: string }>,
|
||||||
searchParams,
|
searchParams,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Search, Trash2 } from "lucide-react";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
createSetting,
|
||||||
|
deleteSetting,
|
||||||
|
updateSetting,
|
||||||
|
} from "@/actions/admin-settings";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { MANAGED_SETTING_KEYS } from "./cms-settings-config";
|
||||||
|
|
||||||
|
interface SettingRow {
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
comment: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdvancedSettingsPanel({
|
||||||
|
settings,
|
||||||
|
canEdit,
|
||||||
|
}: {
|
||||||
|
settings: SettingRow[];
|
||||||
|
canEdit: boolean;
|
||||||
|
}) {
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [showManaged, setShowManaged] = useState(false);
|
||||||
|
const managed = useMemo(() => new Set(MANAGED_SETTING_KEYS), []);
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
return settings.filter((s) => {
|
||||||
|
if (!showManaged && managed.has(s.key)) return false;
|
||||||
|
if (!q) return true;
|
||||||
|
return (
|
||||||
|
s.key.toLowerCase().includes(q) ||
|
||||||
|
s.value.toLowerCase().includes(q) ||
|
||||||
|
(s.comment ?? "").toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}, [settings, query, showManaged, managed]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="space-y-4">
|
||||||
|
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="m-0 text-base font-bold text-[var(--admin-text)]">
|
||||||
|
Advanced key / value
|
||||||
|
</h2>
|
||||||
|
<p className="m-0 mt-0.5 text-xs text-[var(--admin-text-muted)]">
|
||||||
|
Raw website_settings rows. Prefer the grouped form above for common
|
||||||
|
keys.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={showManaged}
|
||||||
|
onChange={(e) => setShowManaged(e.target.checked)}
|
||||||
|
className="rounded border"
|
||||||
|
/>
|
||||||
|
Show keys already in the form
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder="Search keys, values, comments…"
|
||||||
|
className="pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{canEdit ? (
|
||||||
|
<form
|
||||||
|
action={createSetting}
|
||||||
|
className="rounded-2xl border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4 grid gap-2 sm:grid-cols-[1fr_1.4fr_1fr_auto]"
|
||||||
|
>
|
||||||
|
<Input name="key" placeholder="key" required />
|
||||||
|
<Input name="value" placeholder="value" />
|
||||||
|
<Input name="comment" placeholder="comment (optional)" />
|
||||||
|
<Button type="submit">Add / overwrite</Button>
|
||||||
|
</form>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<div className="admin-empty">No matching settings</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{filtered.map((s) => (
|
||||||
|
<div
|
||||||
|
key={s.key}
|
||||||
|
className="rounded-xl border border-[var(--admin-border)] bg-[var(--admin-surface)] p-3"
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<strong className="text-sm font-mono min-w-[12rem]">
|
||||||
|
{s.key}
|
||||||
|
</strong>
|
||||||
|
{canEdit ? (
|
||||||
|
<>
|
||||||
|
<form
|
||||||
|
action={updateSetting}
|
||||||
|
className="flex flex-1 min-w-[220px] gap-2"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="key" value={s.key} />
|
||||||
|
<Input
|
||||||
|
name="value"
|
||||||
|
defaultValue={s.value}
|
||||||
|
className="flex-1 font-mono text-xs"
|
||||||
|
/>
|
||||||
|
<Button type="submit" variant="outline" size="sm">
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
<form action={deleteSetting}>
|
||||||
|
<input type="hidden" name="key" value={s.key} />
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="destructive"
|
||||||
|
size="icon"
|
||||||
|
aria-label={`Delete ${s.key}`}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<code className="flex-1 text-xs break-all text-muted-foreground">
|
||||||
|
{s.value}
|
||||||
|
</code>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{s.comment ? (
|
||||||
|
<p className="m-0 mt-1.5 text-xs text-muted-foreground">
|
||||||
|
{s.comment}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,302 @@
|
|||||||
|
export type FieldType =
|
||||||
|
| "text"
|
||||||
|
| "password"
|
||||||
|
| "url"
|
||||||
|
| "number"
|
||||||
|
| "boolean"
|
||||||
|
| "textarea";
|
||||||
|
|
||||||
|
export type SettingsGroupIcon =
|
||||||
|
| "building"
|
||||||
|
| "image"
|
||||||
|
| "gamepad"
|
||||||
|
| "music"
|
||||||
|
| "shield"
|
||||||
|
| "link"
|
||||||
|
| "user-plus";
|
||||||
|
|
||||||
|
export interface ManagedField {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
type?: FieldType;
|
||||||
|
placeholder?: string;
|
||||||
|
defaultValue?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SettingsGroup {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
icon: SettingsGroupIcon;
|
||||||
|
description?: string;
|
||||||
|
fields: ManagedField[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keys managed by the structured CMS Settings form (not theme / VPN / maintenance pages). */
|
||||||
|
export const SETTINGS_GROUPS: SettingsGroup[] = [
|
||||||
|
{
|
||||||
|
id: "identity",
|
||||||
|
title: "Hotel identity",
|
||||||
|
icon: "building",
|
||||||
|
description: "Name, branding and defaults shown across the site.",
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
key: "hotel_name",
|
||||||
|
label: "Hotel name",
|
||||||
|
type: "text",
|
||||||
|
placeholder: "Epicnabbo",
|
||||||
|
defaultValue: "Atom",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "cms_logo",
|
||||||
|
label: "Logo URL",
|
||||||
|
description: "Header logo path or absolute URL.",
|
||||||
|
type: "url",
|
||||||
|
placeholder: "/api/media/logo/…",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "cms_favicon",
|
||||||
|
label: "Favicon URL",
|
||||||
|
type: "url",
|
||||||
|
placeholder: "/favicon.svg",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "cms_header",
|
||||||
|
label: "Header background",
|
||||||
|
type: "url",
|
||||||
|
placeholder: "/assets/images/background.png",
|
||||||
|
defaultValue: "/assets/images/background.png",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "default_dark",
|
||||||
|
label: "Dark mode by default",
|
||||||
|
description: "New visitors start in dark mode unless they override it.",
|
||||||
|
type: "boolean",
|
||||||
|
defaultValue: "0",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "min_staff_rank",
|
||||||
|
label: "Minimum staff rank",
|
||||||
|
description: "Rank shown on the public staff page.",
|
||||||
|
type: "number",
|
||||||
|
defaultValue: "7",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "imaging",
|
||||||
|
title: "Avatars & badges",
|
||||||
|
icon: "image",
|
||||||
|
description: "Imaging endpoints used for avatars and badge icons.",
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
key: "habbo_imaging_url",
|
||||||
|
label: "Habbo imaging upstream",
|
||||||
|
type: "url",
|
||||||
|
defaultValue: "https://www.habbo.com/habbo-imaging/avatarimage",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "imaging_use_habbo_fallback",
|
||||||
|
label: "Use Habbo.com as avatar fallback",
|
||||||
|
type: "boolean",
|
||||||
|
defaultValue: "1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "badge_base_url",
|
||||||
|
label: "Badge icons base URL",
|
||||||
|
type: "url",
|
||||||
|
placeholder: "/swf/c_images/album1584",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "badges_path",
|
||||||
|
label: "Badges path (rares page)",
|
||||||
|
type: "url",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "furniture_icons_path",
|
||||||
|
label: "Furniture icons path",
|
||||||
|
type: "url",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "client",
|
||||||
|
title: "Nitro client",
|
||||||
|
icon: "gamepad",
|
||||||
|
description: "Game client loaded at /client.",
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
key: "nitro_client_url",
|
||||||
|
label: "Nitro client URL",
|
||||||
|
description: "Absolute or same-origin URL for the Nitro iframe.",
|
||||||
|
type: "url",
|
||||||
|
placeholder: "https://…/client/",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "nitro_files_root",
|
||||||
|
label: "Nitro-Files root (server path)",
|
||||||
|
description:
|
||||||
|
"Filesystem root used when importing furni / writing live assets.",
|
||||||
|
type: "text",
|
||||||
|
placeholder: "E:\\path\\to\\Nitro-Files",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "assets",
|
||||||
|
title: "Game assets",
|
||||||
|
icon: "music",
|
||||||
|
description: "Public URLs and overrides for SWF / Nitro asset packages.",
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
key: "soundtrack_base_url",
|
||||||
|
label: "Song disks MP3 base URL",
|
||||||
|
type: "url",
|
||||||
|
defaultValue: "/swf/dcr/hof_furni/mp3/",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "furni_data_mirror_path",
|
||||||
|
label: "FurnitureData mirror path",
|
||||||
|
type: "text",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "figure_swf_base_url",
|
||||||
|
label: "Figure SWF base URL (override)",
|
||||||
|
type: "url",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "effect_swf_base_url",
|
||||||
|
label: "Effect SWF base URL (override)",
|
||||||
|
type: "url",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "pet_swf_base_url",
|
||||||
|
label: "Pet SWF base URL (override)",
|
||||||
|
type: "url",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "radio",
|
||||||
|
title: "Radio",
|
||||||
|
icon: "music",
|
||||||
|
description: "Public radio player and DJ monitoring feeds.",
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
key: "radio_enabled",
|
||||||
|
label: "Enable radio player",
|
||||||
|
type: "boolean",
|
||||||
|
defaultValue: "0",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "radio_name",
|
||||||
|
label: "Radio display name",
|
||||||
|
type: "text",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "radio_stream_url",
|
||||||
|
label: "Stream URL",
|
||||||
|
type: "url",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "radio_now_playing_api_url",
|
||||||
|
label: "Now-playing API URL",
|
||||||
|
type: "url",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "radio_listeners_api_url",
|
||||||
|
label: "Listeners API URL",
|
||||||
|
type: "url",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "security",
|
||||||
|
title: "Security & registration",
|
||||||
|
icon: "shield",
|
||||||
|
description: "Account limits, captcha and staff 2FA.",
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
key: "force_staff_2fa",
|
||||||
|
label: "Require 2FA for staff",
|
||||||
|
description: "Staff without 2FA are redirected to set it up.",
|
||||||
|
type: "boolean",
|
||||||
|
defaultValue: "0",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "max_accounts_per_ip",
|
||||||
|
label: "Max accounts per IP",
|
||||||
|
description: "0 = unlimited.",
|
||||||
|
type: "number",
|
||||||
|
defaultValue: "0",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "captcha_provider",
|
||||||
|
label: "Captcha provider",
|
||||||
|
description: "none | turnstile | recaptcha",
|
||||||
|
type: "text",
|
||||||
|
defaultValue: "none",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "turnstile_site_key",
|
||||||
|
label: "Turnstile site key",
|
||||||
|
type: "text",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "recaptcha_site_key",
|
||||||
|
label: "reCAPTCHA site key",
|
||||||
|
type: "text",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "abuse_guard_enabled",
|
||||||
|
label: "Enable abuse guard",
|
||||||
|
type: "boolean",
|
||||||
|
defaultValue: "0",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "abuse_guard_threshold",
|
||||||
|
label: "Abuse guard threshold",
|
||||||
|
type: "number",
|
||||||
|
defaultValue: "200",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "abuse_guard_window_seconds",
|
||||||
|
label: "Abuse guard window (seconds)",
|
||||||
|
type: "number",
|
||||||
|
defaultValue: "10",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "misc",
|
||||||
|
title: "Other",
|
||||||
|
icon: "link",
|
||||||
|
description: "Extra hotel features.",
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
key: "drawbadge.price",
|
||||||
|
label: "Draw-badge price",
|
||||||
|
type: "number",
|
||||||
|
defaultValue: "0",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const MANAGED_SETTING_KEYS: string[] = SETTINGS_GROUPS.flatMap((g) =>
|
||||||
|
g.fields.map((f) => f.key),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const BOOLEAN_KEYS = new Set(
|
||||||
|
SETTINGS_GROUPS.flatMap((g) =>
|
||||||
|
g.fields.filter((f) => f.type === "boolean").map((f) => f.key),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const FIELD_DEFAULTS: Record<string, string> = Object.fromEntries(
|
||||||
|
SETTINGS_GROUPS.flatMap((g) =>
|
||||||
|
g.fields
|
||||||
|
.filter((f) => f.defaultValue != null)
|
||||||
|
.map((f) => [f.key, f.defaultValue as string]),
|
||||||
|
),
|
||||||
|
);
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Building2,
|
||||||
|
Gamepad2,
|
||||||
|
ImageIcon,
|
||||||
|
Link as LinkIcon,
|
||||||
|
Loader2,
|
||||||
|
Music,
|
||||||
|
Save,
|
||||||
|
Shield,
|
||||||
|
UserPlus,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { saveManagedSettings } from "@/actions/admin-settings";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { useServerAction } from "@/hooks/use-server-action";
|
||||||
|
import {
|
||||||
|
BOOLEAN_KEYS,
|
||||||
|
type ManagedField,
|
||||||
|
SETTINGS_GROUPS,
|
||||||
|
type SettingsGroup,
|
||||||
|
} from "./cms-settings-config";
|
||||||
|
|
||||||
|
const ICONS = {
|
||||||
|
building: Building2,
|
||||||
|
image: ImageIcon,
|
||||||
|
gamepad: Gamepad2,
|
||||||
|
music: Music,
|
||||||
|
shield: Shield,
|
||||||
|
link: LinkIcon,
|
||||||
|
"user-plus": UserPlus,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export function CmsSettingsForm({
|
||||||
|
values: initialValues,
|
||||||
|
canEdit,
|
||||||
|
}: {
|
||||||
|
values: Record<string, string>;
|
||||||
|
canEdit: boolean;
|
||||||
|
}) {
|
||||||
|
const [values, setValues] = useState(initialValues);
|
||||||
|
const { isPending, run } = useServerAction();
|
||||||
|
|
||||||
|
function handleChange(key: string, value: string) {
|
||||||
|
setValues((prev) => ({ ...prev, [key]: value }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!canEdit) return;
|
||||||
|
const normalized = { ...values };
|
||||||
|
for (const key of BOOLEAN_KEYS) {
|
||||||
|
normalized[key] = values[key] === "1" ? "1" : "0";
|
||||||
|
}
|
||||||
|
run(() => saveManagedSettings({ settings: normalized }), {
|
||||||
|
successMessage: "Settings saved.",
|
||||||
|
errorMessage: "Could not save settings.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
|
{SETTINGS_GROUPS.map((group) => (
|
||||||
|
<GroupSection
|
||||||
|
key={group.id}
|
||||||
|
group={group}
|
||||||
|
values={values}
|
||||||
|
canEdit={canEdit}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{canEdit ? (
|
||||||
|
<div className="sticky bottom-3 z-10 flex justify-end">
|
||||||
|
<Button type="submit" disabled={isPending} className="shadow-lg">
|
||||||
|
{isPending ? (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Save className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Save settings
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
You can view settings but need edit permission to change them.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function GroupSection({
|
||||||
|
group,
|
||||||
|
values,
|
||||||
|
canEdit,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
group: SettingsGroup;
|
||||||
|
values: Record<string, string>;
|
||||||
|
canEdit: boolean;
|
||||||
|
onChange: (key: string, value: string) => void;
|
||||||
|
}) {
|
||||||
|
const Icon = ICONS[group.icon];
|
||||||
|
return (
|
||||||
|
<section className="rounded-2xl border border-[var(--admin-border)] bg-[var(--admin-surface)] p-5 space-y-4 shadow-card">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/20 to-[var(--admin-accent)]/5 grid place-items-center shrink-0">
|
||||||
|
<Icon size={18} className="text-[var(--admin-accent)]" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h2 className="m-0 text-base font-bold text-[var(--admin-text)]">
|
||||||
|
{group.title}
|
||||||
|
</h2>
|
||||||
|
{group.description ? (
|
||||||
|
<p className="m-0 mt-0.5 text-xs text-[var(--admin-text-muted)]">
|
||||||
|
{group.description}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
{group.fields.map((field) => (
|
||||||
|
<FieldControl
|
||||||
|
key={field.key}
|
||||||
|
field={field}
|
||||||
|
value={values[field.key] ?? ""}
|
||||||
|
canEdit={canEdit}
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldControl({
|
||||||
|
field,
|
||||||
|
value,
|
||||||
|
canEdit,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
field: ManagedField;
|
||||||
|
value: string;
|
||||||
|
canEdit: boolean;
|
||||||
|
onChange: (key: string, value: string) => void;
|
||||||
|
}) {
|
||||||
|
const type = field.type ?? "text";
|
||||||
|
const wide = type === "textarea" || type === "boolean";
|
||||||
|
|
||||||
|
if (type === "boolean") {
|
||||||
|
const checked = value === "1" || value.toLowerCase() === "true";
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`${wide ? "sm:col-span-2" : ""} flex items-center justify-between gap-4 rounded-xl border border-[var(--admin-border)] px-4 py-3`}
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<Label htmlFor={field.key} className="text-sm font-semibold">
|
||||||
|
{field.label}
|
||||||
|
</Label>
|
||||||
|
{field.description ? (
|
||||||
|
<p className="m-0 mt-0.5 text-xs text-muted-foreground">
|
||||||
|
{field.description}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id={field.key}
|
||||||
|
checked={checked}
|
||||||
|
disabled={!canEdit}
|
||||||
|
onCheckedChange={(next) => onChange(field.key, next ? "1" : "0")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`space-y-1.5 ${wide ? "sm:col-span-2" : ""}`}>
|
||||||
|
<Label htmlFor={field.key} className="text-sm font-semibold">
|
||||||
|
{field.label}
|
||||||
|
</Label>
|
||||||
|
{field.description ? (
|
||||||
|
<p className="m-0 text-xs text-muted-foreground">{field.description}</p>
|
||||||
|
) : null}
|
||||||
|
{type === "textarea" ? (
|
||||||
|
<Textarea
|
||||||
|
id={field.key}
|
||||||
|
value={value}
|
||||||
|
disabled={!canEdit}
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
onChange={(e) => onChange(field.key, e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
id={field.key}
|
||||||
|
type={
|
||||||
|
type === "number"
|
||||||
|
? "number"
|
||||||
|
: type === "password"
|
||||||
|
? "password"
|
||||||
|
: "text"
|
||||||
|
}
|
||||||
|
value={value}
|
||||||
|
disabled={!canEdit}
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
onChange={(e) => onChange(field.key, e.target.value)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<code className="block text-[0.65rem] text-muted-foreground truncate">
|
||||||
|
{field.key}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,22 +1,32 @@
|
|||||||
|
import { Cog, ExternalLink } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { getTranslations } from "next-intl/server";
|
|
||||||
import {
|
|
||||||
createSetting,
|
|
||||||
deleteSetting,
|
|
||||||
updateSetting,
|
|
||||||
} from "@/actions/admin-settings";
|
|
||||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { AdvancedSettingsPanel } from "./advanced-settings-panel";
|
||||||
|
import { FIELD_DEFAULTS, MANAGED_SETTING_KEYS } from "./cms-settings-config";
|
||||||
|
import { CmsSettingsForm } from "./cms-settings-form";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
export const metadata = { title: "CMS Settings" };
|
||||||
|
|
||||||
|
const RELATED = [
|
||||||
|
{ href: "/admin/theme", label: "Theme & colors" },
|
||||||
|
{ href: "/admin/maintenance", label: "Maintenance" },
|
||||||
|
{ href: "/admin/vpn", label: "VPN / proxy block" },
|
||||||
|
{ href: "/admin/emulator", label: "Emulator config" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
export default async function AdminSettings() {
|
export default async function AdminSettings() {
|
||||||
const { session, permissions } = await getAdminContext();
|
const { session, permissions } = await getAdminContext();
|
||||||
if (!canAccess(permissions, PERMS.SETTINGS_VIEW, session.user.rank)) {
|
if (!canAccess(permissions, PERMS.SETTINGS_VIEW, session.user.rank)) {
|
||||||
redirect("/admin");
|
redirect("/admin");
|
||||||
}
|
}
|
||||||
|
const canEdit = canAccess(
|
||||||
const t = await getTranslations("pages.admin.settings");
|
permissions,
|
||||||
|
PERMS.SETTINGS_EDIT,
|
||||||
|
session.user.rank,
|
||||||
|
);
|
||||||
|
|
||||||
let settings: Awaited<ReturnType<typeof prisma.websiteSetting.findMany>> = [];
|
let settings: Awaited<ReturnType<typeof prisma.websiteSetting.findMany>> = [];
|
||||||
try {
|
try {
|
||||||
@@ -27,103 +37,66 @@ export default async function AdminSettings() {
|
|||||||
settings = [];
|
settings = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const dbValues = new Map(settings.map((s) => [s.key, s.value]));
|
||||||
|
const values: Record<string, string> = Object.fromEntries(
|
||||||
|
MANAGED_SETTING_KEYS.map((key) => [
|
||||||
|
key,
|
||||||
|
dbValues.get(key) ?? FIELD_DEFAULTS[key] ?? "",
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
const documented = settings.filter((s) => !!s.comment).length;
|
const documented = settings.filter((s) => !!s.comment).length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main>
|
<div className="space-y-8">
|
||||||
<div className="flex items-center gap-3 mb-6">
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/20 to-[var(--admin-accent)]/5 grid place-items-center">
|
<div className="flex items-center gap-3">
|
||||||
<span className="text-lg">⚙️</span>
|
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/20 to-[var(--admin-accent)]/5 grid place-items-center">
|
||||||
|
<Cog size={20} className="text-[var(--admin-accent)]" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="m-0 text-2xl font-extrabold tracking-tight text-[var(--admin-text)]">
|
||||||
|
CMS Settings
|
||||||
|
</h1>
|
||||||
|
<p className="m-0 mt-0.5 text-sm text-[var(--admin-text-muted)]">
|
||||||
|
Configure hotel identity, imaging, client, radio and security.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="flex flex-wrap gap-2">
|
||||||
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
|
{RELATED.map((item) => (
|
||||||
{t("title")}
|
<Link
|
||||||
</h1>
|
key={item.href}
|
||||||
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
|
href={item.href}
|
||||||
{t.rich("subtitle", { code: (chunks) => <code>{chunks}</code> })}
|
className="inline-flex items-center gap-1.5 rounded-full border border-[var(--admin-border)] bg-[var(--admin-surface)] px-3 py-1.5 text-xs font-semibold text-[var(--admin-text)] no-underline hover:border-[var(--admin-accent)] transition-colors"
|
||||||
</p>
|
>
|
||||||
|
{item.label}
|
||||||
|
<ExternalLink size={12} className="opacity-60" />
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="admin-stat-row">
|
<div className="admin-stat-row">
|
||||||
<span className="admin-stat-chip">
|
<span className="admin-stat-chip">
|
||||||
{t("settingsCount", { count: settings.length })}
|
{settings.length} keys in database
|
||||||
</span>
|
</span>
|
||||||
<span className="admin-stat-chip">
|
<span className="admin-stat-chip">
|
||||||
{t("documentedCount", { count: documented })}
|
{MANAGED_SETTING_KEYS.length} managed in form
|
||||||
</span>
|
</span>
|
||||||
|
<span className="admin-stat-chip">{documented} documented</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section>
|
<CmsSettingsForm values={values} canEdit={canEdit} />
|
||||||
<h2 className="admin-section-title">{t("addOverwrite")}</h2>
|
|
||||||
<form action={createSetting} className="admin-card">
|
|
||||||
<div className="flex gap-2 flex-wrap">
|
|
||||||
<input name="key" placeholder={t("form.keyPlaceholder")} required />
|
|
||||||
<input
|
|
||||||
name="value"
|
|
||||||
placeholder={t("form.valuePlaceholder")}
|
|
||||||
className="flex-1 min-w-[160px]"
|
|
||||||
/>
|
|
||||||
<input name="comment" placeholder={t("form.commentPlaceholder")} />
|
|
||||||
<button type="submit" className="btn btn-primary">
|
|
||||||
{t("save")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
<AdvancedSettingsPanel
|
||||||
<h2 className="admin-section-title">
|
settings={settings.map((s) => ({
|
||||||
{t("settings")}{" "}
|
key: s.key,
|
||||||
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--admin-accent)]/18 text-[var(--admin-text)]">
|
value: s.value,
|
||||||
{settings.length}
|
comment: s.comment,
|
||||||
</span>
|
}))}
|
||||||
</h2>
|
canEdit={canEdit}
|
||||||
{settings.length === 0 ? (
|
/>
|
||||||
<div className="admin-empty">{t("noSettings")}</div>
|
</div>
|
||||||
) : (
|
|
||||||
<div className="grid">
|
|
||||||
{settings.map((s) => (
|
|
||||||
<div key={s.key} className="admin-card">
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
gap: "0.75rem",
|
|
||||||
alignItems: "baseline",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<strong style={{ minWidth: 220 }}>{s.key}</strong>
|
|
||||||
<form
|
|
||||||
action={updateSetting}
|
|
||||||
style={{ display: "flex", gap: "0.5rem", flex: 1 }}
|
|
||||||
>
|
|
||||||
<input type="hidden" name="key" value={s.key} />
|
|
||||||
<input
|
|
||||||
name="value"
|
|
||||||
defaultValue={s.value}
|
|
||||||
style={{ flex: 1 }}
|
|
||||||
/>
|
|
||||||
<button type="submit" className="btn btn-outline">
|
|
||||||
{t("save")}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
<form action={deleteSetting}>
|
|
||||||
<input type="hidden" name="key" value={s.key} />
|
|
||||||
<button type="submit" className="btn btn-danger">
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
{s.comment ? (
|
|
||||||
<p className="muted" style={{ margin: "0.4rem 0 0" }}>
|
|
||||||
{s.comment}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user