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:
Fatih Emre
2026-08-04 16:11:46 +03:00
parent a62c2a6dc3
commit ab96d78f4d
4 changed files with 343 additions and 132 deletions
@@ -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;
}
+276 -131
View File
@@ -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>
);
})
)}
</>
);
}
+9
View File
@@ -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"
},
+9
View File
@@ -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"
},