Settings: db-viewer redesign, theme/accent wiring, macOS overlay titlebar (#6)
* feat: settings back button returns to origin view (push/pop nav) * feat: redesign settings screen with db-viewer border styling * fix: settings sidebar — icon+text tabs, back at top, top border (PR feedback) * fix: tighten settings sidebar — back and tabs in one group * fix: settings header shows active tab name instead of duplicated title * fix: settings — accent back button, header shows active tab; fix App test * fix: tags settings — no create label, no card chrome (bg/border/padding) * feat: drag-and-drop tag reordering in settings tags tab * fix: general settings — remove section card chrome (bg/border/padding) * fix: merge Appearance and Interface into one settings section * fix: remove row separators between settings items within sections * fix: settings rows use gap spacing instead of per-row padding * feat: strip section cards from all settings tabs; delete SettingsSection * feat: apply theme and font size settings (appearance wiring) * feat: honor default folder setting on startup * feat: confirm_before_delete setting now skips delete confirmations * feat: default_ports setting prefills new connection forms * fix: light theme — text colors, native window chrome, ThemePicker preview * fix: sync window background color with theme so title bar follows light mode * fix: window-sync test uses microtask flush instead of vi.waitFor * fix: app root carries canvas bg so home-screen parent matches theme * fix: grant window set-theme/set-background-color permissions so title bar follows theme * fix: overlay title bar — webview paints under traffic lights, flat canvas color in both themes * fix: compact 28px overlay titlebar with visible bottom border * fix: in-flow titlebar strip (draggable via allowed permission); screens fill remaining height, no bottom clipping * fix: top border only on settings + db viewer pages, not the titlebar strip * fix: db viewer sidebar h-screen -> h-full (28px overflow clipped bottom icons) * fix: system theme resets window to OS before reading matchMedia (was polluted by forced window theme) * fix: theme switcher labels moved below previews for readability * feat: configurable accent color setting (circle palette in Appearance) * docs: update AGENTS.md + README for settings redesign, wired settings, accent color
This commit is contained in:
@@ -2,7 +2,6 @@ import { useSettingsStore } from "../../stores/settingsStore";
|
||||
import { Input } from "../ui/Input";
|
||||
import { Toggle } from "../ui/Toggle";
|
||||
import { SettingsRow } from "../ui/SettingsRow";
|
||||
import { SettingsSection } from "../ui/SettingsSection";
|
||||
import type { DbType } from "../../lib/types";
|
||||
|
||||
const DB_TYPES: { id: DbType; label: string }[] = [
|
||||
@@ -31,39 +30,45 @@ export function AdvancedSettingsTab() {
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSection title="Safety">
|
||||
<SettingsRow
|
||||
title="Confirm before delete"
|
||||
description="Show a confirmation dialog before deleting connections or folders."
|
||||
>
|
||||
<Toggle
|
||||
checked={settings.confirm_before_delete}
|
||||
onChange={(checked) =>
|
||||
updateSetting("confirm_before_delete", checked ? "true" : "false")
|
||||
}
|
||||
label="Confirm before delete"
|
||||
/>
|
||||
</SettingsRow>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Default ports">
|
||||
{DB_TYPES.map((db) => (
|
||||
<div className="space-y-6">
|
||||
<section>
|
||||
<h2 className="text-sm font-medium text-text mb-3">Safety</h2>
|
||||
<div className="flex flex-col gap-4">
|
||||
<SettingsRow
|
||||
key={db.id}
|
||||
title={db.label}
|
||||
description={`Default port for new ${db.label} connections.`}
|
||||
title="Confirm before delete"
|
||||
description="Show a confirmation dialog before deleting connections or folders."
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
value={defaultPorts[db.id]?.toString() ?? ""}
|
||||
onChange={(value) => updatePort(db.id, value)}
|
||||
className="w-24"
|
||||
aria-label={`Default port for ${db.label}`}
|
||||
<Toggle
|
||||
checked={settings.confirm_before_delete}
|
||||
onChange={(checked) =>
|
||||
updateSetting("confirm_before_delete", checked ? "true" : "false")
|
||||
}
|
||||
label="Confirm before delete"
|
||||
/>
|
||||
</SettingsRow>
|
||||
))}
|
||||
</SettingsSection>
|
||||
</>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-sm font-medium text-text mb-3">Default ports</h2>
|
||||
<div className="flex flex-col gap-4">
|
||||
{DB_TYPES.map((db) => (
|
||||
<SettingsRow
|
||||
key={db.id}
|
||||
title={db.label}
|
||||
description={`Default port for new ${db.label} connections.`}
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
value={defaultPorts[db.id]?.toString() ?? ""}
|
||||
onChange={(value) => updatePort(db.id, value)}
|
||||
className="w-24"
|
||||
aria-label={`Default port for ${db.label}`}
|
||||
/>
|
||||
</SettingsRow>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,8 +2,8 @@ import { useSettingsStore } from "../../stores/settingsStore";
|
||||
import { useConnectionStore } from "../../stores/connectionStore";
|
||||
import { Select } from "../ui/Select";
|
||||
import { ThemePicker } from "../ui/ThemePicker";
|
||||
import { AccentPicker } from "../ui/AccentPicker";
|
||||
import { SettingsRow } from "../ui/SettingsRow";
|
||||
import { SettingsSection } from "../ui/SettingsSection";
|
||||
import * as cmd from "../../lib/commands";
|
||||
import type { FontSize } from "../../lib/types";
|
||||
|
||||
@@ -50,80 +50,98 @@ export function GeneralSettingsTab() {
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSection title="Appearance">
|
||||
<SettingsRow title="Theme" description="Choose your preferred appearance.">
|
||||
<ThemePicker
|
||||
value={settings.theme}
|
||||
onChange={(theme) => updateSetting("theme", theme)}
|
||||
/>
|
||||
</SettingsRow>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Interface">
|
||||
<SettingsRow title="Font size" description="Adjust the application font size.">
|
||||
<Select
|
||||
value={settings.font_size}
|
||||
onChange={(value) => updateSetting("font_size", value)}
|
||||
options={FONT_SIZE_OPTIONS}
|
||||
label="Font size"
|
||||
/>
|
||||
</SettingsRow>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Workspace">
|
||||
<SettingsRow
|
||||
title="Default folder"
|
||||
description="Select the folder to show on startup."
|
||||
>
|
||||
<Select
|
||||
value={settings.default_folder_id ?? ""}
|
||||
onChange={(value) => updateSetting("default_folder_id", value)}
|
||||
options={folderOptions}
|
||||
label="Default folder"
|
||||
/>
|
||||
</SettingsRow>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Table defaults">
|
||||
<SettingsRow
|
||||
title="Auto-refresh rate"
|
||||
description="How often tables auto-refresh by default."
|
||||
>
|
||||
<Select
|
||||
value={String(settings.table_refresh_rate ?? 0)}
|
||||
onChange={(value) => updateSetting("table_refresh_rate", value)}
|
||||
options={REFRESH_RATE_OPTIONS}
|
||||
label="Auto-refresh rate"
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
title="Rows per page"
|
||||
description="Default number of rows shown per page."
|
||||
>
|
||||
<Select
|
||||
value={String(settings.table_page_size ?? 50)}
|
||||
onChange={(value) => updateSetting("table_page_size", value)}
|
||||
options={PAGE_SIZE_OPTIONS}
|
||||
label="Rows per page"
|
||||
/>
|
||||
</SettingsRow>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Demo">
|
||||
<SettingsRow
|
||||
title="Re-add demo database"
|
||||
description="Re-create the demo SQLite connection if it was deleted."
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleReAddDemo}
|
||||
className="rounded-lg border border-border px-3 py-1.5 text-sm text-text hover:bg-surface-raised transition-colors"
|
||||
<div className="space-y-6">
|
||||
<section>
|
||||
<h2 className="text-sm font-medium text-text mb-3">Appearance</h2>
|
||||
<div className="flex flex-col gap-4">
|
||||
<SettingsRow title="Theme" description="Choose your preferred appearance.">
|
||||
<ThemePicker
|
||||
value={settings.theme}
|
||||
onChange={(theme) => updateSetting("theme", theme)}
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow title="Font size" description="Adjust the application font size.">
|
||||
<Select
|
||||
value={settings.font_size}
|
||||
onChange={(value) => updateSetting("font_size", value)}
|
||||
options={FONT_SIZE_OPTIONS}
|
||||
label="Font size"
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
title="Accent color"
|
||||
description="Used for buttons, active states, and highlights."
|
||||
>
|
||||
Re-add demo
|
||||
</button>
|
||||
</SettingsRow>
|
||||
</SettingsSection>
|
||||
</>
|
||||
<AccentPicker
|
||||
value={settings.accent_color}
|
||||
onChange={(color) => updateSetting("accent_color", color)}
|
||||
/>
|
||||
</SettingsRow>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-sm font-medium text-text mb-3">Workspace</h2>
|
||||
<div className="flex flex-col gap-4">
|
||||
<SettingsRow
|
||||
title="Default folder"
|
||||
description="Select the folder to show on startup."
|
||||
>
|
||||
<Select
|
||||
value={settings.default_folder_id ?? ""}
|
||||
onChange={(value) => updateSetting("default_folder_id", value)}
|
||||
options={folderOptions}
|
||||
label="Default folder"
|
||||
/>
|
||||
</SettingsRow>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-sm font-medium text-text mb-3">Table defaults</h2>
|
||||
<div className="flex flex-col gap-4">
|
||||
<SettingsRow
|
||||
title="Auto-refresh rate"
|
||||
description="How often tables auto-refresh by default."
|
||||
>
|
||||
<Select
|
||||
value={String(settings.table_refresh_rate ?? 0)}
|
||||
onChange={(value) => updateSetting("table_refresh_rate", value)}
|
||||
options={REFRESH_RATE_OPTIONS}
|
||||
label="Auto-refresh rate"
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
title="Rows per page"
|
||||
description="Default number of rows shown per page."
|
||||
>
|
||||
<Select
|
||||
value={String(settings.table_page_size ?? 50)}
|
||||
onChange={(value) => updateSetting("table_page_size", value)}
|
||||
options={PAGE_SIZE_OPTIONS}
|
||||
label="Rows per page"
|
||||
/>
|
||||
</SettingsRow>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-sm font-medium text-text mb-3">Demo</h2>
|
||||
<div className="flex flex-col gap-4">
|
||||
<SettingsRow
|
||||
title="Re-add demo database"
|
||||
description="Re-create the demo SQLite connection if it was deleted."
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleReAddDemo}
|
||||
className="rounded-lg border border-border px-3 py-1.5 text-sm text-text hover:bg-surface-raised transition-colors"
|
||||
>
|
||||
Re-add demo
|
||||
</button>
|
||||
</SettingsRow>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { SettingsPage } from "./SettingsPage";
|
||||
import { GeneralSettingsTab } from "./GeneralSettingsTab";
|
||||
import { TagsSettingsTab } from "./TagsSettingsTab";
|
||||
import { TagsSettingsTab, reorderTagIds } from "./TagsSettingsTab";
|
||||
import { AdvancedSettingsTab } from "./AdvancedSettingsTab";
|
||||
import { useSettingsStore } from "../../stores/settingsStore";
|
||||
import { useConnectionStore } from "../../stores/connectionStore";
|
||||
@@ -29,6 +29,10 @@ vi.mock("../../lib/commands", () => ({
|
||||
confirm_before_delete: true,
|
||||
default_ports: { postgresql: 5432, mysql: 3306, sqlite: null, redis: 6379 },
|
||||
tag_order: null,
|
||||
table_refresh_rate: 0,
|
||||
table_page_size: 50,
|
||||
shortcuts: {},
|
||||
accent_color: "#2563EB",
|
||||
}),
|
||||
updateSetting: vi.fn().mockResolvedValue(undefined),
|
||||
getConnections: vi.fn().mockResolvedValue([]),
|
||||
@@ -63,6 +67,7 @@ const baseSettings = {
|
||||
table_refresh_rate: 0,
|
||||
table_page_size: 50,
|
||||
shortcuts: {} as Record<string, string>,
|
||||
accent_color: "#2563EB",
|
||||
};
|
||||
|
||||
describe("SettingsPage", () => {
|
||||
@@ -83,12 +88,12 @@ describe("SettingsPage", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("renders settings header with back button and title", async () => {
|
||||
it("renders settings header with back button and active tab title", async () => {
|
||||
render(<SettingsPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("heading", { name: /settings/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: /general/i })).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText(/back/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /back/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders all five sidebar tabs with proper ARIA roles", async () => {
|
||||
@@ -131,7 +136,6 @@ describe("SettingsPage", () => {
|
||||
expect(screen.getByRole("tab", { name: /tags/i })).toBeInTheDocument();
|
||||
});
|
||||
await user.click(screen.getByRole("tab", { name: /tags/i }));
|
||||
expect(screen.getByText(/create tag/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/manage tags/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole("tab", { name: /tags/i })).toHaveAttribute("aria-selected", "true");
|
||||
expect(screen.getByRole("tabpanel")).toHaveAttribute("aria-labelledby", "settings-tab-tags");
|
||||
@@ -196,6 +200,17 @@ describe("GeneralSettingsTab", () => {
|
||||
expect(screen.getByLabelText(/font size/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/default folder/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the accent color picker and persists a selection", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<GeneralSettingsTab />);
|
||||
const accentGroup = screen.getByRole("radiogroup", { name: /accent color/i });
|
||||
expect(accentGroup).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("radio", { name: /accent #22c55e/i }));
|
||||
await waitFor(() => {
|
||||
expect(commands.updateSetting).toHaveBeenCalledWith("accent_color", "#22C55E");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("TagsSettingsTab", () => {
|
||||
@@ -224,6 +239,22 @@ describe("TagsSettingsTab", () => {
|
||||
const moveDownButtons = screen.getAllByRole("button", { name: /move tag down/i });
|
||||
expect(moveDownButtons.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("renders a drag handle for each tag", () => {
|
||||
render(<TagsSettingsTab />);
|
||||
expect(screen.getAllByRole("button", { name: /drag to reorder/i })).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("reorderTagIds moves the active id to the over id position", () => {
|
||||
expect(reorderTagIds(["a", "b", "c"], "a", "c")).toEqual(["b", "c", "a"]);
|
||||
expect(reorderTagIds(["a", "b", "c"], "b", "a")).toEqual(["b", "a", "c"]);
|
||||
});
|
||||
|
||||
it("reorderTagIds leaves the order unchanged for same or unknown ids", () => {
|
||||
expect(reorderTagIds(["a", "b", "c"], "a", "a")).toEqual(["a", "b", "c"]);
|
||||
expect(reorderTagIds(["a", "b", "c"], "a", "zzz")).toEqual(["a", "b", "c"]);
|
||||
expect(reorderTagIds(["a", "b", "c"], "zzz", "c")).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AdvancedSettingsTab", () => {
|
||||
|
||||
@@ -2,8 +2,6 @@ import { useEffect, useState } from "react";
|
||||
import { motion, AnimatePresence } from "motion/react";
|
||||
import { useSettingsStore } from "../../stores/settingsStore";
|
||||
import { useUiStore } from "../../stores/uiStore";
|
||||
import { Button } from "../ui/Button";
|
||||
import { SettingsSection } from "../ui/SettingsSection";
|
||||
import { GeneralSettingsTab } from "./GeneralSettingsTab";
|
||||
import { TagsSettingsTab } from "./TagsSettingsTab";
|
||||
import { ShortcutsSettingsTab } from "./ShortcutsSettingsTab";
|
||||
@@ -35,7 +33,7 @@ const TABS: TabDefinition[] = [
|
||||
];
|
||||
|
||||
export function SettingsPage() {
|
||||
const setActiveView = useUiStore((s) => s.setActiveView);
|
||||
const closeSettings = useUiStore((s) => s.closeSettings);
|
||||
const { load } = useSettingsStore();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<SettingsTab>("general");
|
||||
@@ -45,11 +43,12 @@ export function SettingsPage() {
|
||||
}, [load]);
|
||||
|
||||
const renderEditor = () => (
|
||||
<SettingsSection title="Editor">
|
||||
<section>
|
||||
<h2 className="text-sm font-medium text-text mb-3">Editor</h2>
|
||||
<div className="py-8 text-center text-sm text-text-muted">
|
||||
Editor settings are coming soon.
|
||||
</div>
|
||||
</SettingsSection>
|
||||
</section>
|
||||
);
|
||||
|
||||
const renderTabContent = () => {
|
||||
@@ -68,70 +67,75 @@ export function SettingsPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-canvas">
|
||||
<div className="flex gap-6 px-6 py-6">
|
||||
{/* Sidebar */}
|
||||
<aside className="w-48 shrink-0 space-y-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setActiveView("home")}
|
||||
className="w-full justify-start gap-1 px-3 py-2 mb-4"
|
||||
<div className="h-full bg-canvas flex border-t border-border overflow-hidden">
|
||||
{/* Left sidebar — icon + text, Back at top */}
|
||||
<div className="w-48 h-full bg-canvas border-r border-border flex flex-col py-3 shrink-0">
|
||||
<div className="flex flex-col gap-1 px-2 flex-1">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Back"
|
||||
onClick={closeSettings}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-left transition-colors cursor-pointer bg-accent text-white hover:bg-accent-hover focus:outline-none focus:ring-2 focus:ring-accent/50"
|
||||
>
|
||||
<ChevronLeft size={16} /> Back
|
||||
</Button>
|
||||
|
||||
</button>
|
||||
<nav
|
||||
className="space-y-1"
|
||||
role="tablist"
|
||||
aria-label="Settings sections"
|
||||
className="flex flex-col gap-1"
|
||||
>
|
||||
{TABS.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
const isActive = activeTab === tab.id;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
id={`settings-tab-${tab.id}`}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
aria-controls="settings-tabpanel"
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-left transition-colors cursor-pointer ${
|
||||
isActive
|
||||
? "bg-surface-raised text-text"
|
||||
: "text-text-muted hover:text-text hover:bg-surface-raised"
|
||||
}`}
|
||||
>
|
||||
<Icon size={16} />
|
||||
{tab.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 min-w-0 pt-1">
|
||||
<h1 className="font-heading text-xl text-text mb-6">
|
||||
Settings
|
||||
</h1>
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={activeTab}
|
||||
id="settings-tabpanel"
|
||||
role="tabpanel"
|
||||
aria-labelledby={`settings-tab-${activeTab}`}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
{renderTabContent()}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</main>
|
||||
{TABS.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
const isActive = activeTab === tab.id;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
id={`settings-tab-${tab.id}`}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
aria-controls="settings-tabpanel"
|
||||
aria-label={tab.label}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-left transition-colors cursor-pointer focus:outline-none focus:ring-2 focus:ring-accent/50 ${
|
||||
isActive
|
||||
? "text-accent"
|
||||
: "text-text-muted hover:text-text hover:bg-surface-raised"
|
||||
}`}
|
||||
>
|
||||
<Icon size={16} />
|
||||
{tab.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
<header className="px-3 pt-3 pb-3 border-b border-border shrink-0 flex items-center gap-3">
|
||||
<h1 className="font-heading text-lg text-text">
|
||||
{TABS.find((t) => t.id === activeTab)?.label}
|
||||
</h1>
|
||||
</header>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-4 py-4">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={activeTab}
|
||||
id="settings-tabpanel"
|
||||
role="tabpanel"
|
||||
aria-labelledby={`settings-tab-${activeTab}`}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
{renderTabContent()}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { Pencil } from "lucide-react";
|
||||
import { SettingsSection } from "../ui/SettingsSection";
|
||||
import { useSettingsStore } from "../../stores/settingsStore";
|
||||
|
||||
type ShortcutDef = {
|
||||
@@ -100,12 +99,15 @@ export function ShortcutsSettingsTab() {
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSection title="Customizable Shortcuts">
|
||||
<div className="space-y-6">
|
||||
<section>
|
||||
<h2 className="text-sm font-medium text-text mb-3">
|
||||
Customizable Shortcuts
|
||||
</h2>
|
||||
<p className="text-xs text-text-muted mb-3">
|
||||
Click the pencil icon to record a new key combination. Click the shortcut to reset to default.
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
<div className="flex flex-col gap-1">
|
||||
{SHORTCUTS.map((s) => {
|
||||
const custom = customShortcuts[s.id];
|
||||
const isRecording = recording === s.id;
|
||||
@@ -148,13 +150,14 @@ export function ShortcutsSettingsTab() {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
</section>
|
||||
|
||||
<SettingsSection title="System Shortcuts">
|
||||
<section>
|
||||
<h2 className="text-sm font-medium text-text mb-3">System Shortcuts</h2>
|
||||
<p className="text-xs text-text-muted mb-3">
|
||||
These shortcuts are standard across all applications and cannot be changed.
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
{STATIC_SHORTCUTS.map((s) => (
|
||||
<div key={s.description} className="flex items-center justify-between py-1">
|
||||
<span className="text-sm text-text">{s.description}</span>
|
||||
@@ -166,7 +169,7 @@ export function ShortcutsSettingsTab() {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
</>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,35 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
PointerSensor,
|
||||
KeyboardSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
useSortable,
|
||||
arrayMove,
|
||||
sortableKeyboardCoordinates,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { useConnectionStore } from "../../stores/connectionStore";
|
||||
import { useNotificationStore } from "../../stores/notificationStore";
|
||||
import { useSortedTags } from "../../hooks/useSortedTags";
|
||||
import { Button } from "../ui/Button";
|
||||
import { Input } from "../ui/Input";
|
||||
import { SettingsSection } from "../ui/SettingsSection";
|
||||
import { Plus, Trash2, Check, X, ChevronUp, ChevronDown } from "lucide-react";
|
||||
import {
|
||||
Plus,
|
||||
Trash2,
|
||||
Check,
|
||||
X,
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
GripVertical,
|
||||
} from "lucide-react";
|
||||
import type { Tag } from "../../lib/types";
|
||||
|
||||
const TAG_COLORS = [
|
||||
@@ -21,6 +45,155 @@ const TAG_COLORS = [
|
||||
"#78716c",
|
||||
];
|
||||
|
||||
/** Move `activeId` to `overId`'s position. Returns `order` unchanged if the
|
||||
* ids are equal, or either id is missing. */
|
||||
export function reorderTagIds(order: string[], activeId: string, overId: string): string[] {
|
||||
const from = order.indexOf(activeId);
|
||||
const to = order.indexOf(overId);
|
||||
if (activeId === overId || from === -1 || to === -1) return order;
|
||||
return arrayMove(order, from, to);
|
||||
}
|
||||
|
||||
interface SortableTagRowProps {
|
||||
tag: Tag;
|
||||
index: number;
|
||||
count: number;
|
||||
editingId: string | null;
|
||||
editName: string;
|
||||
editColor: string;
|
||||
onEditNameChange: (value: string) => void;
|
||||
onEditColorChange: (color: string) => void;
|
||||
onStartEdit: (tag: Tag) => void;
|
||||
onCancelEdit: () => void;
|
||||
onUpdateTag: (id: string) => void;
|
||||
onDeleteTag: (id: string, name: string) => void;
|
||||
onMoveTag: (index: number, direction: "up" | "down") => void;
|
||||
}
|
||||
|
||||
function SortableTagRow({
|
||||
tag,
|
||||
index,
|
||||
count,
|
||||
editingId,
|
||||
editName,
|
||||
editColor,
|
||||
onEditNameChange,
|
||||
onEditColorChange,
|
||||
onStartEdit,
|
||||
onCancelEdit,
|
||||
onUpdateTag,
|
||||
onDeleteTag,
|
||||
onMoveTag,
|
||||
}: SortableTagRowProps) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
setActivatorNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: tag.id });
|
||||
|
||||
const isEditing = editingId === tag.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={{ transform: CSS.Transform.toString(transform), transition }}
|
||||
className={`bg-surface-raised border border-border rounded-xl p-3 flex items-center gap-3 relative ${
|
||||
isDragging ? "opacity-50 z-10 ring-1 ring-accent" : ""
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
ref={setActivatorNodeRef}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
type="button"
|
||||
aria-label={`Drag to reorder ${tag.name}`}
|
||||
className="cursor-grab active:cursor-grabbing touch-none text-text-muted hover:text-text transition-colors shrink-0"
|
||||
>
|
||||
<GripVertical size={14} />
|
||||
</button>
|
||||
|
||||
<div className="flex flex-col gap-0.5 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onMoveTag(index, "up")}
|
||||
disabled={index === 0}
|
||||
className="text-text-muted hover:text-text disabled:opacity-30 cursor-pointer disabled:cursor-not-allowed"
|
||||
aria-label="Move tag up"
|
||||
>
|
||||
<ChevronUp size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onMoveTag(index, "down")}
|
||||
disabled={index === count - 1}
|
||||
className="text-text-muted hover:text-text disabled:opacity-30 cursor-pointer disabled:cursor-not-allowed"
|
||||
aria-label="Move tag down"
|
||||
>
|
||||
<ChevronDown size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isEditing ? (
|
||||
<>
|
||||
<Input
|
||||
value={editName}
|
||||
onChange={onEditNameChange}
|
||||
className="flex-1"
|
||||
aria-label="Edit tag name"
|
||||
/>
|
||||
<div className="flex items-center gap-1">
|
||||
{TAG_COLORS.map((color) => (
|
||||
<button
|
||||
key={color}
|
||||
type="button"
|
||||
onClick={() => onEditColorChange(color)}
|
||||
className={`w-5 h-5 rounded-full border-2 transition-all cursor-pointer ${
|
||||
editColor === color ? "border-text scale-110" : "border-transparent"
|
||||
}`}
|
||||
style={{ backgroundColor: color }}
|
||||
aria-label={`Select color ${color}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<Button onClick={() => onUpdateTag(tag.id)}>
|
||||
<Check size={14} />
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={onCancelEdit}>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
className="w-4 h-4 rounded-full shrink-0"
|
||||
style={{ backgroundColor: tag.color }}
|
||||
/>
|
||||
<span className="text-sm text-text flex-1">{tag.name}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="text-xs"
|
||||
onClick={() => onStartEdit(tag)}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDeleteTag(tag.id, tag.name)}
|
||||
className="text-text-muted hover:text-red-400 transition-colors cursor-pointer"
|
||||
aria-label={`Delete tag ${tag.name}`}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TagsSettingsTab() {
|
||||
const tags = useSortedTags();
|
||||
const tagOrder = useConnectionStore((s) => s.tagOrder);
|
||||
@@ -36,6 +209,11 @@ export function TagsSettingsTab() {
|
||||
const [editName, setEditName] = useState("");
|
||||
const [editColor, setEditColor] = useState("");
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
|
||||
);
|
||||
|
||||
const handleCreateTag = async () => {
|
||||
const trimmed = newName.trim();
|
||||
if (!trimmed) {
|
||||
@@ -87,6 +265,14 @@ export function TagsSettingsTab() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
const currentOrder = tagOrder.length === tags.length ? tagOrder : tags.map((t) => t.id);
|
||||
const newOrder = reorderTagIds(currentOrder, String(active.id), String(over.id));
|
||||
setTagOrder(newOrder).catch((e) => notify(`Failed to reorder tags: ${e}`, "error"));
|
||||
};
|
||||
|
||||
const startEdit = (tag: Tag) => {
|
||||
setEditingId(tag.id);
|
||||
setEditName(tag.name);
|
||||
@@ -94,130 +280,76 @@ export function TagsSettingsTab() {
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSection title="Create tag">
|
||||
<div className="py-4 flex items-center gap-3">
|
||||
<Input
|
||||
placeholder="Tag name"
|
||||
value={newName}
|
||||
onChange={setNewName}
|
||||
className="flex-1"
|
||||
aria-label="New tag name"
|
||||
/>
|
||||
<div className="flex items-center gap-1">
|
||||
{TAG_COLORS.map((color) => (
|
||||
<button
|
||||
key={color}
|
||||
type="button"
|
||||
onClick={() => setNewColor(color)}
|
||||
className={`w-6 h-6 rounded-full border-2 transition-all cursor-pointer ${
|
||||
newColor === color ? "border-text scale-110" : "border-transparent"
|
||||
}`}
|
||||
style={{ backgroundColor: color }}
|
||||
aria-label={`Select color ${color}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<Button onClick={handleCreateTag}>
|
||||
<Plus size={14} /> Add
|
||||
</Button>
|
||||
<div className="space-y-6">
|
||||
{/* Create — no section label */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Input
|
||||
placeholder="Tag name"
|
||||
value={newName}
|
||||
onChange={setNewName}
|
||||
className="flex-1"
|
||||
aria-label="New tag name"
|
||||
/>
|
||||
<div className="flex items-center gap-1">
|
||||
{TAG_COLORS.map((color) => (
|
||||
<button
|
||||
key={color}
|
||||
type="button"
|
||||
onClick={() => setNewColor(color)}
|
||||
className={`w-6 h-6 rounded-full border-2 transition-all cursor-pointer ${
|
||||
newColor === color ? "border-text scale-110" : "border-transparent"
|
||||
}`}
|
||||
style={{ backgroundColor: color }}
|
||||
aria-label={`Select color ${color}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
<Button onClick={handleCreateTag}>
|
||||
<Plus size={14} /> Add
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<SettingsSection title="Manage tags">
|
||||
{/* Manage tags — plain section, no card */}
|
||||
<section>
|
||||
<h2 className="text-sm font-medium text-text mb-3">Manage tags</h2>
|
||||
{tags.length === 0 ? (
|
||||
<div className="text-center py-12 text-text-muted text-sm">
|
||||
No tags yet. Create one above.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 py-2">
|
||||
{tags.map((tag, index) => {
|
||||
const isEditing = editingId === tag.id;
|
||||
return (
|
||||
<div
|
||||
key={tag.id}
|
||||
className="bg-surface-raised border border-border rounded-xl p-3 flex items-center gap-3"
|
||||
>
|
||||
<div className="flex flex-col gap-0.5 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleMoveTag(index, "up")}
|
||||
disabled={index === 0}
|
||||
className="text-text-muted hover:text-text disabled:opacity-30 cursor-pointer disabled:cursor-not-allowed"
|
||||
aria-label="Move tag up"
|
||||
>
|
||||
<ChevronUp size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleMoveTag(index, "down")}
|
||||
disabled={index === tags.length - 1}
|
||||
className="text-text-muted hover:text-text disabled:opacity-30 cursor-pointer disabled:cursor-not-allowed"
|
||||
aria-label="Move tag down"
|
||||
>
|
||||
<ChevronDown size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isEditing ? (
|
||||
<>
|
||||
<Input
|
||||
value={editName}
|
||||
onChange={setEditName}
|
||||
className="flex-1"
|
||||
aria-label="Edit tag name"
|
||||
/>
|
||||
<div className="flex items-center gap-1">
|
||||
{TAG_COLORS.map((color) => (
|
||||
<button
|
||||
key={color}
|
||||
type="button"
|
||||
onClick={() => setEditColor(color)}
|
||||
className={`w-5 h-5 rounded-full border-2 transition-all cursor-pointer ${
|
||||
editColor === color ? "border-text scale-110" : "border-transparent"
|
||||
}`}
|
||||
style={{ backgroundColor: color }}
|
||||
aria-label={`Select color ${color}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<Button onClick={() => handleUpdateTag(tag.id)}>
|
||||
<Check size={14} />
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => setEditingId(null)}>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
className="w-4 h-4 rounded-full shrink-0"
|
||||
style={{ backgroundColor: tag.color }}
|
||||
/>
|
||||
<span className="text-sm text-text flex-1">{tag.name}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="text-xs"
|
||||
onClick={() => startEdit(tag)}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDeleteTag(tag.id, tag.name)}
|
||||
className="text-text-muted hover:text-red-400 transition-colors cursor-pointer"
|
||||
aria-label={`Delete tag ${tag.name}`}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={tags.map((t) => t.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{tags.map((tag, index) => (
|
||||
<SortableTagRow
|
||||
key={tag.id}
|
||||
tag={tag}
|
||||
index={index}
|
||||
count={tags.length}
|
||||
editingId={editingId}
|
||||
editName={editName}
|
||||
editColor={editColor}
|
||||
onEditNameChange={setEditName}
|
||||
onEditColorChange={setEditColor}
|
||||
onStartEdit={startEdit}
|
||||
onCancelEdit={() => setEditingId(null)}
|
||||
onUpdateTag={handleUpdateTag}
|
||||
onDeleteTag={handleDeleteTag}
|
||||
onMoveTag={handleMoveTag}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
)}
|
||||
</SettingsSection>
|
||||
</>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user