feat: port the demo redesign with Manage and Settings tabs

This commit is contained in:
LukeGus committed 2026-10-05 16:23:21 -05:00
1 parent f8151cb885
commit 35c954b79a
128 files changed
+13262 -8143

No files matched your search

+2 -2
View File
@@ -96,7 +96,7 @@ export function FeatureSettingsSection({
);
}
function FeatureSettingsForm({
export function FeatureSettingsForm({
plugin,
scope,
fields,
@@ -208,7 +208,7 @@ function FeatureSettingsForm({
return (
<div className="flex flex-col gap-2 pt-2">
{!running && (
<div className="border border-yellow-500/40 bg-yellow-500/10 px-3 py-2 text-xs text-yellow-500">
<div className="border border-warning/40 bg-warning/10 px-3 py-2 text-xs text-warning">
{t("settings.featureUnavailable", {
name: t(pluginKey(plugin.id, "plugin.name"), {
defaultValue: plugin.name,
+1 -1
View File
@@ -157,7 +157,7 @@ function HostPluginSection({
icon={<PluginIcon name={plugin.icon} className="size-3.5" />}
>
{!running && (
<div className="my-3 border border-yellow-500/40 bg-yellow-500/10 px-3 py-2 text-xs text-yellow-500">
<div className="my-3 border border-warning/40 bg-warning/10 px-3 py-2 text-xs text-warning">
{t("settings.featureUnavailable", { name: plugin.name })}
</div>
)}
+477
View File
@@ -0,0 +1,477 @@
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
getUserPreferences,
parseCustomKeybindings,
} from "@/api/open-tabs-api";
import { saveUserPreferences } from "@/main-axios";
import {
useKeybindingActions,
useKeybindingDefaults,
type KeybindingActionDef,
} from "@/shell/keybinding-registry";
import type {
CustomKeybinding,
KeybindingAction,
KeyCombo,
} from "@/types/keybindings";
import { InlineView, useConfirm } from "@/components/surface/surface-scope";
import { GroupHeading } from "@/components/panel-layout";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import { Keyboard, Pencil, Plus, RotateCcw, Trash2 } from "lucide-react";
import { FixedShortcutsList } from "@/sidebar/FixedShortcutsList";
function formatCombo(combo: KeyCombo): string {
const parts: string[] = [];
if (combo.ctrl) parts.push("Ctrl");
if (combo.alt) parts.push("Alt");
if (combo.shift) parts.push("Shift");
if (combo.meta) parts.push("Cmd");
parts.push(combo.key.length === 1 ? combo.key.toUpperCase() : combo.key);
return parts.join(" + ");
}
function generateId(): string {
return typeof crypto !== "undefined" && "randomUUID" in crypto
? crypto.randomUUID()
: `kb-${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
/** An action without the keys an editor cleared, so it saves clean. */
function withoutEmpty(action: KeybindingAction): KeybindingAction {
const out: KeybindingAction = { type: action.type };
for (const [key, value] of Object.entries(action)) {
if (key !== "type" && value !== undefined && value !== null) {
out[key] = value;
}
}
return out;
}
/**
* Settings > Keybindings. The actions come from the keybinding registry:
* the shell's own and whatever the running plugins register, each with its
* own parameter editor. A saved binding whose plugin is off stays in the
* list and keeps working once the plugin is back.
*/
export function KeybindingsSettings() {
const { t } = useTranslation();
const confirm = useConfirm();
const actions = useKeybindingActions();
const defaults = useKeybindingDefaults();
const [bindings, setBindings] = useState<CustomKeybinding[]>([]);
const [loading, setLoading] = useState(false);
const [formOpen, setFormOpen] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [overridesDefaultId, setOverridesDefaultId] = useState<
string | undefined
>(undefined);
const [recordedCombo, setRecordedCombo] = useState<KeyCombo | null>(null);
const [recording, setRecording] = useState(false);
const [draft, setDraft] = useState<KeybindingAction>({ type: "" });
const actionById = useMemo(
() => new Map(actions.map((action) => [action.id, action])),
[actions],
);
const draftDef: KeybindingActionDef | undefined = actionById.get(draft.type);
useEffect(() => {
setLoading(true);
getUserPreferences()
.then((prefs) =>
setBindings(parseCustomKeybindings(prefs.customKeybindings)),
)
.catch(() => setBindings([]))
.finally(() => setLoading(false));
}, []);
const conflictWarning = useMemo(() => {
if (!recordedCombo) return null;
const conflict = bindings.find(
(kb) =>
kb.id !== editingId &&
kb.enabled &&
kb.combo.ctrl === recordedCombo.ctrl &&
kb.combo.alt === recordedCombo.alt &&
kb.combo.shift === recordedCombo.shift &&
kb.combo.meta === recordedCombo.meta &&
kb.combo.key === recordedCombo.key,
);
if (conflict) {
return t("newUi.sidebar.keybindings.conflictWarning", {
combo: formatCombo(conflict.combo),
});
}
return null;
}, [recordedCombo, bindings, editingId, t]);
function actionLabel(type: string): string {
const def = actionById.get(type);
return def
? t(def.labelKey)
: t("newUi.sidebar.keybindings.unavailableAction", { type });
}
function resetForm() {
setEditingId(null);
setOverridesDefaultId(undefined);
setRecordedCombo(null);
setRecording(false);
setDraft({ type: actions[0]?.id ?? "" });
}
function openAddForm() {
resetForm();
setFormOpen(true);
}
function openEditForm(kb: CustomKeybinding) {
setEditingId(kb.id);
setOverridesDefaultId(kb.overridesDefaultId);
setRecordedCombo(kb.combo);
setRecording(false);
setDraft({ ...kb.action });
setFormOpen(true);
}
function openOverrideDefaultForm(defaultId: string, combo: KeyCombo) {
resetForm();
setOverridesDefaultId(defaultId);
setRecordedCombo(combo);
setFormOpen(true);
}
async function persist(updated: CustomKeybinding[]) {
try {
await saveUserPreferences({
customKeybindings: JSON.stringify(updated),
});
setBindings(updated);
window.dispatchEvent(new Event("customKeybindingsChanged"));
} catch {
toast.error(t("newUi.sidebar.keybindings.saveError"));
}
}
async function handleDelete(id: string) {
const ok = await confirm({
title: t("newUi.sidebar.keybindings.deleteConfirm"),
});
if (ok) persist(bindings.filter((kb) => kb.id !== id));
}
async function handleResetAll() {
const ok = await confirm({
title: t("newUi.sidebar.keybindings.resetAllConfirm"),
confirmLabel: t("newUi.sidebar.keybindings.resetAllToDefaults"),
});
if (ok) persist([]);
}
function startRecording(inputEl: HTMLInputElement) {
setRecording(true);
const handler = (e: KeyboardEvent) => {
e.preventDefault();
e.stopPropagation();
if (["Control", "Alt", "Shift", "Meta"].includes(e.key)) {
return;
}
const isAlnum = /^[a-zA-Z0-9]$/.test(e.key);
const combo: KeyCombo = {
key: isAlnum ? e.key.toLowerCase() : e.code,
isCode: !isAlnum,
ctrl: e.ctrlKey,
alt: e.altKey,
shift: e.shiftKey,
meta: e.metaKey,
};
setRecordedCombo(combo);
setRecording(false);
inputEl.removeEventListener("keydown", handler, true);
inputEl.blur();
};
inputEl.addEventListener("keydown", handler, true);
}
function handleSave() {
if (!recordedCombo) {
toast.error(t("newUi.sidebar.keybindings.comboRequiredError"));
return;
}
if (!draftDef) {
toast.error(t("newUi.sidebar.keybindings.actionRequiredError"));
return;
}
const action = withoutEmpty(draft);
const problem = draftDef.validate?.(action);
if (problem) {
toast.error(t(problem));
return;
}
const now = new Date().toISOString();
const existing = editingId
? bindings.find((kb) => kb.id === editingId)
: undefined;
const newBinding: CustomKeybinding = {
id: editingId ?? generateId(),
combo: recordedCombo,
action,
enabled: true,
overridesDefaultId,
createdAt: existing?.createdAt ?? now,
updatedAt: now,
};
const updated = editingId
? bindings.map((kb) => (kb.id === editingId ? newBinding : kb))
: [...bindings, newBinding];
persist(updated);
setFormOpen(false);
resetForm();
}
const customOnlyBindings = bindings.filter((kb) => !kb.overridesDefaultId);
const Editor = draftDef?.editor;
return (
<>
<div className="flex flex-col gap-4 border border-border bg-card p-3">
<p className="text-xs text-muted-foreground">
{t("newUi.sidebar.keybindings.description")}
</p>
{defaults.length > 0 && (
<div className="flex flex-col gap-1.5">
<GroupHeading
title={t("newUi.sidebar.keybindings.defaultsHeading")}
/>
{defaults.map((def) => {
const override = bindings.find(
(kb) => kb.overridesDefaultId === def.id,
);
return (
<div
key={def.id}
className="flex items-center justify-between gap-2 border border-border bg-background px-2.5 py-2"
>
<div className="flex flex-col min-w-0">
<span className="text-xs font-mono">
{override
? formatCombo(override.combo)
: formatCombo(def.combo)}
</span>
<span className="text-xs text-muted-foreground truncate">
{t(def.descriptionKey)}
</span>
</div>
<div className="flex items-center gap-1 shrink-0">
{override ? (
<>
<span className="text-[10px] font-bold text-accent-brand border border-accent-brand/40 px-1">
{t("newUi.sidebar.keybindings.customizedBadge")}
</span>
<Button
variant="ghost"
size="icon"
className="size-7"
onClick={() => openEditForm(override)}
>
<Pencil className="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="size-7 text-muted-foreground hover:text-destructive"
onClick={() => void handleDelete(override.id)}
title={t("newUi.sidebar.keybindings.resetToDefault")}
>
<RotateCcw className="size-3.5" />
</Button>
</>
) : (
<>
<span className="text-[10px] text-muted-foreground border border-border px-1">
{t("newUi.sidebar.keybindings.defaultBadge")}
</span>
<Button
variant="ghost"
size="icon"
className="size-7"
onClick={() =>
openOverrideDefaultForm(def.id, def.combo)
}
title={t("newUi.sidebar.keybindings.customize")}
>
<Pencil className="size-3.5" />
</Button>
</>
)}
</div>
</div>
);
})}
</div>
)}
<div className="flex flex-col gap-1.5">
<GroupHeading
title={t("newUi.sidebar.keybindings.customHeading")}
action={
<Button variant="outline" size="sm" onClick={openAddForm}>
<Plus className="size-3.5 mr-1" />
{t("newUi.sidebar.keybindings.addBinding")}
</Button>
}
/>
{loading ? (
<span className="text-xs text-muted-foreground">
{t("newUi.sidebar.keybindings.loading")}
</span>
) : customOnlyBindings.length === 0 ? (
<span className="text-xs text-muted-foreground/60">
{t("newUi.sidebar.keybindings.noCustomBindings")}
</span>
) : (
customOnlyBindings.map((kb) => {
const Summary = actionById.get(kb.action.type)?.summary;
return (
<div
key={kb.id}
className="flex items-center justify-between gap-2 border border-border bg-background px-2.5 py-2"
>
<div className="flex flex-col min-w-0">
<span className="text-xs font-mono">
{formatCombo(kb.combo)}
</span>
<span className="text-xs text-muted-foreground truncate">
{actionLabel(kb.action.type)}
{Summary && (
<>
{" "}
<Summary action={kb.action} onChange={() => {}} />
</>
)}
</span>
</div>
<div className="flex items-center gap-1 shrink-0">
<Button
variant="ghost"
size="icon"
className="size-7"
onClick={() => openEditForm(kb)}
>
<Pencil className="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="size-7 text-muted-foreground hover:text-destructive"
onClick={() => void handleDelete(kb.id)}
>
<Trash2 className="size-3.5" />
</Button>
</div>
</div>
);
})
)}
</div>
<FixedShortcutsList />
<Button
variant="ghost"
size="sm"
className="self-start text-muted-foreground"
onClick={() => void handleResetAll()}
disabled={bindings.length === 0}
>
<RotateCcw className="size-3.5 mr-1" />
{t("newUi.sidebar.keybindings.resetAllToDefaults")}
</Button>
</div>
<InlineView
open={formOpen}
onOpenChange={setFormOpen}
icon={<Keyboard className="size-4" />}
title={
editingId || overridesDefaultId
? t("newUi.sidebar.keybindings.editBindingTitle")
: t("newUi.sidebar.keybindings.addBindingTitle")
}
footer={
<div className="ml-auto flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => setFormOpen(false)}
>
{t("newUi.sidebar.keybindings.cancel")}
</Button>
<Button
variant="outline"
size="sm"
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={handleSave}
>
{t("newUi.sidebar.keybindings.saveBinding")}
</Button>
</div>
}
>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold">
{t("newUi.sidebar.keybindings.pressKeysToRecord")}{" "}
<span className="text-accent-brand">*</span>
</label>
<Input
data-keybinding-recorder
readOnly
value={
recording
? t("newUi.sidebar.keybindings.recording")
: recordedCombo
? formatCombo(recordedCombo)
: ""
}
placeholder={t("newUi.sidebar.keybindings.pressKeysPlaceholder")}
onFocus={(e) => startRecording(e.currentTarget)}
className="font-mono cursor-pointer"
/>
{conflictWarning && (
<span className="text-xs text-warning">{conflictWarning}</span>
)}
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold">
{t("newUi.sidebar.keybindings.actionLabel")}
</label>
<select
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"
>
{!draftDef && draft.type && (
<option value={draft.type}>{actionLabel(draft.type)}</option>
)}
{actions.map((action) => (
<option key={action.id} value={action.id}>
{t(action.labelKey)}
</option>
))}
</select>
</div>
{Editor && <Editor action={draft} onChange={setDraft} />}
</div>
</InlineView>
</>
);
}
+1 -1
View File
@@ -117,7 +117,7 @@ export function SettingsFieldRow({
{label(field.labelKey)}
</span>
{error && (
<span className="text-[10px] font-bold text-yellow-500 border border-yellow-500/40 px-1 shrink-0">
<span className="text-[10px] font-bold text-warning border border-warning/40 px-1 shrink-0">
{t("common.error")}
</span>
)}
+340
View File
@@ -0,0 +1,340 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { ChevronDown, Settings as SettingsIcon } from "lucide-react";
import {
GroupHeading,
PANEL,
PanelSearch,
PanelShell,
} from "@/components/panel-layout";
import { SurfaceScope } from "@/components/surface/surface-scope";
import { EmptyState } from "@/components/empty-state";
import { isElectron } from "@/lib/electron";
import { cn } from "@/lib/utils";
import { pluginKey } from "@/lib/plugin-i18n";
import { PluginIcon } from "@/lib/plugin-icon";
import type { Host } from "@/types/ui-types";
import type { UserPreferences } from "@/main-axios";
import { UserProfilePanel } from "@/sidebar/UserProfilePanel";
import { AdminSettingsPanel } from "@/sidebar/AdminSettingsPanel";
import {
FeatureSettingsForm,
useFeatureSettings,
} from "./FeatureSettingsSections";
import { KeybindingsSettings } from "./KeybindingsSettings";
import { SettingsEmbedContext } from "./settings-embed";
import {
CORE_SETTINGS_PAGES,
SETTINGS_BAND_ORDER,
searchSettingsPages,
visibleSettingsPages,
type SettingsBand,
type SettingsPage,
} from "./settings-pages";
export interface SettingsTabProps {
/** The page to open, from the tab's data. */
section?: string;
username: string;
isAdmin: boolean;
onLogout?: () => void;
userPrefs?: UserPreferences;
onPrefsChange?: (prefs: Partial<UserPreferences>) => void;
onOpenHostTab?: (host: Host) => void;
}
const BAND_LABEL: Record<SettingsBand, string> = {
you: "settings.bandYou",
app: "settings.bandApp",
plugins: "settings.bandPlugins",
instance: "settings.bandInstance",
};
/** Scrolls to the first text on the page containing the query and marks it. */
function revealText(container: HTMLElement, needle: string): boolean {
const lower = needle.toLowerCase();
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT);
let node = walker.nextNode();
while (node) {
if (node.textContent?.toLowerCase().includes(lower)) {
const el = node.parentElement;
if (el && el.offsetParent !== null) {
el.scrollIntoView?.({ block: "center", behavior: "smooth" });
el.classList.add("settings-search-hit");
window.setTimeout(
() => el.classList.remove("settings-search-hit"),
1600,
);
return true;
}
}
node = walker.nextNode();
}
return false;
}
/** Everything that configures the app, one page at a time. */
export function SettingsTab({
section,
username,
isAdmin,
onLogout,
userPrefs,
onPrefsChange,
onOpenHostTab,
}: SettingsTabProps) {
const { t } = useTranslation();
const userFeatures = useFeatureSettings("user");
const adminFeatures = useFeatureSettings("admin");
const [query, setQuery] = useState("");
const [navOpen, setNavOpen] = useState(false);
const contentRef = useRef<HTMLDivElement>(null);
const pages = useMemo(() => {
const plugins = new Map<string, SettingsPage>();
for (const plugin of [...userFeatures, ...(isAdmin ? adminFeatures : [])]) {
if (plugins.has(plugin.id)) continue;
const fields = [
...(plugin.contributes?.settings?.user ?? []),
...(isAdmin ? (plugin.contributes?.settings?.admin ?? []) : []),
];
plugins.set(plugin.id, {
id: `plugin:${plugin.id}`,
band: "plugins",
labelKey: pluginKey(plugin.id, "plugin.name"),
label: t(pluginKey(plugin.id, "plugin.name"), {
defaultValue: plugin.name,
}),
icon: SettingsIcon,
pluginId: plugin.id,
searchKeys: fields.flatMap((field) =>
[field.labelKey, field.descriptionKey]
.filter((key): key is string => !!key)
.map((key) => pluginKey(plugin.id, key)),
),
});
}
return visibleSettingsPages(
[
...CORE_SETTINGS_PAGES,
...[...plugins.values()].sort((a, b) =>
(a.label ?? "").localeCompare(b.label ?? ""),
),
],
{ isAdmin, isElectron: isElectron() },
);
}, [userFeatures, adminFeatures, isAdmin, t]);
const [current, setCurrent] = useState(section ?? "account");
useEffect(() => {
if (section) setCurrent(section);
}, [section]);
const page =
pages.find((p) => p.id === current) ??
pages.find((p) => p.id === "account")!;
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);
contentRef.current?.scrollTo?.({ top: 0 });
const needle = query.trim();
if (!needle) return;
let tries = 0;
const attempt = () => {
if (!contentRef.current) return;
if (revealText(contentRef.current, needle) || ++tries > 12) return;
window.setTimeout(attempt, 150);
};
window.setTimeout(attempt, 60);
};
const pluginFeature = page.pluginId
? {
user: userFeatures.find((p) => p.id === page.pluginId),
admin: isAdmin
? adminFeatures.find((p) => p.id === page.pluginId)
: undefined,
}
: null;
const nav = (
<div className="flex flex-col gap-3 py-2.5">
<div className="px-2.5">
<PanelSearch
value={query}
onChange={setQuery}
placeholder={t("settings.searchPlaceholder")}
fill
/>
</div>
{query.trim() && hits.length === 0 && (
<p className="px-3 text-xs text-muted-foreground">
{t("settings.noResults")}
</p>
)}
{SETTINGS_BAND_ORDER.map((band) => {
const items = hits.filter((hit) => hit.page.band === band);
if (items.length === 0) return null;
return (
<div key={band} className="flex flex-col">
<GroupHeading
title={t(BAND_LABEL[band])}
className="px-2.5 pb-1.5"
/>
{items.map(({ page: item, match }) => {
const on = item.id === page.id;
const Icon = item.icon;
return (
<button
key={item.id}
type="button"
onClick={() => go(item.id)}
aria-current={on ? "page" : undefined}
className={cn(
"flex w-full flex-col border-l-2 py-1.5 pl-2 pr-2 text-left transition-colors focus-visible:relative focus-visible:z-10 focus-visible:ring-1 focus-visible:ring-ring",
on
? "border-accent-brand bg-accent-brand/10 text-accent-brand"
: "border-transparent text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
<span className="flex items-center gap-2">
{item.pluginId ? (
<PluginIcon
name={
[...userFeatures, ...adminFeatures].find(
(p) => p.id === item.pluginId,
)?.icon
}
className="size-3.5 shrink-0"
/>
) : (
<Icon className="size-3.5 shrink-0" />
)}
<span className="truncate text-xs font-medium">
{pageLabel(item)}
</span>
</span>
{match && (
<span className="truncate pl-5.5 text-[10px] text-muted-foreground">
{match}
</span>
)}
</button>
);
})}
</div>
);
})}
</div>
);
const showProfile = !!page.profileSection && page.id !== "keybindings";
const showAdmin = !!page.adminSection;
return (
<PanelShell
icon={<SettingsIcon className="size-4" />}
title={t("nav.settings")}
status={pageLabel(page)}
scroll={false}
actions={
<button
type="button"
onClick={() => setNavOpen((v) => !v)}
aria-expanded={navOpen}
className="flex items-center gap-1 px-2 py-1 text-xs text-muted-foreground hover:text-foreground md:hidden"
>
{t("settings.pages")}
<ChevronDown
className={cn(
"size-3.5 transition-transform",
navOpen && "rotate-180",
)}
/>
</button>
}
>
<div className="relative flex min-h-0 flex-1">
<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",
navOpen ? "absolute inset-0 z-20 block" : "hidden",
)}
>
{nav}
</nav>
<SurfaceScope kind="tab" className="min-w-0">
<div
ref={contentRef}
className={`min-h-0 flex-1 overflow-y-auto thin-scrollbar ${PANEL.body}`}
>
<div
className={`mx-auto flex w-full max-w-3xl flex-col ${PANEL.gap} pb-6`}
>
<SettingsEmbedContext.Provider value>
<div className={showProfile ? "contents" : "hidden"}>
<UserProfilePanel
section={page.profileSection ?? "__none__"}
username={username}
onLogout={onLogout}
userPrefs={userPrefs}
onPrefsChange={onPrefsChange}
onOpenSettingsPage={go}
/>
</div>
{isAdmin && (
<div className={showAdmin ? "contents" : "hidden"}>
<AdminSettingsPanel
section={page.adminSection ?? ("__none__" as never)}
onOpenHostTab={onOpenHostTab}
/>
</div>
)}
</SettingsEmbedContext.Provider>
{page.id === "keybindings" && <KeybindingsSettings />}
{pluginFeature && (
<>
{pluginFeature.user && (
<FeatureSettingsForm
plugin={pluginFeature.user}
scope="user"
fields={
pluginFeature.user.contributes?.settings?.user ?? []
}
/>
)}
{pluginFeature.admin && (
<>
<GroupHeading
title={t("settings.instanceSettings")}
className="pt-2"
/>
<FeatureSettingsForm
plugin={pluginFeature.admin}
scope="admin"
fields={
pluginFeature.admin.contributes?.settings?.admin ?? []
}
/>
</>
)}
{!pluginFeature.user && !pluginFeature.admin && (
<EmptyState
icon={SettingsIcon}
title={t("settings.nothingHere")}
/>
)}
</>
)}
</div>
</div>
</SurfaceScope>
</div>
</PanelShell>
);
}
+11
View File
@@ -0,0 +1,11 @@
import { createContext, useContext } from "react";
/**
* True inside a Settings tab page. Panels built from accordion sections then
* show only the open one, flat, since the page already names it.
*/
export const SettingsEmbedContext = createContext(false);
export function useSettingsEmbedded(): boolean {
return useContext(SettingsEmbedContext);
}
+230
View File
@@ -0,0 +1,230 @@
import {
Database,
FileText,
Globe,
KeyRound,
Keyboard,
Lock,
Monitor,
Palette,
Server,
Settings,
Shield,
SlidersHorizontal,
User,
Users,
type LucideIcon,
} from "lucide-react";
import type { AdminSection } from "@/types/ui-types";
import { SETTINGS_SEARCH_INDEX } from "./settings-search-index";
export type SettingsBand = "you" | "app" | "plugins" | "instance";
export const SETTINGS_BAND_ORDER: SettingsBand[] = [
"you",
"app",
"plugins",
"instance",
];
export interface SettingsPage {
id: string;
band: SettingsBand;
labelKey: string;
/** Already translated, for plugin pages named after the plugin. */
label?: string;
icon: LucideIcon;
/** Only for admins. */
admin?: boolean;
/** Left out of the desktop app. */
browserOnly?: boolean;
/** The profile section this page shows. */
profileSection?: string;
/** The admin section this page shows. */
adminSection?: AdminSection;
/** A plugin's settings page. */
pluginId?: string;
/** i18n keys whose text search matches. */
searchKeys: string[];
}
function core(
page: Omit<SettingsPage, "searchKeys"> & { indexId?: string },
): SettingsPage {
return {
...page,
searchKeys: SETTINGS_SEARCH_INDEX[page.indexId ?? page.id] ?? [],
};
}
export const CORE_SETTINGS_PAGES: SettingsPage[] = [
core({
id: "account",
band: "you",
labelKey: "settings.account",
icon: User,
profileSection: "account",
}),
core({
id: "security",
band: "you",
labelKey: "settings.security",
icon: Shield,
profileSection: "security",
browserOnly: true,
}),
core({
id: "api-keys",
band: "you",
labelKey: "settings.apiKeys",
icon: KeyRound,
profileSection: "api-keys",
}),
core({
id: "data",
band: "you",
labelKey: "settings.data",
icon: Database,
profileSection: "data",
}),
core({
id: "interface",
band: "app",
labelKey: "settings.interface",
icon: SlidersHorizontal,
profileSection: "interface",
}),
core({
id: "appearance",
band: "app",
labelKey: "settings.appearance",
icon: Palette,
profileSection: "appearance",
}),
core({
id: "keybindings",
band: "app",
labelKey: "settings.keybindings",
icon: Keyboard,
profileSection: "keybindings",
}),
core({
id: "admin-general",
band: "instance",
labelKey: "settings.adminGeneral",
icon: Settings,
admin: true,
adminSection: "general",
}),
core({
id: "admin-users",
band: "instance",
labelKey: "settings.adminUsers",
icon: Users,
admin: true,
adminSection: "users",
}),
core({
id: "admin-sessions",
band: "instance",
labelKey: "settings.adminSessions",
icon: Monitor,
admin: true,
adminSection: "sessions",
}),
core({
id: "admin-roles",
band: "instance",
labelKey: "settings.adminRoles",
icon: Lock,
admin: true,
adminSection: "roles",
}),
core({
id: "admin-host-defaults",
band: "instance",
labelKey: "settings.adminHostDefaults",
icon: Server,
admin: true,
adminSection: "host-defaults",
}),
core({
id: "admin-branding",
band: "instance",
labelKey: "settings.adminBranding",
icon: Palette,
admin: true,
adminSection: "branding",
}),
core({
id: "admin-database",
band: "instance",
labelKey: "settings.adminDatabase",
icon: Database,
admin: true,
adminSection: "database",
}),
core({
id: "admin-ssl",
band: "instance",
labelKey: "settings.adminSsl",
icon: Globe,
admin: true,
adminSection: "ssl",
}),
core({
id: "admin-api-keys",
band: "instance",
labelKey: "settings.adminApiKeys",
icon: KeyRound,
admin: true,
adminSection: "api-keys",
}),
core({
id: "admin-audit-log",
band: "instance",
labelKey: "settings.adminAuditLog",
icon: FileText,
admin: true,
adminSection: "audit-log",
}),
];
/** The pages this user can reach, in nav order. */
export function visibleSettingsPages(
pages: SettingsPage[],
{ isAdmin, isElectron }: { isAdmin: boolean; isElectron: boolean },
): SettingsPage[] {
return pages.filter(
(page) => (!page.admin || isAdmin) && (!page.browserOnly || !isElectron),
);
}
export interface SettingsSearchHit {
page: SettingsPage;
/** The first matching phrase, to show under the page name. */
match: string;
}
/** Pages whose name or any label on them contains the query. */
export function searchSettingsPages(
pages: SettingsPage[],
query: string,
translate: (key: string) => string,
): SettingsSearchHit[] {
const needle = query.trim().toLowerCase();
if (!needle) return pages.map((page) => ({ page, match: "" }));
const hits: SettingsSearchHit[] = [];
for (const page of pages) {
const label = page.label ?? translate(page.labelKey);
if (label.toLowerCase().includes(needle)) {
hits.push({ page, match: "" });
continue;
}
const phrase = page.searchKeys
.map((key) => translate(key))
.find((text) => text.toLowerCase().includes(needle));
if (phrase) hits.push({ page, match: phrase });
}
return hits;
}
+427
View File
@@ -0,0 +1,427 @@
// Generated by scripts/generate-settings-search-index.cjs. Do not edit.
/** The i18n keys each core settings section shows, for search. */
export const SETTINGS_SEARCH_INDEX: Record<string, string[]> = {
"account": [
"newUi.sidebar.userProfile.authMethodLabel",
"newUi.sidebar.userProfile.betaProgramDescription",
"newUi.sidebar.userProfile.betaProgramFeedback",
"newUi.sidebar.userProfile.betaProgramLearnMore",
"newUi.sidebar.userProfile.betaProgramTitle",
"newUi.sidebar.userProfile.cliDescription",
"newUi.sidebar.userProfile.cliLearnMore",
"newUi.sidebar.userProfile.cliTitle",
"newUi.sidebar.userProfile.deleteAccount",
"newUi.sidebar.userProfile.deleteAccountDescription",
"newUi.sidebar.userProfile.deleteButton",
"newUi.sidebar.userProfile.desktopProfileDescription",
"newUi.sidebar.userProfile.desktopProfileTitle",
"newUi.sidebar.userProfile.linkedProfileDescription",
"newUi.sidebar.userProfile.linkedProfileTitle",
"newUi.sidebar.userProfile.manageSync",
"newUi.sidebar.userProfile.roleLabel",
"newUi.sidebar.userProfile.sectionAccount",
"newUi.sidebar.userProfile.setUpSync",
"newUi.sidebar.userProfile.twoFaLabel",
"newUi.sidebar.userProfile.twoFaOff",
"newUi.sidebar.userProfile.twoFaOn",
"newUi.sidebar.userProfile.usernameLabel",
"newUi.sidebar.userProfile.versionLabel",
],
"admin-api-keys": [
"admin.apiKeyCreatedWarning",
"admin.apiKeyExpiresAt",
"admin.apiKeyName",
"admin.apiKeyNoExpiry",
"admin.apiKeySelectUser",
"admin.apiKeyUser",
"admin.apiKeysCliHint",
"admin.apiKeysCliLink",
"admin.apiKeysCount",
"admin.apiKeysDocsLink",
"admin.copiedToClipboard",
"admin.createKey",
"admin.createRole",
"admin.creating",
"admin.done",
"admin.newApiKey",
"admin.revokeKey",
"admin.revokeKeyConfirm",
"admin.revokeKeyFailed",
"admin.revokeKeySuccess",
"admin.revokedBadge",
"admin.sectionApiKeys",
"common.cancel",
"placeholders.apiKeyName",
],
"admin-audit-log": [
"admin.auditForwardingDesc",
"admin.auditForwardingEnvNotice",
"admin.auditForwardingSaveError",
"admin.auditForwardingSaved",
"admin.auditForwardingTitle",
"admin.auditForwardingToken",
"admin.auditForwardingTokenStored",
"admin.auditLogClearFilters",
"admin.auditLogEmpty",
"admin.auditLogFailed",
"admin.auditLogFilterAction",
"admin.auditLogFilterAll",
"admin.auditLogFilterFrom",
"admin.auditLogFilterResourceType",
"admin.auditLogFilterStatus",
"admin.auditLogFilterTo",
"admin.auditLogFilterUser",
"admin.auditLogIp",
"admin.auditLogPage",
"admin.auditLogResourceId",
"admin.auditLogSuccess",
"admin.auditLogTotal",
"admin.sectionAuditLog",
"common.save",
],
"admin-branding": [
"admin.brandingAppName",
"admin.brandingAppNameDesc",
"admin.brandingLogo",
"admin.brandingLogoDesc",
"admin.brandingLogoInvalid",
"admin.brandingLogoReset",
"admin.brandingLogoUpload",
"admin.brandingTagline",
"admin.brandingTaglineDesc",
"admin.sectionBranding",
"common.loading",
"common.save",
"common.saving",
],
"admin-database": [
"admin.changeFile",
"admin.export",
"admin.exportDatabase",
"admin.exportDatabaseDesc",
"admin.exporting",
"admin.import",
"admin.importDatabase",
"admin.importDatabaseDesc",
"admin.importDatabaseSelected",
"admin.importing",
"admin.sectionDatabase",
"admin.selectFile",
],
"admin-general": [
"admin.allowPasswordLogin",
"admin.allowPasswordLoginDesc",
"admin.allowPasswordReset",
"admin.allowPasswordResetDesc",
"admin.allowRegistration",
"admin.allowRegistrationDesc",
"admin.externalAutoProvision",
"admin.externalAutoProvisionDesc",
"admin.hours",
"admin.logLevel",
"admin.monitoringDefaults",
"admin.notificationPrivateEndpoints",
"admin.notificationPrivateEndpointsDesc",
"admin.passwordLoginForced",
"admin.sec",
"admin.secondFactorAfterExternalLogin",
"admin.secondFactorAfterExternalLoginDesc",
"admin.sectionGeneral",
"admin.sessionTimeout",
"admin.sessionTimeoutRange",
"admin.statusCheck",
"common.save",
],
"admin-host-defaults": [
"admin.hostTags",
"admin.hostTagsDesc",
"admin.hostTagsError",
"admin.sectionHostDefaults",
"common.remove",
"hostDefaults.adminSectionDescription",
"hostDefaults.editServerDefaults",
"hosts.addTag",
],
"admin-roles": [
"admin.createRole",
"admin.creating",
"admin.customBadge",
"admin.deleteRoleConfirm",
"admin.deleteRoleSuccess",
"admin.newRole",
"admin.roleDescription",
"admin.roleDisplayName",
"admin.roleName",
"admin.rolePermissions.count",
"admin.rolePermissions.editAction",
"admin.rolePermissions.loadError",
"admin.rolePermissions.pluginDisabled",
"admin.rolePermissions.save",
"admin.rolePermissions.saveError",
"admin.rolePermissions.saved",
"admin.rolePermissions.saving",
"admin.rolesCount",
"admin.sectionRoles",
"admin.systemBadge",
"common.cancel",
"common.optional",
],
"admin-sessions": [
"admin.revokeAll",
"admin.revokeAllSessionsConfirm",
"admin.revokeAllSessionsFailed",
"admin.revokeAllSessionsSuccess",
"admin.revokeSession",
"admin.revokeSessionConfirm",
"admin.revokeSessionFailed",
"admin.sectionSessions",
"admin.sessionActive",
"admin.sessionExpires",
"admin.sessionsActive",
"admin.youBadge",
],
"admin-ssl": [
"admin.sectionSsl",
"admin.sslCertExpiresAt",
"admin.sslCertStatus",
"admin.sslCertStatusExpired",
"admin.sslCertStatusExpiring",
"admin.sslCertStatusNone",
"admin.sslCertStatusValid",
"admin.sslDescription",
"admin.sslDocsLink",
"admin.sslInfoNote",
"admin.sslIssuer",
"admin.sslManualCert",
"admin.sslManualCertPlaceholder",
"admin.sslManualDesc",
"admin.sslManualKey",
"admin.sslManualKeyPlaceholder",
"admin.sslManualTitle",
"admin.sslManualUpload",
"admin.sslManualUploadLoading",
"admin.sslNames",
"admin.sslNotRenewed",
"admin.sslRenewedBy",
"admin.sslSelfSigned",
],
"admin-users": [
"admin.addRole",
"admin.adminBadge",
"admin.adminStatusAdministrator",
"admin.adminStatusRegularUser",
"admin.assignRoleFailed",
"admin.authTypeDual",
"admin.authTypeExternal",
"admin.authTypeLocal",
"admin.createUser",
"admin.createUserDesc",
"admin.createUserEnterUsername",
"admin.createUserPassword",
"admin.createUserPasswordHint",
"admin.createUserSubmit",
"admin.createUserTitle",
"admin.createUserUsername",
"admin.creating",
"admin.deleteUser",
"admin.deleteUserConfirm",
"admin.deleteUserFailed",
"admin.deleteUserSuccess",
"admin.deleteUserWarning",
"admin.deleting",
"admin.editUserAdminStatus",
"admin.editUserAuthType",
"admin.editUserDesc",
"admin.editUserTitle",
"admin.editUserUserId",
"admin.editUserUsername",
"admin.linkAccountDesc",
"admin.linkAccountDescLocal",
"admin.linkAccountEffect1",
"admin.linkAccountEffect2",
"admin.linkAccountEffect3",
"admin.linkAccountExternalNotFound",
"admin.linkAccountExternalPlaceholder",
"admin.linkAccountExternalUsername",
"admin.linkAccountFailed",
"admin.linkAccountInProgress",
"admin.linkAccountSuccess",
"admin.linkAccountTargetPlaceholder",
"admin.linkAccountTargetUsername",
"admin.linkAccountTitle",
"admin.linkAccountWarningTitle",
"admin.linkAccounts",
"admin.manageUserData",
"admin.noCustomRoles",
"admin.noUsers",
"admin.noUsersMatch",
"admin.pageOf",
"admin.removeRoleFailed",
"admin.revoke",
"admin.revokeAllUserSessions",
"admin.revokeAllUserSessionsDesc",
"admin.searchUsers",
"admin.sectionUsers",
"admin.unlinkAccount",
"admin.unlinkAccountDesc",
"admin.unlinkAccountFailed",
"admin.unlinkAccountInProgress",
"admin.unlinkAccountSuccess",
"admin.unlinkAccountTitle",
"admin.unlinkAccountWarning",
"admin.userAdminAccess",
"admin.userAdminAccessDesc",
"admin.userRoles",
"admin.usersCount",
"common.cancel",
"common.loading",
"common.next",
"common.previous",
"manage.cannotBeUndone",
"placeholders.enterPassword",
],
"api-keys": [
"admin.revokeKey",
"admin.revokeKeyConfirm",
"newUi.sidebar.userProfile.apiKeyActive",
"newUi.sidebar.userProfile.apiKeyCount",
"newUi.sidebar.userProfile.apiKeyCreateFailed",
"newUi.sidebar.userProfile.apiKeyCreated",
"newUi.sidebar.userProfile.apiKeyCreatedWarning",
"newUi.sidebar.userProfile.apiKeyExpires",
"newUi.sidebar.userProfile.apiKeyNameLabel",
"newUi.sidebar.userProfile.apiKeyNamePlaceholder",
"newUi.sidebar.userProfile.apiKeyNameRequired",
"newUi.sidebar.userProfile.apiKeyPermissionsHint",
"newUi.sidebar.userProfile.apiKeyRevokeFailed",
"newUi.sidebar.userProfile.apiKeyRevoked",
"newUi.sidebar.userProfile.apiKeyUsageHint",
"newUi.sidebar.userProfile.apiKeyUsageHintHeader",
"newUi.sidebar.userProfile.apiKeyUser",
"newUi.sidebar.userProfile.cancel",
"newUi.sidebar.userProfile.copiedToClipboard",
"newUi.sidebar.userProfile.copyApiKey",
"newUi.sidebar.userProfile.createApiKeyDescription",
"newUi.sidebar.userProfile.createApiKeyTitle",
"newUi.sidebar.userProfile.createKey",
"newUi.sidebar.userProfile.done",
"newUi.sidebar.userProfile.expiryDateLabel",
"newUi.sidebar.userProfile.newKey",
"newUi.sidebar.userProfile.noApiKeys",
"newUi.sidebar.userProfile.optional",
"newUi.sidebar.userProfile.sectionApiKeys",
],
"appearance": [
"newUi.sidebar.userProfile.accentColorLabel",
"newUi.sidebar.userProfile.colorPickerTooltip",
"newUi.sidebar.userProfile.commandPalette",
"newUi.sidebar.userProfile.commandPaletteDesc",
"newUi.sidebar.userProfile.confirmTabClose",
"newUi.sidebar.userProfile.confirmTabCloseDesc",
"newUi.sidebar.userProfile.disableUpdateChecks",
"newUi.sidebar.userProfile.disableUpdateChecksDesc",
"newUi.sidebar.userProfile.expandAppRailOnHover",
"newUi.sidebar.userProfile.expandAppRailOnHoverDesc",
"newUi.sidebar.userProfile.fontSizeLabel",
"newUi.sidebar.userProfile.interfaceFontDescription",
"newUi.sidebar.userProfile.interfaceFontLabel",
"newUi.sidebar.userProfile.keyboardShortcuts",
"newUi.sidebar.userProfile.keyboardShortcutsDescription",
"newUi.sidebar.userProfile.languageLabel",
"newUi.sidebar.userProfile.manageShortcuts",
"newUi.sidebar.userProfile.navigationTabsDesc",
"newUi.sidebar.userProfile.pinAppRail",
"newUi.sidebar.userProfile.pinAppRailDesc",
"newUi.sidebar.userProfile.reopenTabsOnLogin",
"newUi.sidebar.userProfile.reopenTabsOnLoginDesc",
"newUi.sidebar.userProfile.sectionAppearance",
"newUi.sidebar.userProfile.settingsAppRail",
"newUi.sidebar.userProfile.settingsNavigation",
"newUi.sidebar.userProfile.settingsTabsAndShortcuts",
"newUi.sidebar.userProfile.settingsUpdates",
"newUi.sidebar.userProfile.showPinAppRailButton",
"newUi.sidebar.userProfile.showPinAppRailButtonDesc",
"newUi.sidebar.userProfile.showTabNumbers",
"newUi.sidebar.userProfile.showTabNumbersDesc",
"newUi.sidebar.userProfile.sidebarSettingsMoved",
"newUi.sidebar.userProfile.themeLabel",
],
"data": [
"newUi.sidebar.userProfile.cancel",
"newUi.sidebar.userProfile.changeFile",
"newUi.sidebar.userProfile.confirmPasswordDeletePlaceholder",
"newUi.sidebar.userProfile.confirmPasswordLabel",
"newUi.sidebar.userProfile.deleteAccount",
"newUi.sidebar.userProfile.deleteAccountPermanent",
"newUi.sidebar.userProfile.deleteAccountWarning",
"newUi.sidebar.userProfile.deleting",
"newUi.sidebar.userProfile.export",
"newUi.sidebar.userProfile.exportData",
"newUi.sidebar.userProfile.exportDataDesc",
"newUi.sidebar.userProfile.exporting",
"newUi.sidebar.userProfile.import",
"newUi.sidebar.userProfile.importData",
"newUi.sidebar.userProfile.importDataDesc",
"newUi.sidebar.userProfile.importDataSelected",
"newUi.sidebar.userProfile.importing",
"newUi.sidebar.userProfile.sectionData",
"newUi.sidebar.userProfile.selectFile",
],
"interface": [
"newUi.sidebar.userProfile.interfacePresetDesc",
"newUi.sidebar.userProfile.presetClearOverrides",
"newUi.sidebar.userProfile.presetConfirmApply",
"newUi.sidebar.userProfile.presetConfirmDesc",
"newUi.sidebar.userProfile.presetConfirmTitle",
"newUi.sidebar.userProfile.presetOverridesTitle",
"newUi.sidebar.userProfile.presetRevertArea",
"newUi.sidebar.userProfile.runSetupAgainButton",
"newUi.sidebar.userProfile.runSetupAgainDesc",
"newUi.sidebar.userProfile.sectionInterface",
],
"keybindings": [
"newUi.sidebar.keybindings.actionLabel",
"newUi.sidebar.keybindings.actionRequiredError",
"newUi.sidebar.keybindings.addBinding",
"newUi.sidebar.keybindings.addBindingTitle",
"newUi.sidebar.keybindings.cancel",
"newUi.sidebar.keybindings.comboRequiredError",
"newUi.sidebar.keybindings.conflictWarning",
"newUi.sidebar.keybindings.customHeading",
"newUi.sidebar.keybindings.customize",
"newUi.sidebar.keybindings.customizedBadge",
"newUi.sidebar.keybindings.defaultBadge",
"newUi.sidebar.keybindings.defaultsHeading",
"newUi.sidebar.keybindings.deleteConfirm",
"newUi.sidebar.keybindings.description",
"newUi.sidebar.keybindings.editBindingTitle",
"newUi.sidebar.keybindings.loading",
"newUi.sidebar.keybindings.noCustomBindings",
"newUi.sidebar.keybindings.pressKeysPlaceholder",
"newUi.sidebar.keybindings.pressKeysToRecord",
"newUi.sidebar.keybindings.recording",
"newUi.sidebar.keybindings.resetAllConfirm",
"newUi.sidebar.keybindings.resetAllToDefaults",
"newUi.sidebar.keybindings.resetToDefault",
"newUi.sidebar.keybindings.saveBinding",
"newUi.sidebar.keybindings.saveError",
"newUi.sidebar.keybindings.unavailableAction",
],
"security": [
"newUi.sidebar.userProfile.changePassword",
"newUi.sidebar.userProfile.confirmPasswordLabel",
"newUi.sidebar.userProfile.confirmPasswordPlaceholder",
"newUi.sidebar.userProfile.currentPasswordLabel",
"newUi.sidebar.userProfile.currentPasswordPlaceholder",
"newUi.sidebar.userProfile.newPasswordLabel",
"newUi.sidebar.userProfile.newPasswordPlaceholder",
"newUi.sidebar.userProfile.passwordFieldsRequired",
"newUi.sidebar.userProfile.passwordMismatch",
"newUi.sidebar.userProfile.passwordTooShort",
"newUi.sidebar.userProfile.passwordUpdateFailed",
"newUi.sidebar.userProfile.passwordUpdated",
"newUi.sidebar.userProfile.sectionSecurity",
"newUi.sidebar.userProfile.updatePassword",
],
};
+239 -212
View File
@@ -1,5 +1,6 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { InlineView, useSurfaceKind } from "@/components/surface/surface-scope";
import { toast } from "sonner";
import { AlertCircle, Loader2, X } from "lucide-react";
import { Button } from "@/components/button";
@@ -222,6 +223,241 @@ export function LinkServerDialog({
0,
);
const wide = step === "signin";
const inSurface = useSurfaceKind() !== null;
const title = relogin
? t("sync.wizard.reloginTitle")
: t("sync.wizard.title");
const description =
step === "server"
? t("sync.wizard.serverDescription")
: step === "signin"
? t("sync.wizard.signinDescription", { url: target.serverUrl })
: step === "choose"
? t("sync.wizard.chooseDescription")
: t("sync.wizard.finishingDescription");
const content = (
<>
{step === "server" && (
<form
className="flex flex-col gap-4"
onSubmit={(event) => {
event.preventDefault();
void handleCheck();
}}
>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold" htmlFor="sync-server-url">
{t("sync.wizard.serverUrl")}
</label>
<Input
id="sync-server-url"
autoFocus
className="rounded-none"
placeholder="https://termix.example.com"
value={target.serverUrl}
onChange={(event) =>
setTarget({ ...target, serverUrl: event.target.value })
}
/>
<p className="text-[10px] text-muted-foreground">
{t("sync.wizard.serverUrlHint")}
</p>
{!relogin && savedUrls.length > 0 && (
<div className="flex flex-col gap-1 pt-1">
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
{t("sync.wizard.savedServers")}
</span>
{savedUrls.map((url) => (
<div
key={url}
className="flex items-center border border-border hover:bg-muted"
>
<button
type="button"
className="flex-1 min-w-0 truncate px-2 py-1.5 text-left font-mono text-xs"
onClick={() => setTarget({ ...target, serverUrl: url })}
>
{url}
</button>
<button
type="button"
title={t("sync.wizard.removeServer")}
className="px-2 py-1.5 text-muted-foreground hover:text-destructive"
onClick={() => setSavedUrls(forgetServerUrl(url))}
>
<X className="size-3" />
</button>
</div>
))}
</div>
)}
</div>
{problem && (
<div className="flex gap-2 border border-destructive/40 bg-destructive/10 p-2.5 text-xs">
<AlertCircle className="size-4 shrink-0 text-destructive" />
<span>{problem}</span>
</div>
)}
{(problemKind === "tls_untrusted" ||
target.allowInvalidCertificate) && (
<div className="flex items-center justify-between gap-3 border border-border p-2.5">
<div className="flex flex-col gap-0.5">
<span className="text-xs font-medium">
{t("sync.proxy.allowInvalidCertificate")}
</span>
<span className="text-[10px] text-muted-foreground">
{t("sync.proxy.allowInvalidCertificateHint")}
</span>
</div>
<Switch
checked={!!target.allowInvalidCertificate}
onCheckedChange={(checked) =>
setTarget({ ...target, allowInvalidCertificate: checked })
}
/>
</div>
)}
<button
type="button"
className="self-start text-[10px] font-bold uppercase tracking-widest text-muted-foreground hover:text-foreground"
onClick={() => setShowAdvanced((value) => !value)}
>
{showAdvanced
? t("sync.wizard.hideProxySettings")
: t("sync.wizard.showProxySettings")}
</button>
{showAdvanced && (
<ProxySettingsFields
customHeaders={target.customHeaders ?? []}
basicAuth={target.basicAuth ?? null}
onChange={(next) => setTarget({ ...target, ...next })}
/>
)}
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
className="rounded-none"
onClick={() => onOpenChange(false)}
>
{t("common.cancel")}
</Button>
<Button
type="submit"
className="rounded-none"
disabled={checking || !target.serverUrl.trim()}
>
{checking && <Loader2 className="size-4 animate-spin" />}
{t("sync.wizard.continue")}
</Button>
</div>
</form>
)}
{step === "signin" && (
<div className="flex-1 min-h-0">
<ElectronLoginForm
serverUrl={target.serverUrl}
targetPurpose="link"
onAuthSuccess={handleSignedIn}
onChangeServer={() =>
relogin ? onOpenChange(false) : setStep("server")
}
/>
</div>
)}
{step === "choose" && (
<div className="flex flex-col gap-3">
{finishError && (
<div className="flex gap-2 border border-destructive/40 bg-destructive/10 p-2.5 text-xs">
<AlertCircle className="size-4 shrink-0 text-destructive" />
<span>{finishError}</span>
</div>
)}
{(
[
{
value: "merge",
title: t("sync.wizard.mergeTitle"),
body: t("sync.wizard.mergeBody", { count: localTotal }),
},
{
value: "replace",
title: t("sync.wizard.replaceTitle"),
body: t("sync.wizard.replaceBody", { count: localTotal }),
},
] as const
).map((option) => (
<button
key={option.value}
type="button"
onClick={() => setMode(option.value)}
className={`text-left border p-3 flex flex-col gap-1 transition-colors ${
mode === option.value
? "border-accent-brand bg-accent-brand/10"
: "border-border hover:bg-muted/40"
}`}
>
<span className="text-sm font-semibold">{option.title}</span>
<span className="text-xs text-muted-foreground">
{option.body}
</span>
</button>
))}
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
className="rounded-none"
onClick={() => onOpenChange(false)}
>
{t("common.cancel")}
</Button>
<Button
type="button"
className="rounded-none"
disabled={!token}
onClick={() => token && void finish(token, mode)}
>
{t("sync.wizard.link")}
</Button>
</div>
</div>
)}
{step === "finishing" && (
<div className="flex items-center gap-3 py-6 text-sm text-muted-foreground">
<Loader2 className="size-5 animate-spin text-accent-brand" />
{t("sync.wizard.linking")}
</div>
)}
</>
);
// From a sidebar panel the wizard takes over the panel. Outside one (the
// first-run setup) it stays a dialog.
if (inSurface) {
return (
<InlineView
open={open}
onOpenChange={onOpenChange}
title={title}
width="wide"
scroll={!wide}
>
{!wide && (
<p className="text-xs text-muted-foreground">{description}</p>
)}
{content}
</InlineView>
);
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@@ -235,220 +471,11 @@ export function LinkServerDialog({
onInteractOutside={(event) => wide && event.preventDefault()}
>
<DialogHeader className={wide ? "sr-only" : undefined}>
<DialogTitle>
{relogin ? t("sync.wizard.reloginTitle") : t("sync.wizard.title")}
</DialogTitle>
<DialogDescription>
{step === "server" && t("sync.wizard.serverDescription")}
{step === "signin" &&
t("sync.wizard.signinDescription", { url: target.serverUrl })}
{step === "choose" && t("sync.wizard.chooseDescription")}
{step === "finishing" && t("sync.wizard.finishingDescription")}
</DialogDescription>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
{step === "server" && (
<form
className="flex flex-col gap-4"
onSubmit={(event) => {
event.preventDefault();
void handleCheck();
}}
>
<div className="flex flex-col gap-1.5">
<label
className="text-xs font-semibold"
htmlFor="sync-server-url"
>
{t("sync.wizard.serverUrl")}
</label>
<Input
id="sync-server-url"
autoFocus
className="rounded-none"
placeholder="https://termix.example.com"
value={target.serverUrl}
onChange={(event) =>
setTarget({ ...target, serverUrl: event.target.value })
}
/>
<p className="text-[10px] text-muted-foreground">
{t("sync.wizard.serverUrlHint")}
</p>
{!relogin && savedUrls.length > 0 && (
<div className="flex flex-col gap-1 pt-1">
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
{t("sync.wizard.savedServers")}
</span>
{savedUrls.map((url) => (
<div
key={url}
className="flex items-center border border-border hover:bg-muted"
>
<button
type="button"
className="flex-1 min-w-0 truncate px-2 py-1.5 text-left font-mono text-xs"
onClick={() => setTarget({ ...target, serverUrl: url })}
>
{url}
</button>
<button
type="button"
title={t("sync.wizard.removeServer")}
className="px-2 py-1.5 text-muted-foreground hover:text-destructive"
onClick={() => setSavedUrls(forgetServerUrl(url))}
>
<X className="size-3" />
</button>
</div>
))}
</div>
)}
</div>
{problem && (
<div className="flex gap-2 border border-destructive/40 bg-destructive/10 p-2.5 text-xs">
<AlertCircle className="size-4 shrink-0 text-destructive" />
<span>{problem}</span>
</div>
)}
{(problemKind === "tls_untrusted" ||
target.allowInvalidCertificate) && (
<div className="flex items-center justify-between gap-3 border border-border p-2.5">
<div className="flex flex-col gap-0.5">
<span className="text-xs font-medium">
{t("sync.proxy.allowInvalidCertificate")}
</span>
<span className="text-[10px] text-muted-foreground">
{t("sync.proxy.allowInvalidCertificateHint")}
</span>
</div>
<Switch
checked={!!target.allowInvalidCertificate}
onCheckedChange={(checked) =>
setTarget({ ...target, allowInvalidCertificate: checked })
}
/>
</div>
)}
<button
type="button"
className="self-start text-[10px] font-bold uppercase tracking-widest text-muted-foreground hover:text-foreground"
onClick={() => setShowAdvanced((value) => !value)}
>
{showAdvanced
? t("sync.wizard.hideProxySettings")
: t("sync.wizard.showProxySettings")}
</button>
{showAdvanced && (
<ProxySettingsFields
customHeaders={target.customHeaders ?? []}
basicAuth={target.basicAuth ?? null}
onChange={(next) => setTarget({ ...target, ...next })}
/>
)}
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
className="rounded-none"
onClick={() => onOpenChange(false)}
>
{t("common.cancel")}
</Button>
<Button
type="submit"
className="rounded-none"
disabled={checking || !target.serverUrl.trim()}
>
{checking && <Loader2 className="size-4 animate-spin" />}
{t("sync.wizard.continue")}
</Button>
</div>
</form>
)}
{step === "signin" && (
<div className="flex-1 min-h-0">
<ElectronLoginForm
serverUrl={target.serverUrl}
targetPurpose="link"
onAuthSuccess={handleSignedIn}
onChangeServer={() =>
relogin ? onOpenChange(false) : setStep("server")
}
/>
</div>
)}
{step === "choose" && (
<div className="flex flex-col gap-3">
{finishError && (
<div className="flex gap-2 border border-destructive/40 bg-destructive/10 p-2.5 text-xs">
<AlertCircle className="size-4 shrink-0 text-destructive" />
<span>{finishError}</span>
</div>
)}
{(
[
{
value: "merge",
title: t("sync.wizard.mergeTitle"),
body: t("sync.wizard.mergeBody", { count: localTotal }),
},
{
value: "replace",
title: t("sync.wizard.replaceTitle"),
body: t("sync.wizard.replaceBody", { count: localTotal }),
},
] as const
).map((option) => (
<button
key={option.value}
type="button"
onClick={() => setMode(option.value)}
className={`text-left border p-3 flex flex-col gap-1 transition-colors ${
mode === option.value
? "border-accent-brand bg-accent-brand/10"
: "border-border hover:bg-muted/40"
}`}
>
<span className="text-sm font-semibold">{option.title}</span>
<span className="text-xs text-muted-foreground">
{option.body}
</span>
</button>
))}
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
className="rounded-none"
onClick={() => onOpenChange(false)}
>
{t("common.cancel")}
</Button>
<Button
type="button"
className="rounded-none"
disabled={!token}
onClick={() => token && void finish(token, mode)}
>
{t("sync.wizard.link")}
</Button>
</div>
</div>
)}
{step === "finishing" && (
<div className="flex items-center gap-3 py-6 text-sm text-muted-foreground">
<Loader2 className="size-5 animate-spin text-accent-brand" />
{t("sync.wizard.linking")}
</div>
)}
{content}
</DialogContent>
</Dialog>
);
+47 -50
View File
@@ -1,3 +1,4 @@
import { InlineView } from "@/components/surface/surface-scope";
import {
useCallback,
useEffect,
@@ -17,14 +18,6 @@ import {
} from "lucide-react";
import { Button } from "@/components/button";
import { Switch } from "@/components/switch";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/dialog";
import { useSyncStatus } from "@/hooks/use-sync-status";
import { refreshSyncStatus, setSyncStatus } from "@/lib/sync-status";
import { notifySyncChanged } from "@/lib/linked-server";
@@ -565,45 +558,12 @@ export function SyncPanel() {
relogin={status.serverUrl ? { serverUrl: status.serverUrl } : undefined}
/>
<Dialog open={unlinkOpen} onOpenChange={setUnlinkOpen}>
<DialogContent className="bg-card border border-border rounded-none sm:max-w-md">
<DialogHeader>
<DialogTitle>{t("sync.unlinkTitle")}</DialogTitle>
<DialogDescription>{t("sync.unlinkDescription")}</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-2">
{(
[
{
value: true,
title: t("sync.unlinkKeepTitle"),
body: t("sync.unlinkKeepBody"),
},
{
value: false,
title: t("sync.unlinkRemoveTitle"),
body: t("sync.unlinkRemoveBody"),
},
] as const
).map((option) => (
<button
key={String(option.value)}
type="button"
onClick={() => setKeepData(option.value)}
className={`text-left border p-3 flex flex-col gap-1 transition-colors ${
keepData === option.value
? "border-accent-brand bg-accent-brand/10"
: "border-border hover:bg-muted/40"
}`}
>
<span className="text-sm font-semibold">{option.title}</span>
<span className="text-xs text-muted-foreground">
{option.body}
</span>
</button>
))}
</div>
<DialogFooter>
<InlineView
open={unlinkOpen}
onOpenChange={setUnlinkOpen}
title={t("sync.unlinkTitle")}
footer={
<div className="ml-auto flex flex-wrap items-center justify-end gap-2">
<Button
variant="outline"
className="rounded-none"
@@ -626,9 +586,46 @@ export function SyncPanel() {
{busy === "unlink" && <Loader2 className="size-4 animate-spin" />}
{t("sync.unlink")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
}
>
<p className="text-xs text-muted-foreground">
{t("sync.unlinkDescription")}
</p>
<div className="flex flex-col gap-2">
{(
[
{
value: true,
title: t("sync.unlinkKeepTitle"),
body: t("sync.unlinkKeepBody"),
},
{
value: false,
title: t("sync.unlinkRemoveTitle"),
body: t("sync.unlinkRemoveBody"),
},
] as const
).map((option) => (
<button
key={String(option.value)}
type="button"
onClick={() => setKeepData(option.value)}
className={`text-left border p-3 flex flex-col gap-1 transition-colors ${
keepData === option.value
? "border-accent-brand bg-accent-brand/10"
: "border-border hover:bg-muted/40"
}`}
>
<span className="text-sm font-semibold">{option.title}</span>
<span className="text-xs text-muted-foreground">
{option.body}
</span>
</button>
))}
</div>
</InlineView>
{linkDialog}
</div>
);