feat(housekeeping): cut over administration to ase
This commit is contained in:
1 parent
c9e35cf602
commit
2b8f73a91d
457 files changed
+589
-55936
No files matched your search
@@ -1,147 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,343 +0,0 @@
|
||||
import { HABBO_GAMEDATA_HOTELS } from "@/lib/habbo-gamedata-hotel";
|
||||
|
||||
export type FieldType =
|
||||
| "text"
|
||||
| "password"
|
||||
| "url"
|
||||
| "number"
|
||||
| "boolean"
|
||||
| "textarea"
|
||||
| "select";
|
||||
|
||||
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;
|
||||
options?: Array<{ value: string; label: 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: "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:
|
||||
"Minimum rank treated as staff (admin lists and 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: "Public imager URL",
|
||||
type: "url",
|
||||
defaultValue: "/imaging",
|
||||
description:
|
||||
"Public avatar endpoint (relative or absolute). Leave as /imaging to serve from this site.",
|
||||
},
|
||||
{
|
||||
key: "imaging_use_habbo_fallback",
|
||||
label: "Use Habbo.com as avatar fallback (legacy proxy)",
|
||||
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",
|
||||
},
|
||||
{
|
||||
key: "gamedata_root",
|
||||
label: "Production Gamedata root (server path)",
|
||||
description:
|
||||
"Filesystem root served at /gamedata. Auto-detected as /var/www/Gamedata when present.",
|
||||
type: "text",
|
||||
placeholder: "/var/www/Gamedata",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "assets",
|
||||
title: "Game assets",
|
||||
icon: "music",
|
||||
description: "Public URLs and overrides for SWF / Nitro asset packages.",
|
||||
fields: [
|
||||
{
|
||||
key: "furnidata_translate_enabled",
|
||||
label: "Translate furniture names",
|
||||
description:
|
||||
"Rebuild FurnitureData_<lang>.json with translated names after each import. Disable to skip the LibreTranslate / deep-clone work and save CPU & RAM when you don't need localized furniture names. You can still rebuild on demand via the import 'build languages' action.",
|
||||
type: "boolean",
|
||||
defaultValue: "1",
|
||||
},
|
||||
{
|
||||
key: "habbo_gamedata_hotel",
|
||||
label: "Habbo hotel for furni names",
|
||||
description:
|
||||
"Official Habbo locale used for catalog name suggestions, furni import enrichment, and badge text lookup (furnidata / external texts).",
|
||||
type: "select",
|
||||
defaultValue: "it",
|
||||
options: HABBO_GAMEDATA_HOTELS.map((h) => ({
|
||||
value: h.value,
|
||||
label: h.label,
|
||||
})),
|
||||
},
|
||||
{
|
||||
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: "require_email_verification",
|
||||
label: "Require email verification",
|
||||
description:
|
||||
"Block login until the account email is verified (accounts without email are unaffected).",
|
||||
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 | hcaptcha",
|
||||
type: "text",
|
||||
defaultValue: "none",
|
||||
},
|
||||
{
|
||||
key: "turnstile_site_key",
|
||||
label: "Turnstile site key",
|
||||
type: "text",
|
||||
},
|
||||
{
|
||||
key: "hcaptcha_site_key",
|
||||
label: "hCaptcha 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]),
|
||||
),
|
||||
);
|
||||
@@ -1,235 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Building2,
|
||||
Gamepad2,
|
||||
ImageIcon,
|
||||
Link as LinkIcon,
|
||||
Loader2,
|
||||
Music,
|
||||
Save,
|
||||
Shield,
|
||||
UserPlus,
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
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();
|
||||
const t = useTranslations("pages.admin.settings");
|
||||
|
||||
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: t("saved"),
|
||||
errorMessage: t("saveFailed"),
|
||||
});
|
||||
}
|
||||
|
||||
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" />
|
||||
)}
|
||||
{isPending ? t("form.saving") : t("saveSettings")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">{t("viewOnly")}</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}
|
||||
/>
|
||||
) : type === "select" ? (
|
||||
<select
|
||||
id={field.key}
|
||||
value={value}
|
||||
disabled={!canEdit}
|
||||
onChange={(e) => onChange(field.key, e.target.value)}
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{(field.options ?? []).map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<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,74 +0,0 @@
|
||||
import { asc } from "drizzle-orm";
|
||||
import { Settings } from "lucide-react";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { AdminPageShell } from "@/components/admin/admin-page-shell";
|
||||
import { db, WebsiteSetting } from "@/lib/db";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { AdvancedSettingsPanel } from "./advanced-settings-panel";
|
||||
import { FIELD_DEFAULTS, MANAGED_SETTING_KEYS } from "./cms-settings-config";
|
||||
import { CmsSettingsForm } from "./cms-settings-form";
|
||||
|
||||
export const metadata = { title: "CMS Settings" };
|
||||
|
||||
export default async function AdminSettings() {
|
||||
const { session, permissions } = await getAdminContext();
|
||||
if (!canAccess(permissions, PERMS.SETTINGS_VIEW, session.user.rank)) {
|
||||
redirect("/admin");
|
||||
}
|
||||
const canEdit = canAccess(
|
||||
permissions,
|
||||
PERMS.SETTINGS_EDIT,
|
||||
session.user.rank,
|
||||
);
|
||||
const t = await getTranslations("pages.admin.settings");
|
||||
|
||||
let settings: (typeof WebsiteSetting.$inferSelect)[] = [];
|
||||
try {
|
||||
settings = await db
|
||||
.select()
|
||||
.from(WebsiteSetting)
|
||||
.orderBy(asc(WebsiteSetting.key));
|
||||
} catch {
|
||||
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;
|
||||
|
||||
return (
|
||||
<AdminPageShell icon={Settings} title={t("title")} subtitle={t("subtitle")}>
|
||||
<div className="space-y-8">
|
||||
<div className="admin-stat-row">
|
||||
<span className="admin-stat-chip">
|
||||
{t("keysInDatabase", { count: settings.length })}
|
||||
</span>
|
||||
<span className="admin-stat-chip">
|
||||
{t("managedInForm", { count: MANAGED_SETTING_KEYS.length })}
|
||||
</span>
|
||||
<span className="admin-stat-chip">
|
||||
{t("documented", { count: documented })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<CmsSettingsForm values={values} canEdit={canEdit} />
|
||||
|
||||
<AdvancedSettingsPanel
|
||||
settings={settings.map((s) => ({
|
||||
key: s.key,
|
||||
value: s.value,
|
||||
comment: s.comment,
|
||||
}))}
|
||||
canEdit={canEdit}
|
||||
/>
|
||||
</div>
|
||||
</AdminPageShell>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user