mirror of
https://github.com/NginxProxyManager/nginx-proxy-manager.git
synced 2026-09-22 01:34:58 +01:00
Collapse custom locations into a filterable list
The Custom Locations tab rendered every location fully expanded, which becomes unusable once a host has more than a handful of them. Each row is now a collapsible card showing its path and forward target, collapsed by default, with the Add Location button moved to the top so it stays reachable without scrolling to the end of the list. A filter appears once there are five or more locations and matches on the path as well as the forward scheme, host and port. New locations are still appended to the end of the list, so the generated nginx config is unchanged. Also fixes two existing issues in this component: the per-location inputs shared the same DOM ids across rows, and the advanced-config toggle state was keyed by array index, so removing a location moved it onto the wrong row.
This commit is contained in:
@@ -1,3 +1,51 @@
|
||||
/* card-active points --tblr-card-border-color at --tblr-primary, which both the
|
||||
card outline and the card header's bottom border are drawn from. Tabler's
|
||||
stylesheet is loaded after this one, so the override needs !important. */
|
||||
.locationCard {
|
||||
border-color: light-dark(var(--tblr-gray-200), var(--tblr-gray-700)) !important;
|
||||
--tblr-card-border-color: light-dark(var(--tblr-gray-200), var(--tblr-gray-700)) !important;
|
||||
}
|
||||
|
||||
.filter {
|
||||
max-width: 20rem;
|
||||
}
|
||||
|
||||
/* The header is a plain toggle rather than a button-styled control, so that a
|
||||
list of collapsed locations reads as rows instead of a stack of buttons. */
|
||||
.toggle {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
align-self: stretch;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.toggle:focus-visible {
|
||||
outline: 2px solid var(--tblr-primary);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
/* Keeps the marker on one line next to the delete button, and lets it drop out
|
||||
of the way before the path does when the row runs out of room. */
|
||||
.marker {
|
||||
display: flex;
|
||||
flex: 0 1 auto;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.path {
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.summary {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -1,20 +1,43 @@
|
||||
import { IconSettings } from "@tabler/icons-react";
|
||||
import {
|
||||
IconChevronDown,
|
||||
IconChevronRight,
|
||||
IconPlus,
|
||||
IconSearch,
|
||||
IconSettings,
|
||||
IconTrash,
|
||||
IconX,
|
||||
} from "@tabler/icons-react";
|
||||
import CodeEditor from "@uiw/react-textarea-code-editor";
|
||||
import cn from "classnames";
|
||||
import { useFormikContext } from "formik";
|
||||
import { useState } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import type { ProxyLocation } from "src/api/backend";
|
||||
import { intl, T } from "src/locale";
|
||||
import styles from "./LocationsFields.module.css";
|
||||
|
||||
// Below this many locations the list is short enough to scan by eye, and the
|
||||
// filter would only take up space.
|
||||
const FILTER_THRESHOLD = 5;
|
||||
|
||||
// Locations are identified by a client-side id rather than their array index,
|
||||
// so that expanded/advanced state stays with the right row when one is removed.
|
||||
interface Row {
|
||||
id: number;
|
||||
value: ProxyLocation;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
initialValues: ProxyLocation[];
|
||||
name?: string;
|
||||
}
|
||||
export function LocationsFields({ initialValues, name = "locations" }: Props) {
|
||||
const [values, setValues] = useState<ProxyLocation[]>(initialValues || []);
|
||||
const [rows, setRows] = useState<Row[]>(() => (initialValues || []).map((value, id) => ({ id, value })));
|
||||
const { setFieldValue } = useFormikContext();
|
||||
const [expanded, setExpanded] = useState<number[]>([]);
|
||||
const [advVisible, setAdvVisible] = useState<number[]>([]);
|
||||
const [filter, setFilter] = useState("");
|
||||
const nextId = useRef(rows.length);
|
||||
const scrollToId = useRef<number | null>(null);
|
||||
|
||||
const blankItem: ProxyLocation = {
|
||||
path: "",
|
||||
@@ -24,32 +47,62 @@ export function LocationsFields({ initialValues, name = "locations" }: Props) {
|
||||
forwardPort: 80,
|
||||
};
|
||||
|
||||
const toggleAdvVisible = (idx: number) => {
|
||||
setAdvVisible(advVisible.includes(idx) ? advVisible.filter((i) => i !== idx) : [...advVisible, idx]);
|
||||
const toggleExpanded = (id: number) => {
|
||||
setExpanded(expanded.includes(id) ? expanded.filter((i) => i !== id) : [...expanded, id]);
|
||||
};
|
||||
|
||||
const toggleAdvVisible = (id: number) => {
|
||||
setAdvVisible(advVisible.includes(id) ? advVisible.filter((i) => i !== id) : [...advVisible, id]);
|
||||
};
|
||||
|
||||
const handleAdd = () => {
|
||||
setValues([...values, blankItem]);
|
||||
const id = nextId.current++;
|
||||
setRows([...rows, { id, value: blankItem }]);
|
||||
// A new location starts empty, so open it and make sure an active filter
|
||||
// doesn't hide the row that was just added.
|
||||
setExpanded([...expanded, id]);
|
||||
setFilter("");
|
||||
scrollToId.current = id;
|
||||
};
|
||||
|
||||
const handleRemove = (idx: number) => {
|
||||
const newValues = values.filter((_: ProxyLocation, i: number) => i !== idx);
|
||||
setValues(newValues);
|
||||
setFormField(newValues);
|
||||
const handleRemove = (id: number) => {
|
||||
const newRows = rows.filter((r: Row) => r.id !== id);
|
||||
setRows(newRows);
|
||||
setExpanded(expanded.filter((i) => i !== id));
|
||||
setAdvVisible(advVisible.filter((i) => i !== id));
|
||||
setFormField(newRows);
|
||||
};
|
||||
|
||||
const handleChange = (idx: number, field: string, fieldValue: string) => {
|
||||
const newValues = values.map((v: ProxyLocation, i: number) => (i === idx ? { ...v, [field]: fieldValue } : v));
|
||||
setValues(newValues);
|
||||
setFormField(newValues);
|
||||
const handleChange = (id: number, field: string, fieldValue: string) => {
|
||||
const newRows = rows.map((r: Row) => (r.id === id ? { ...r, value: { ...r.value, [field]: fieldValue } } : r));
|
||||
setRows(newRows);
|
||||
setFormField(newRows);
|
||||
};
|
||||
|
||||
const setFormField = (newValues: ProxyLocation[]) => {
|
||||
const filtered = newValues.filter((v: ProxyLocation) => v?.path?.trim() !== "");
|
||||
const setFormField = (newRows: Row[]) => {
|
||||
const filtered = newRows.map((r: Row) => r.value).filter((v: ProxyLocation) => v?.path?.trim() !== "");
|
||||
setFieldValue(name, filtered);
|
||||
};
|
||||
|
||||
if (values.length === 0) {
|
||||
const forwardSummary = (item: ProxyLocation) => {
|
||||
if (!item.forwardHost) {
|
||||
return "";
|
||||
}
|
||||
return `${item.forwardScheme}://${item.forwardHost}${item.forwardPort ? `:${item.forwardPort}` : ""}`;
|
||||
};
|
||||
|
||||
// Matches the path as well as the destination, so a location can be found by
|
||||
// the host or port it forwards to and not just by its path.
|
||||
const matchesFilter = (item: ProxyLocation, query: string) =>
|
||||
[item.path, item.forwardScheme, item.forwardHost, item.forwardPort, forwardSummary(item)]
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
.includes(query);
|
||||
|
||||
const query = filter.trim().toLowerCase();
|
||||
const visibleRows = query ? rows.filter((r: Row) => matchesFilter(r.value, query)) : rows;
|
||||
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<div className="text-center">
|
||||
<button type="button" className="btn my-3" onClick={handleAdd}>
|
||||
@@ -61,125 +114,217 @@ export function LocationsFields({ initialValues, name = "locations" }: Props) {
|
||||
|
||||
return (
|
||||
<>
|
||||
{values.map((item: ProxyLocation, idx: number) => (
|
||||
<div key={idx} className={cn("card", "card-active", "mb-3", styles.locationCard)}>
|
||||
<div className="card-body">
|
||||
<div className="row">
|
||||
<div className="col-md-10">
|
||||
<div className="input-group mb-3">
|
||||
<span className="input-group-text">Location</span>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
placeholder="/path"
|
||||
autoComplete="off"
|
||||
value={item.path}
|
||||
onChange={(e) => handleChange(idx, "path", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-md-2 text-end">
|
||||
<button
|
||||
type="button"
|
||||
className="btn p-0"
|
||||
title="Advanced"
|
||||
onClick={() => toggleAdvVisible(idx)}
|
||||
>
|
||||
<IconSettings size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="row">
|
||||
<div className="col-md-3">
|
||||
<div className="mb-3">
|
||||
<label className="form-label" htmlFor="forwardScheme">
|
||||
<T id="host.forward-scheme" />
|
||||
</label>
|
||||
<select
|
||||
id="forwardScheme"
|
||||
className="form-control"
|
||||
value={item.forwardScheme}
|
||||
onChange={(e) => handleChange(idx, "forwardScheme", e.target.value)}
|
||||
>
|
||||
<option value="http">http</option>
|
||||
<option value="https">https</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-md-6">
|
||||
<div className="mb-3">
|
||||
<label className="form-label" htmlFor="forwardHost">
|
||||
<T id="proxy-host.forward-host" />
|
||||
</label>
|
||||
<input
|
||||
id="forwardHost"
|
||||
type="text"
|
||||
className="form-control"
|
||||
required
|
||||
placeholder="eg: 10.0.0.1/path/"
|
||||
value={item.forwardHost}
|
||||
onChange={(e) => handleChange(idx, "forwardHost", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-md-3">
|
||||
<div className="mb-3">
|
||||
<label className="form-label" htmlFor="forwardPort">
|
||||
<T id="host.forward-port" />
|
||||
</label>
|
||||
<input
|
||||
id="forwardPort"
|
||||
type="number"
|
||||
min={1}
|
||||
max={65535}
|
||||
className="form-control"
|
||||
required
|
||||
placeholder="eg: 8081"
|
||||
value={item.forwardPort}
|
||||
onChange={(e) => handleChange(idx, "forwardPort", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{advVisible.includes(idx) && (
|
||||
<div className="">
|
||||
<CodeEditor
|
||||
language="nginx"
|
||||
placeholder={intl.formatMessage({ id: "nginx-config.placeholder" })}
|
||||
padding={15}
|
||||
data-color-mode="dark"
|
||||
minHeight={170}
|
||||
indentWidth={2}
|
||||
value={item.advancedConfig}
|
||||
onChange={(e) => handleChange(idx, "advancedConfig", e.target.value)}
|
||||
style={{
|
||||
fontFamily:
|
||||
"ui-monospace,SFMono-Regular,SF Mono,Consolas,Liberation Mono,Menlo,monospace",
|
||||
borderRadius: "0.3rem",
|
||||
minHeight: "170px",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-1">
|
||||
<a
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleRemove(idx);
|
||||
}}
|
||||
<div className="d-flex align-items-center mb-3">
|
||||
{rows.length >= FILTER_THRESHOLD && (
|
||||
<div className={cn("input-group", styles.filter)}>
|
||||
<span className="input-group-text">
|
||||
<IconSearch size={16} />
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
autoComplete="off"
|
||||
placeholder={intl.formatMessage({ id: "location.filter" })}
|
||||
aria-label={intl.formatMessage({ id: "location.filter" })}
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
/>
|
||||
{filter ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-icon"
|
||||
title={intl.formatMessage({ id: "action.clear" })}
|
||||
aria-label={intl.formatMessage({ id: "action.clear" })}
|
||||
onClick={() => setFilter("")}
|
||||
>
|
||||
<T id="action.delete" />
|
||||
</a>
|
||||
</div>
|
||||
<IconX size={16} />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div>
|
||||
<button type="button" className="btn btn-sm" onClick={handleAdd}>
|
||||
)}
|
||||
<button type="button" className="btn ms-auto" onClick={handleAdd}>
|
||||
<IconPlus size={16} className="me-1" />
|
||||
<T id="action.add-location" />
|
||||
</button>
|
||||
</div>
|
||||
{visibleRows.length === 0 ? (
|
||||
<div className="text-secondary text-center my-3">
|
||||
<T id="empty-search" />
|
||||
</div>
|
||||
) : (
|
||||
visibleRows.map((row: Row) => {
|
||||
const item = row.value;
|
||||
const isOpen = expanded.includes(row.id);
|
||||
const bodyId = `location-body-${row.id}`;
|
||||
return (
|
||||
<div
|
||||
key={row.id}
|
||||
ref={(node) => {
|
||||
if (node && scrollToId.current === row.id) {
|
||||
scrollToId.current = null;
|
||||
node.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
}}
|
||||
className={cn("card", "card-active", "mb-2", styles.locationCard)}
|
||||
>
|
||||
<div className={cn("card-header", "p-2", !isOpen && "border-bottom-0")}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.toggle}
|
||||
aria-expanded={isOpen}
|
||||
aria-controls={bodyId}
|
||||
onClick={() => toggleExpanded(row.id)}
|
||||
>
|
||||
{isOpen ? <IconChevronDown size={16} /> : <IconChevronRight size={16} />}
|
||||
<span className={cn("ms-2", styles.path)}>{item.path}</span>
|
||||
<span className={cn("ms-2", "text-secondary", styles.summary)}>
|
||||
{forwardSummary(item)}
|
||||
</span>
|
||||
</button>
|
||||
{item.advancedConfig ? (
|
||||
// Deliberately the same icon as the advanced-config toggle in the
|
||||
// body below, so the marker reads as "this row has that section
|
||||
// filled in" rather than as a decoration of its own.
|
||||
<span
|
||||
className={cn("ms-2", "text-secondary", styles.marker)}
|
||||
role="img"
|
||||
title={intl.formatMessage({ id: "location.advanced-config" })}
|
||||
aria-label={intl.formatMessage({ id: "location.advanced-config" })}
|
||||
>
|
||||
<IconSettings size={16} />
|
||||
</span>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-action ms-2"
|
||||
title={intl.formatMessage({ id: "action.delete" })}
|
||||
aria-label={intl.formatMessage({ id: "action.delete" })}
|
||||
onClick={() => handleRemove(row.id)}
|
||||
>
|
||||
<IconTrash size={16} className="icon" />
|
||||
</button>
|
||||
</div>
|
||||
{isOpen && (
|
||||
<div className="card-body" id={bodyId}>
|
||||
<div className="row">
|
||||
<div className="col-md-10">
|
||||
<div className="input-group mb-3">
|
||||
<span className="input-group-text">Location</span>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
placeholder="/path"
|
||||
autoComplete="off"
|
||||
value={item.path}
|
||||
onChange={(e) => handleChange(row.id, "path", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-md-2 text-end">
|
||||
<button
|
||||
type="button"
|
||||
className="btn p-0"
|
||||
title="Advanced"
|
||||
aria-expanded={advVisible.includes(row.id)}
|
||||
onClick={() => toggleAdvVisible(row.id)}
|
||||
>
|
||||
<IconSettings size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="row">
|
||||
<div className="col-md-3">
|
||||
<div className="mb-3">
|
||||
<label
|
||||
className="form-label"
|
||||
htmlFor={`location-forwardScheme-${row.id}`}
|
||||
>
|
||||
<T id="host.forward-scheme" />
|
||||
</label>
|
||||
<select
|
||||
id={`location-forwardScheme-${row.id}`}
|
||||
className="form-control"
|
||||
value={item.forwardScheme}
|
||||
onChange={(e) =>
|
||||
handleChange(row.id, "forwardScheme", e.target.value)
|
||||
}
|
||||
>
|
||||
<option value="http">http</option>
|
||||
<option value="https">https</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-md-6">
|
||||
<div className="mb-3">
|
||||
<label
|
||||
className="form-label"
|
||||
htmlFor={`location-forwardHost-${row.id}`}
|
||||
>
|
||||
<T id="proxy-host.forward-host" />
|
||||
</label>
|
||||
<input
|
||||
id={`location-forwardHost-${row.id}`}
|
||||
type="text"
|
||||
className="form-control"
|
||||
required
|
||||
placeholder="eg: 10.0.0.1/path/"
|
||||
value={item.forwardHost}
|
||||
onChange={(e) =>
|
||||
handleChange(row.id, "forwardHost", e.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-md-3">
|
||||
<div className="mb-3">
|
||||
<label
|
||||
className="form-label"
|
||||
htmlFor={`location-forwardPort-${row.id}`}
|
||||
>
|
||||
<T id="host.forward-port" />
|
||||
</label>
|
||||
<input
|
||||
id={`location-forwardPort-${row.id}`}
|
||||
type="number"
|
||||
min={1}
|
||||
max={65535}
|
||||
className="form-control"
|
||||
required
|
||||
placeholder="eg: 8081"
|
||||
value={item.forwardPort}
|
||||
onChange={(e) =>
|
||||
handleChange(row.id, "forwardPort", e.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{advVisible.includes(row.id) && (
|
||||
<div className="">
|
||||
<CodeEditor
|
||||
language="nginx"
|
||||
placeholder={intl.formatMessage({ id: "nginx-config.placeholder" })}
|
||||
padding={15}
|
||||
data-color-mode="dark"
|
||||
minHeight={170}
|
||||
indentWidth={2}
|
||||
value={item.advancedConfig}
|
||||
onChange={(e) => handleChange(row.id, "advancedConfig", e.target.value)}
|
||||
style={{
|
||||
fontFamily:
|
||||
"ui-monospace,SFMono-Regular,SF Mono,Consolas,Liberation Mono,Menlo,monospace",
|
||||
borderRadius: "0.3rem",
|
||||
minHeight: "170px",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -101,6 +101,9 @@
|
||||
"action.allow": {
|
||||
"defaultMessage": "Allow"
|
||||
},
|
||||
"action.clear": {
|
||||
"defaultMessage": "Clear"
|
||||
},
|
||||
"action.close": {
|
||||
"defaultMessage": "Close"
|
||||
},
|
||||
@@ -461,6 +464,12 @@
|
||||
"loading": {
|
||||
"defaultMessage": "Loading…"
|
||||
},
|
||||
"location.advanced-config": {
|
||||
"defaultMessage": "Has custom Nginx configuration"
|
||||
},
|
||||
"location.filter": {
|
||||
"defaultMessage": "Filter by path or destination"
|
||||
},
|
||||
"login.2fa-code": {
|
||||
"defaultMessage": "Verification Code"
|
||||
},
|
||||
|
||||
@@ -44,6 +44,9 @@
|
||||
"action.allow": {
|
||||
"defaultMessage": "İzin Ver"
|
||||
},
|
||||
"action.clear": {
|
||||
"defaultMessage": "Temizle"
|
||||
},
|
||||
"action.close": {
|
||||
"defaultMessage": "Kapat"
|
||||
},
|
||||
@@ -386,6 +389,12 @@
|
||||
"loading": {
|
||||
"defaultMessage": "Yükleniyor…"
|
||||
},
|
||||
"location.advanced-config": {
|
||||
"defaultMessage": "Özel Nginx yapılandırması var"
|
||||
},
|
||||
"location.filter": {
|
||||
"defaultMessage": "Yol veya hedefe göre filtrele"
|
||||
},
|
||||
"login.title": {
|
||||
"defaultMessage": "Hesabınıza giriş yapın"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user