diff --git a/frontend/src/components/Form/LocationsFields.module.css b/frontend/src/components/Form/LocationsFields.module.css index 4b48ef3cb9..9551e0d4ef 100644 --- a/frontend/src/components/Form/LocationsFields.module.css +++ b/frontend/src/components/Form/LocationsFields.module.css @@ -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; } diff --git a/frontend/src/components/Form/LocationsFields.tsx b/frontend/src/components/Form/LocationsFields.tsx index 4240b1f986..23198c10c0 100644 --- a/frontend/src/components/Form/LocationsFields.tsx +++ b/frontend/src/components/Form/LocationsFields.tsx @@ -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(initialValues || []); + const [rows, setRows] = useState(() => (initialValues || []).map((value, id) => ({ id, value }))); const { setFieldValue } = useFormikContext(); + const [expanded, setExpanded] = useState([]); const [advVisible, setAdvVisible] = useState([]); + const [filter, setFilter] = useState(""); + const nextId = useRef(rows.length); + const scrollToId = useRef(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 (
+ ) : null} +
+ )} + + + {visibleRows.length === 0 ? ( +
+ +
+ ) : ( + visibleRows.map((row: Row) => { + const item = row.value; + const isOpen = expanded.includes(row.id); + const bodyId = `location-body-${row.id}`; + return ( +
{ + if (node && scrollToId.current === row.id) { + scrollToId.current = null; + node.scrollIntoView({ block: "nearest" }); + } + }} + className={cn("card", "card-active", "mb-2", styles.locationCard)} + > +
-
-
-
-
-
- - -
-
-
-
- - handleChange(idx, "forwardHost", e.target.value)} - /> -
+ + + ) : null} +
-
-
- - handleChange(idx, "forwardPort", e.target.value)} - /> + {isOpen && ( +
+
+
+
+ Location + handleChange(row.id, "path", e.target.value)} + /> +
+
+
+ +
+
+
+
+
+ + +
+
+
+
+ + + handleChange(row.id, "forwardHost", e.target.value) + } + /> +
+
+
+
+ + + handleChange(row.id, "forwardPort", e.target.value) + } + /> +
+
+
+ {advVisible.includes(row.id) && ( +
+ 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", + }} + /> +
+ )}
-
+ )}
- {advVisible.includes(idx) && ( -
- handleChange(idx, "advancedConfig", e.target.value)} - style={{ - fontFamily: - "ui-monospace,SFMono-Regular,SF Mono,Consolas,Liberation Mono,Menlo,monospace", - borderRadius: "0.3rem", - minHeight: "170px", - }} - /> -
- )} - -
- - ))} -
- -
+ ); + }) + )} ); } diff --git a/frontend/src/locale/src/en.json b/frontend/src/locale/src/en.json index bb00ac3322..88fdd724ed 100644 --- a/frontend/src/locale/src/en.json +++ b/frontend/src/locale/src/en.json @@ -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" }, diff --git a/frontend/src/locale/src/tr.json b/frontend/src/locale/src/tr.json index 972fa895ec..cc2d488811 100644 --- a/frontend/src/locale/src/tr.json +++ b/frontend/src/locale/src/tr.json @@ -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" },