mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45:24 +00:00
feat: shared list kit, settings search, rail order and ui consistency fixes
This commit is contained in:
1 parent
3ed5775ed5
commit
58c3434e58
68 files changed
+2078
-512
No files matched your search
@@ -233,8 +233,9 @@ export function FeatureSettingsForm({
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
className="h-7 text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand dark:border-accent-brand/40 dark:bg-transparent dark:hover:bg-accent-brand/10"
|
||||
disabled={!dirty || saving}
|
||||
onClick={() => void save()}
|
||||
>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Select2 } from "@/components/select2";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
@@ -453,7 +454,7 @@ export function KeybindingsSettings() {
|
||||
<label className="text-xs font-semibold">
|
||||
{t("newUi.sidebar.keybindings.actionLabel")}
|
||||
</label>
|
||||
<select
|
||||
<Select2
|
||||
value={draft.type}
|
||||
onChange={(e) => setDraft({ type: e.target.value })}
|
||||
className="h-8 border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||
@@ -466,7 +467,7 @@ export function KeybindingsSettings() {
|
||||
{t(action.labelKey)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Select2>
|
||||
</div>
|
||||
|
||||
{Editor && <Editor action={draft} onChange={setDraft} />}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronDown, Settings as SettingsIcon } from "lucide-react";
|
||||
import { ChevronDown, Search, Settings as SettingsIcon } from "lucide-react";
|
||||
import {
|
||||
GroupHeading,
|
||||
PANEL,
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
} from "./FeatureSettingsSections";
|
||||
import { KeybindingsSettings } from "./KeybindingsSettings";
|
||||
import { SettingsEmbedContext } from "./settings-embed";
|
||||
import { useSettingsPageFilter } from "./page-filter";
|
||||
import {
|
||||
CORE_SETTINGS_PAGES,
|
||||
SETTINGS_BAND_ORDER,
|
||||
@@ -35,6 +36,8 @@ import {
|
||||
export interface SettingsTabProps {
|
||||
/** The page to open, from the tab's data. */
|
||||
section?: string;
|
||||
/** An element id on that page to scroll to, as "id@nonce". */
|
||||
reveal?: string;
|
||||
username: string;
|
||||
isAdmin: boolean;
|
||||
onLogout?: () => void;
|
||||
@@ -76,6 +79,7 @@ function revealText(container: HTMLElement, needle: string): boolean {
|
||||
/** Everything that configures the app, one page at a time. */
|
||||
export function SettingsTab({
|
||||
section,
|
||||
reveal,
|
||||
username,
|
||||
isAdmin,
|
||||
onLogout,
|
||||
@@ -88,7 +92,9 @@ export function SettingsTab({
|
||||
const adminFeatures = useFeatureSettings("admin");
|
||||
const [query, setQuery] = useState("");
|
||||
const [navOpen, setNavOpen] = useState(false);
|
||||
const [pageQuery, setPageQuery] = useState("");
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const pageRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const pages = useMemo(() => {
|
||||
const plugins = new Map<string, SettingsPage>();
|
||||
@@ -130,15 +136,42 @@ export function SettingsTab({
|
||||
if (section) setCurrent(section);
|
||||
}, [section]);
|
||||
|
||||
// The page renders lazily, so the target may take a moment to appear.
|
||||
useEffect(() => {
|
||||
const target = reveal?.split("@")[0];
|
||||
if (!target) return;
|
||||
let tries = 0;
|
||||
let timer = 0;
|
||||
const attempt = () => {
|
||||
const el = contentRef.current?.querySelector<HTMLElement>(
|
||||
`#${CSS.escape(target)}`,
|
||||
);
|
||||
if (el) {
|
||||
el.scrollIntoView?.({ block: "start", behavior: "smooth" });
|
||||
el.classList.add("settings-search-hit");
|
||||
timer = window.setTimeout(
|
||||
() => el.classList.remove("settings-search-hit"),
|
||||
1600,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (++tries < 15) timer = window.setTimeout(attempt, 120);
|
||||
};
|
||||
timer = window.setTimeout(attempt, 60);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [reveal]);
|
||||
|
||||
const page =
|
||||
pages.find((p) => p.id === current) ??
|
||||
pages.find((p) => p.id === "account")!;
|
||||
const pageHasMatches = useSettingsPageFilter(pageRef, pageQuery, page.id);
|
||||
const hits = searchSettingsPages(pages, query, (key) => t(key));
|
||||
const pageLabel = (p: SettingsPage) => p.label ?? t(p.labelKey);
|
||||
|
||||
const go = (id: string) => {
|
||||
setCurrent(id);
|
||||
setNavOpen(false);
|
||||
setPageQuery("");
|
||||
contentRef.current?.scrollTo?.({ top: 0 });
|
||||
const needle = query.trim();
|
||||
if (!needle) return;
|
||||
@@ -261,7 +294,7 @@ export function SettingsTab({
|
||||
<nav
|
||||
aria-label={t("settings.pages")}
|
||||
className={cn(
|
||||
"w-full shrink-0 overflow-y-auto border-r border-border bg-sidebar thin-scrollbar md:static md:block md:w-60",
|
||||
"w-full shrink-0 overflow-y-auto border-r border-border bg-background thin-scrollbar md:static md:block md:w-60",
|
||||
navOpen ? "absolute inset-0 z-20 block" : "hidden",
|
||||
)}
|
||||
>
|
||||
@@ -269,11 +302,25 @@ export function SettingsTab({
|
||||
</nav>
|
||||
|
||||
<SurfaceScope kind="tab" className="min-w-0">
|
||||
<div className={`shrink-0 border-b border-border ${PANEL.band}`}>
|
||||
<div className="mx-auto w-full max-w-3xl">
|
||||
<PanelSearch
|
||||
value={pageQuery}
|
||||
onChange={setPageQuery}
|
||||
placeholder={t("settings.searchPage")}
|
||||
fill
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
ref={contentRef}
|
||||
className={`min-h-0 flex-1 overflow-y-auto thin-scrollbar ${PANEL.body}`}
|
||||
>
|
||||
{!pageHasMatches && (
|
||||
<EmptyState icon={Search} title={t("settings.noPageMatches")} />
|
||||
)}
|
||||
<div
|
||||
ref={pageRef}
|
||||
className={`mx-auto flex w-full max-w-3xl flex-col ${PANEL.gap} pb-6`}
|
||||
>
|
||||
<SettingsEmbedContext.Provider value>
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { useEffect, useState, type RefObject } from "react";
|
||||
|
||||
export const HIDDEN_ATTR = "data-search-hidden";
|
||||
const HIGHLIGHT = "settings-search";
|
||||
const BLOCK = ".bg-card, [data-setting-row]";
|
||||
|
||||
function matches(el: Element, needle: string) {
|
||||
return (el.textContent ?? "").toLowerCase().includes(needle);
|
||||
}
|
||||
|
||||
/** Visible blocks with no other block above them inside root. */
|
||||
function topBlocks(root: HTMLElement, blocks: Element[], selector: string) {
|
||||
return blocks.filter((el) => {
|
||||
let parent = el.parentElement;
|
||||
while (parent && parent !== root) {
|
||||
if (parent.matches(selector) || parent.classList.contains("hidden"))
|
||||
return false;
|
||||
parent = parent.parentElement;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hides the cards and rows on a settings page that do not contain the query.
|
||||
* A card whose own text matches keeps every row; otherwise only its matching
|
||||
* rows stay. Returns how many cards are still showing.
|
||||
*/
|
||||
export function filterSettingsPage(
|
||||
root: HTMLElement,
|
||||
query: string,
|
||||
blockSelector: string = BLOCK,
|
||||
): number {
|
||||
for (const el of root.querySelectorAll(`[${HIDDEN_ATTR}]`)) {
|
||||
el.removeAttribute(HIDDEN_ATTR);
|
||||
}
|
||||
const needle = query.trim().toLowerCase();
|
||||
const blocks = [...root.querySelectorAll(blockSelector)];
|
||||
const cards = topBlocks(root, blocks, blockSelector);
|
||||
if (!needle) return cards.length;
|
||||
|
||||
let shown = 0;
|
||||
for (const card of cards) {
|
||||
if (!matches(card, needle)) {
|
||||
card.setAttribute(HIDDEN_ATTR, "");
|
||||
continue;
|
||||
}
|
||||
shown++;
|
||||
const rows = [...card.querySelectorAll("[data-setting-row]")].filter(
|
||||
(row) => row !== card,
|
||||
);
|
||||
if (!rows.some((row) => matches(row, needle))) continue;
|
||||
for (const row of rows) {
|
||||
if (!matches(row, needle)) row.setAttribute(HIDDEN_ATTR, "");
|
||||
}
|
||||
for (const inner of card.querySelectorAll(".bg-card")) {
|
||||
if (inner !== card && !matches(inner, needle)) {
|
||||
inner.setAttribute(HIDDEN_ATTR, "");
|
||||
}
|
||||
}
|
||||
}
|
||||
return shown;
|
||||
}
|
||||
|
||||
type HighlightRegistry = Map<string, unknown>;
|
||||
|
||||
function highlights(): HighlightRegistry | undefined {
|
||||
const css = (globalThis as { CSS?: { highlights?: HighlightRegistry } }).CSS;
|
||||
return css?.highlights;
|
||||
}
|
||||
|
||||
/** Marks every match with the CSS highlight API where the browser has it. */
|
||||
function highlightMatches(root: HTMLElement, query: string) {
|
||||
const registry = highlights();
|
||||
const Highlight = (
|
||||
globalThis as { Highlight?: new (...r: Range[]) => unknown }
|
||||
).Highlight;
|
||||
if (!registry || !Highlight) return;
|
||||
registry.delete(HIGHLIGHT);
|
||||
const needle = query.trim().toLowerCase();
|
||||
if (!needle) return;
|
||||
const ranges: Range[] = [];
|
||||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
||||
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
||||
const text = node.textContent?.toLowerCase() ?? "";
|
||||
let at = text.indexOf(needle);
|
||||
while (at !== -1) {
|
||||
const range = document.createRange();
|
||||
range.setStart(node, at);
|
||||
range.setEnd(node, at + needle.length);
|
||||
ranges.push(range);
|
||||
at = text.indexOf(needle, at + needle.length);
|
||||
}
|
||||
}
|
||||
registry.set(HIGHLIGHT, new Highlight(...ranges));
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps a settings page filtered while its content loads and changes.
|
||||
* Returns false when the query hides everything.
|
||||
*/
|
||||
export function useSettingsPageFilter(
|
||||
rootRef: RefObject<HTMLElement | null>,
|
||||
query: string,
|
||||
pageKey: string,
|
||||
blockSelector: string = BLOCK,
|
||||
onFiltered?: (root: HTMLElement) => void,
|
||||
): boolean {
|
||||
const [anyShown, setAnyShown] = useState(true);
|
||||
useEffect(() => {
|
||||
const root = rootRef.current;
|
||||
if (!root) return;
|
||||
let frame = 0;
|
||||
const run = () => {
|
||||
cancelAnimationFrame(frame);
|
||||
frame = requestAnimationFrame(() => {
|
||||
setAnyShown(
|
||||
filterSettingsPage(root, query, blockSelector) > 0 || !query.trim(),
|
||||
);
|
||||
highlightMatches(root, query);
|
||||
onFiltered?.(root);
|
||||
});
|
||||
};
|
||||
run();
|
||||
if (!query.trim()) return () => cancelAnimationFrame(frame);
|
||||
const observer = new MutationObserver(run);
|
||||
observer.observe(root, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
characterData: true,
|
||||
});
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
cancelAnimationFrame(frame);
|
||||
highlights()?.delete(HIGHLIGHT);
|
||||
};
|
||||
}, [rootRef, query, pageKey, blockSelector, onFiltered]);
|
||||
return anyShown;
|
||||
}
|
||||
@@ -348,8 +348,9 @@ export function LinkServerDialog({
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
type="submit"
|
||||
className="rounded-none"
|
||||
className="rounded-none border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand dark:border-accent-brand/40 dark:bg-transparent dark:hover:bg-accent-brand/10"
|
||||
disabled={checking || !target.serverUrl.trim()}
|
||||
>
|
||||
{checking && <Loader2 className="size-4 animate-spin" />}
|
||||
@@ -420,8 +421,9 @@ export function LinkServerDialog({
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
className="rounded-none"
|
||||
className="rounded-none border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand dark:border-accent-brand/40 dark:bg-transparent dark:hover:bg-accent-brand/10"
|
||||
disabled={!token}
|
||||
onClick={() => token && void finish(token, mode)}
|
||||
>
|
||||
|
||||
@@ -198,7 +198,8 @@ export function SyncPanel() {
|
||||
<li>{t("sync.unlinkedPointAccount")}</li>
|
||||
</ul>
|
||||
<Button
|
||||
className="self-start rounded-none"
|
||||
variant="outline"
|
||||
className="self-start rounded-none border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand dark:border-accent-brand/40 dark:bg-transparent dark:hover:bg-accent-brand/10"
|
||||
onClick={() => setLinkOpen(true)}
|
||||
>
|
||||
<Cloud className="size-4" />
|
||||
@@ -302,8 +303,9 @@ export function SyncPanel() {
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{state === "signed_out" ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-[10px] rounded-none"
|
||||
className="h-7 text-[10px] rounded-none border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand dark:border-accent-brand/40 dark:bg-transparent dark:hover:bg-accent-brand/10"
|
||||
onClick={() => setReloginOpen(true)}
|
||||
>
|
||||
{t("sync.signInAgain")}
|
||||
@@ -535,8 +537,9 @@ export function SyncPanel() {
|
||||
</div>
|
||||
{proxyDraft && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="self-start h-7 text-[10px] rounded-none"
|
||||
className="self-start h-7 text-[10px] rounded-none border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand dark:border-accent-brand/40 dark:bg-transparent dark:hover:bg-accent-brand/10"
|
||||
disabled={busy === "proxy"}
|
||||
onClick={() =>
|
||||
run("proxy", async () => {
|
||||
|
||||
Reference in new issue
Block a user