mirror of
https://github.com/NginxProxyManager/nginx-proxy-manager.git
synced 2026-09-24 13:50:20 +01:00
Merge pull request #5765 from fatihemre/feat/collapsible-custom-locations
Collapse custom locations into a filterable list
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 {
|
.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 CodeEditor from "@uiw/react-textarea-code-editor";
|
||||||
import cn from "classnames";
|
import cn from "classnames";
|
||||||
import { useFormikContext } from "formik";
|
import { useFormikContext } from "formik";
|
||||||
import { useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import type { ProxyLocation } from "src/api/backend";
|
import type { ProxyLocation } from "src/api/backend";
|
||||||
import { intl, T } from "src/locale";
|
import { intl, T } from "src/locale";
|
||||||
import styles from "./LocationsFields.module.css";
|
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 {
|
interface Props {
|
||||||
initialValues: ProxyLocation[];
|
initialValues: ProxyLocation[];
|
||||||
name?: string;
|
name?: string;
|
||||||
}
|
}
|
||||||
export function LocationsFields({ initialValues, name = "locations" }: Props) {
|
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 { setFieldValue } = useFormikContext();
|
||||||
|
const [expanded, setExpanded] = useState<number[]>([]);
|
||||||
const [advVisible, setAdvVisible] = 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 = {
|
const blankItem: ProxyLocation = {
|
||||||
path: "",
|
path: "",
|
||||||
@@ -24,32 +47,62 @@ export function LocationsFields({ initialValues, name = "locations" }: Props) {
|
|||||||
forwardPort: 80,
|
forwardPort: 80,
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleAdvVisible = (idx: number) => {
|
const toggleExpanded = (id: number) => {
|
||||||
setAdvVisible(advVisible.includes(idx) ? advVisible.filter((i) => i !== idx) : [...advVisible, idx]);
|
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 = () => {
|
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 handleRemove = (id: number) => {
|
||||||
const newValues = values.filter((_: ProxyLocation, i: number) => i !== idx);
|
const newRows = rows.filter((r: Row) => r.id !== id);
|
||||||
setValues(newValues);
|
setRows(newRows);
|
||||||
setFormField(newValues);
|
setExpanded(expanded.filter((i) => i !== id));
|
||||||
|
setAdvVisible(advVisible.filter((i) => i !== id));
|
||||||
|
setFormField(newRows);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleChange = (idx: number, field: string, fieldValue: string) => {
|
const handleChange = (id: number, field: string, fieldValue: string) => {
|
||||||
const newValues = values.map((v: ProxyLocation, i: number) => (i === idx ? { ...v, [field]: fieldValue } : v));
|
const newRows = rows.map((r: Row) => (r.id === id ? { ...r, value: { ...r.value, [field]: fieldValue } } : r));
|
||||||
setValues(newValues);
|
setRows(newRows);
|
||||||
setFormField(newValues);
|
setFormField(newRows);
|
||||||
};
|
};
|
||||||
|
|
||||||
const setFormField = (newValues: ProxyLocation[]) => {
|
const setFormField = (newRows: Row[]) => {
|
||||||
const filtered = newValues.filter((v: ProxyLocation) => v?.path?.trim() !== "");
|
const filtered = newRows.map((r: Row) => r.value).filter((v: ProxyLocation) => v?.path?.trim() !== "");
|
||||||
setFieldValue(name, filtered);
|
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 (
|
return (
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<button type="button" className="btn my-3" onClick={handleAdd}>
|
<button type="button" className="btn my-3" onClick={handleAdd}>
|
||||||
@@ -61,125 +114,217 @@ export function LocationsFields({ initialValues, name = "locations" }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{values.map((item: ProxyLocation, idx: number) => (
|
<div className="d-flex align-items-center mb-3">
|
||||||
<div key={idx} className={cn("card", "card-active", "mb-3", styles.locationCard)}>
|
{rows.length >= FILTER_THRESHOLD && (
|
||||||
<div className="card-body">
|
<div className={cn("input-group", styles.filter)}>
|
||||||
<div className="row">
|
<span className="input-group-text">
|
||||||
<div className="col-md-10">
|
<IconSearch size={16} />
|
||||||
<div className="input-group mb-3">
|
</span>
|
||||||
<span className="input-group-text">Location</span>
|
<input
|
||||||
<input
|
type="text"
|
||||||
type="text"
|
className="form-control"
|
||||||
className="form-control"
|
autoComplete="off"
|
||||||
placeholder="/path"
|
placeholder={intl.formatMessage({ id: "location.filter" })}
|
||||||
autoComplete="off"
|
aria-label={intl.formatMessage({ id: "location.filter" })}
|
||||||
value={item.path}
|
value={filter}
|
||||||
onChange={(e) => handleChange(idx, "path", e.target.value)}
|
onChange={(e) => setFilter(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
{filter ? (
|
||||||
</div>
|
<button
|
||||||
<div className="col-md-2 text-end">
|
type="button"
|
||||||
<button
|
className="btn btn-icon"
|
||||||
type="button"
|
title={intl.formatMessage({ id: "action.clear" })}
|
||||||
className="btn p-0"
|
aria-label={intl.formatMessage({ id: "action.clear" })}
|
||||||
title="Advanced"
|
onClick={() => setFilter("")}
|
||||||
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);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<T id="action.delete" />
|
<IconX size={16} />
|
||||||
</a>
|
</button>
|
||||||
</div>
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
))}
|
<button type="button" className="btn ms-auto" onClick={handleAdd}>
|
||||||
<div>
|
<IconPlus size={16} className="me-1" />
|
||||||
<button type="button" className="btn btn-sm" onClick={handleAdd}>
|
|
||||||
<T id="action.add-location" />
|
<T id="action.add-location" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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": {
|
"action.allow": {
|
||||||
"defaultMessage": "Allow"
|
"defaultMessage": "Allow"
|
||||||
},
|
},
|
||||||
|
"action.clear": {
|
||||||
|
"defaultMessage": "Clear"
|
||||||
|
},
|
||||||
"action.close": {
|
"action.close": {
|
||||||
"defaultMessage": "Close"
|
"defaultMessage": "Close"
|
||||||
},
|
},
|
||||||
@@ -461,6 +464,12 @@
|
|||||||
"loading": {
|
"loading": {
|
||||||
"defaultMessage": "Loading…"
|
"defaultMessage": "Loading…"
|
||||||
},
|
},
|
||||||
|
"location.advanced-config": {
|
||||||
|
"defaultMessage": "Has custom Nginx configuration"
|
||||||
|
},
|
||||||
|
"location.filter": {
|
||||||
|
"defaultMessage": "Filter by path or destination"
|
||||||
|
},
|
||||||
"login.2fa-code": {
|
"login.2fa-code": {
|
||||||
"defaultMessage": "Verification Code"
|
"defaultMessage": "Verification Code"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -44,6 +44,9 @@
|
|||||||
"action.allow": {
|
"action.allow": {
|
||||||
"defaultMessage": "İzin Ver"
|
"defaultMessage": "İzin Ver"
|
||||||
},
|
},
|
||||||
|
"action.clear": {
|
||||||
|
"defaultMessage": "Temizle"
|
||||||
|
},
|
||||||
"action.close": {
|
"action.close": {
|
||||||
"defaultMessage": "Kapat"
|
"defaultMessage": "Kapat"
|
||||||
},
|
},
|
||||||
@@ -386,6 +389,12 @@
|
|||||||
"loading": {
|
"loading": {
|
||||||
"defaultMessage": "Yükleniyor…"
|
"defaultMessage": "Yükleniyor…"
|
||||||
},
|
},
|
||||||
|
"location.advanced-config": {
|
||||||
|
"defaultMessage": "Özel Nginx yapılandırması var"
|
||||||
|
},
|
||||||
|
"location.filter": {
|
||||||
|
"defaultMessage": "Yol veya hedefe göre filtrele"
|
||||||
|
},
|
||||||
"login.title": {
|
"login.title": {
|
||||||
"defaultMessage": "Hesabınıza giriş yapın"
|
"defaultMessage": "Hesabınıza giriş yapın"
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user