mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 15:15:24 +00:00
feat: port the demo redesign with Manage and Settings tabs
This commit is contained in:
1 parent
f8151cb885
commit
35c954b79a
128 files changed
+13262
-8143
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
],
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user