فهرست منبع

Settings polish

Jamie Curnow 1 ماه پیش
والد
کامیت
678593111e

+ 3 - 3
frontend/src/api/backend/models.ts

@@ -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 {

+ 1 - 0
frontend/src/hooks/index.ts

@@ -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";

+ 40 - 0
frontend/src/hooks/useSetting.ts

@@ -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 };

+ 9 - 0
frontend/src/locale/lang/en.json

@@ -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",

+ 27 - 0
frontend/src/locale/src/en.json

@@ -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."
 	},

+ 269 - 0
frontend/src/pages/Settings/DefaultSite.tsx

@@ -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>
+	);
+}

+ 40 - 0
frontend/src/pages/Settings/Layout.tsx

@@ -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>
+	);
+}

+ 0 - 113
frontend/src/pages/Settings/SettingTable.tsx

@@ -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>
-	);
-}

+ 2 - 2
frontend/src/pages/Settings/index.tsx

@@ -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>
 	);
 };