Merge pull request #5765 from fatihemre/feat/collapsible-custom-locations

Collapse custom locations into a filterable list
This commit is contained in:
jc21
2026-08-27 07:42:43 +10:00
committed by GitHub
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"
},