1
0
mirror of https://github.com/NginxProxyManager/nginx-proxy-manager.git synced 2025-10-29 06:49:22 +03:00

Settings polish

This commit is contained in:
Jamie Curnow
2025-10-28 22:12:00 +10:00
parent c08b1be3cb
commit 678593111e
9 changed files with 391 additions and 118 deletions

View File

@@ -196,10 +196,10 @@ export interface Stream {
export interface Setting {
id: string;
name: string;
description: string;
name?: string;
description?: string;
value: string;
meta: Record<string, any>;
meta?: Record<string, any>;
}
export interface DNSProvider {

View File

@@ -13,6 +13,7 @@ export * from "./useProxyHost";
export * from "./useProxyHosts";
export * from "./useRedirectionHost";
export * from "./useRedirectionHosts";
export * from "./useSetting";
export * from "./useStream";
export * from "./useStreams";
export * from "./useTheme";

View File

@@ -0,0 +1,40 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { getSetting, type Setting, updateSetting } from "src/api/backend";
const fetchSetting = (id: string) => {
return getSetting(id);
};
const useSetting = (id: string, options = {}) => {
return useQuery<Setting, Error>({
queryKey: ["setting", id],
queryFn: () => fetchSetting(id),
staleTime: 60 * 1000, // 1 minute
...options,
});
};
const useSetSetting = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (values: Setting) => updateSetting(values),
onMutate: (values: Setting) => {
if (!values.id) {
return;
}
const previousObject = queryClient.getQueryData(["setting", values.id]);
queryClient.setQueryData(["setting", values.id], (old: Setting) => ({
...old,
...values,
}));
return () => queryClient.setQueryData(["setting", values.id], previousObject);
},
onError: (_, __, rollback: any) => rollback(),
onSuccess: async ({ id }: Setting) => {
queryClient.invalidateQueries({ queryKey: ["setting", id] });
queryClient.invalidateQueries({ queryKey: ["audit-logs"] });
},
});
};
export { useSetting, useSetSetting };

View File

@@ -168,7 +168,16 @@
"role.admin": "Administrator",
"role.standard-user": "Standard User",
"save": "Save",
"setting": "Setting",
"settings": "Settings",
"settings.default-site": "Default Site",
"settings.default-site.404": "404 Page",
"settings.default-site.444": "No Response (444)",
"settings.default-site.congratulations": "Congratulations Page",
"settings.default-site.description": "What to show when Nginx is hit with an unknown Host",
"settings.default-site.html": "Custom HTML",
"settings.default-site.html.placeholder": "<!-- Enter your custom HTML content here -->",
"settings.default-site.redirect": "Redirect",
"setup.preamble": "Get started by creating your admin account.",
"setup.title": "Welcome!",
"sign-in": "Sign in",

View File

@@ -506,9 +506,36 @@
"save": {
"defaultMessage": "Save"
},
"setting": {
"defaultMessage": "Setting"
},
"settings": {
"defaultMessage": "Settings"
},
"settings.default-site": {
"defaultMessage": "Default Site"
},
"settings.default-site.404": {
"defaultMessage": "404 Page"
},
"settings.default-site.444": {
"defaultMessage": "No Response (444)"
},
"settings.default-site.congratulations": {
"defaultMessage": "Congratulations Page"
},
"settings.default-site.description": {
"defaultMessage": "What to show when Nginx is hit with an unknown Host"
},
"settings.default-site.html": {
"defaultMessage": "Custom HTML"
},
"settings.default-site.html.placeholder": {
"defaultMessage": "<!-- Enter your custom HTML content here -->"
},
"settings.default-site.redirect": {
"defaultMessage": "Redirect"
},
"setup.preamble": {
"defaultMessage": "Get started by creating your admin account."
},

View File

@@ -0,0 +1,269 @@
import CodeEditor from "@uiw/react-textarea-code-editor";
import { Field, Form, Formik } from "formik";
import { type ReactNode, useState } from "react";
import { Alert } from "react-bootstrap";
import { Button, Loading } from "src/components";
import { useSetSetting, useSetting } from "src/hooks";
import { intl, T } from "src/locale";
import { validateString } from "src/modules/Validations";
import { showObjectSuccess } from "src/notifications";
export default function DefaultSite() {
const { data, isLoading, error } = useSetting("default-site");
const { mutate: setSetting } = useSetSetting();
const [errorMsg, setErrorMsg] = useState<ReactNode | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const onSubmit = async (values: any, { setSubmitting }: any) => {
if (isSubmitting) return;
setIsSubmitting(true);
setErrorMsg(null);
const payload = {
id: "default-site",
value: values.value,
meta: {
redirect: values.redirect,
html: values.html,
},
};
setSetting(payload, {
onError: (err: any) => setErrorMsg(<T id={err.message} />),
onSuccess: () => {
showObjectSuccess("setting", "saved");
},
onSettled: () => {
setIsSubmitting(false);
setSubmitting(false);
},
});
};
if (!isLoading && error) {
return (
<div className="card-body">
<div className="mb-3">
<Alert variant="danger" show>
{error.message}
</Alert>
</div>
</div>
);
}
if (isLoading) {
return (
<div className="card-body">
<div className="mb-3">
<Loading noLogo />
</div>
</div>
);
}
return (
<Formik
initialValues={
{
value: data?.value || "congratulations",
redirect: data?.meta?.redirect || "",
html: data?.meta?.html || "",
} as any
}
onSubmit={onSubmit}
>
{({ values }) => (
<Form>
<div className="card-body">
<Alert variant="danger" show={!!errorMsg} onClose={() => setErrorMsg(null)} dismissible>
{errorMsg}
</Alert>
<Field name="value">
{({ field, form }: any) => (
<div className="mb-3">
<label className="form-label" htmlFor="setting-host-unknown">
<T id="settings.default-site.description" />
</label>
<div className="form-selectgroup form-selectgroup-boxes d-flex flex-column">
<label className="form-selectgroup-item flex-fill">
<input
type="radio"
name={field.name}
value="congratulations"
className="form-selectgroup-input"
checked={field.value === "congratulations"}
onChange={(e) => form.setFieldValue(field.name, e.target.value)}
/>
<div className="form-selectgroup-label d-flex align-items-center p-3">
<div className="me-3">
<span className="form-selectgroup-check" />
</div>
<div>
<T id="settings.default-site.congratulations" />
</div>
</div>
</label>
<label className="form-selectgroup-item flex-fill">
<input
type="radio"
name={field.name}
value="404"
className="form-selectgroup-input"
checked={field.value === "404"}
onChange={(e) => form.setFieldValue(field.name, e.target.value)}
/>
<div className="form-selectgroup-label d-flex align-items-center p-3">
<div className="me-3">
<span className="form-selectgroup-check" />
</div>
<div>
<T id="settings.default-site.404" />
</div>
</div>
</label>
<label className="form-selectgroup-item flex-fill">
<input
type="radio"
name={field.name}
value="444"
className="form-selectgroup-input"
checked={field.value === "444"}
onChange={(e) => form.setFieldValue(field.name, e.target.value)}
/>
<div className="form-selectgroup-label d-flex align-items-center p-3">
<div className="me-3">
<span className="form-selectgroup-check" />
</div>
<div>
<T id="settings.default-site.444" />
</div>
</div>
</label>
<label className="form-selectgroup-item flex-fill">
<input
type="radio"
name={field.name}
value="redirect"
className="form-selectgroup-input"
checked={field.value === "redirect"}
onChange={(e) => form.setFieldValue(field.name, e.target.value)}
/>
<div className="form-selectgroup-label d-flex align-items-center p-3">
<div className="me-3">
<span className="form-selectgroup-check" />
</div>
<div>
<T id="settings.default-site.redirect" />
</div>
</div>
</label>
<label className="form-selectgroup-item flex-fill">
<input
type="radio"
name={field.name}
value="html"
className="form-selectgroup-input"
checked={field.value === "html"}
onChange={(e) => form.setFieldValue(field.name, e.target.value)}
/>
<div className="form-selectgroup-label d-flex align-items-center p-3">
<div className="me-3">
<span className="form-selectgroup-check" />
</div>
<div>
<T id="settings.default-site.redirect" />
</div>
</div>
</label>
</div>
</div>
)}
</Field>
{values.value === "redirect" && (
<Field name="redirect" validate={validateString(1, 255)}>
{({ field, form }: any) => (
<div className="mt-5 mb-3">
<label className="form-label" htmlFor="setting-host-unknown">
<T id="settings.default-site.redirect" />
</label>
<div>
<input
id="redirect"
type="text"
placeholder="https://"
required
autoComplete="off"
className="form-control"
{...field}
/>
{form.errors.redirect ? (
<div className="invalid-feedback">
{form.errors.redirect && form.touched.redirect
? form.errors.redirect
: null}
</div>
) : null}
</div>
</div>
)}
</Field>
)}
{values.value === "html" && (
<Field name="html" validate={validateString(1)}>
{({ field, form }: any) => (
<div className="mt-5 mb-3">
<label className="form-label" htmlFor="setting-host-unknown">
<T id="settings.default-site.html" />
</label>
<div>
<CodeEditor
// Believe it or not, 'html' sucks yet 'php' renders the html
// content much nicer.
language="php"
placeholder={intl.formatMessage({
id: "settings.default-site.html.placeholder",
})}
padding={15}
data-color-mode="dark"
minHeight={300}
indentWidth={2}
style={{
fontFamily:
"ui-monospace,SFMono-Regular,SF Mono,Consolas,Liberation Mono,Menlo,monospace",
borderRadius: "0.3rem",
minHeight: "300px",
backgroundColor: "var(--tblr-bg-surface-dark)",
}}
{...field}
/>
{form.errors.html ? (
<div className="invalid-feedback">
{form.errors.html && form.touched.html ? form.errors.html : null}
</div>
) : null}
</div>
</div>
)}
</Field>
)}
</div>
<div className="card-footer bg-transparent mt-auto">
<div className="btn-list justify-content-end">
<Button
type="submit"
actionType="primary"
className="ms-auto bg-teal"
data-bs-dismiss="modal"
isLoading={isSubmitting}
disabled={isSubmitting}
>
<T id="save" />
</Button>
</div>
</div>
</Form>
)}
</Formik>
);
}

View File

@@ -0,0 +1,40 @@
import { T } from "src/locale";
import DefaultSite from "./DefaultSite";
export default function Layout() {
// Taken from https://preview.tabler.io/settings.html
// Refer to that when updating this content
return (
<div className="card mt-4">
<div className="card-status-top bg-teal" />
<div className="card-table">
<div className="card-header">
<div className="row w-full">
<h2 className="mt-1 mb-0">
<T id="settings" />
</h2>
</div>
</div>
<div className="row g-0">
<div className="col-12 col-md-3 border-end">
<div className="card-body mt-0 pt-0">
<div className="list-group list-group-transparent">
<a
href="#"
className="list-group-item list-group-item-action d-flex align-items-center active"
onClick={(e) => e.preventDefault()}
>
<T id="settings.default-site" />
</a>
</div>
</div>
</div>
<div className="col-12 col-md-9 d-flex flex-column">
<DefaultSite />
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,113 +0,0 @@
import { IconDotsVertical, IconEdit, IconPower, IconTrash } from "@tabler/icons-react";
import { T } from "src/locale";
export default function SettingTable() {
return (
<div className="card mt-4">
<div className="card-status-top bg-teal" />
<div className="card-table">
<div className="card-header">
<div className="row w-full">
<h2 className="mt-1 mb-0">
<T id="settings" />
</h2>
</div>
</div>
<div id="advanced-table">
<div className="table-responsive">
<table className="table table-vcenter table-selectable">
<thead>
<tr>
<th className="w-1" />
<th>
<button type="button" className="table-sort d-flex justify-content-between">
Source
</button>
</th>
<th>
<button type="button" className="table-sort d-flex justify-content-between">
Destination
</button>
</th>
<th>
<button type="button" className="table-sort d-flex justify-content-between">
SSL
</button>
</th>
<th>
<button type="button" className="table-sort d-flex justify-content-between">
Access
</button>
</th>
<th>
<button type="button" className="table-sort d-flex justify-content-between">
Status
</button>
</th>
<th className="w-1" />
</tr>
</thead>
<tbody className="table-tbody">
<tr>
<td data-label="Owner">
<div className="d-flex py-1 align-items-center">
<span
className="avatar avatar-2 me-2"
style={{
backgroundImage:
"url(//www.gravatar.com/avatar/6193176330f8d38747f038c170ddb193?default=mm)",
}}
/>
</div>
</td>
<td data-label="Destination">
<div className="flex-fill">
<div className="font-weight-medium">
<span className="badge badge-lg domain-name">blog.jc21.com</span>
</div>
<div className="text-secondary mt-1">Created: 20th September 2024</div>
</div>
</td>
<td data-label="Source">http://172.17.0.1:3001</td>
<td data-label="SSL">Let's Encrypt</td>
<td data-label="Access">Public</td>
<td data-label="Status">
<span className="badge bg-lime-lt">Online</span>
</td>
<td data-label="Status" className="text-end">
<span className="dropdown">
<button
type="button"
className="btn dropdown-toggle btn-action btn-sm px-1"
data-bs-boundary="viewport"
data-bs-toggle="dropdown"
>
<IconDotsVertical />
</button>
<div className="dropdown-menu dropdown-menu-end">
<span className="dropdown-header">Proxy Host #2</span>
<a className="dropdown-item" href="#">
<IconEdit size={16} />
Edit
</a>
<a className="dropdown-item" href="#">
<IconPower size={16} />
Disable
</a>
<div className="dropdown-divider" />
<a className="dropdown-item" href="#">
<IconTrash size={16} />
Delete
</a>
</div>
</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,10 +1,10 @@
import { HasPermission } from "src/components";
import SettingTable from "./SettingTable";
import Layout from "./Layout";
const Settings = () => {
return (
<HasPermission permission="admin" type="manage" pageLoading loadingNoLogo>
<SettingTable />
<Layout />
</HasPermission>
);
};