@@ -349,7 +585,7 @@ function ProjectCard({ entry }: { entry: RecentProject }) {
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
- borderRadius: "var(--radius-sm)",
+ borderRadius: "50%",
background: "rgba(0,0,0,0.55)",
color: "var(--status-error)",
}}
diff --git a/web/src/components/preview/TimelinePlaybackLayer.tsx b/web/src/components/preview/TimelinePlaybackLayer.tsx
index bcf9d86..2bd01e6 100644
--- a/web/src/components/preview/TimelinePlaybackLayer.tsx
+++ b/web/src/components/preview/TimelinePlaybackLayer.tsx
@@ -96,7 +96,7 @@ export function TimelinePlayback({ timeline, fps, playing }: { timeline: Timelin
if (!playing) {
// Paused: keep elements in DOM but silence the clock and media.
mediaClock.release();
-
+
for (const el of els.current.values()) el.pause();
return;
}
diff --git a/web/src/components/settings/SettingsSidebar.tsx b/web/src/components/settings/SettingsSidebar.tsx
new file mode 100644
index 0000000..b1053bf
--- /dev/null
+++ b/web/src/components/settings/SettingsSidebar.tsx
@@ -0,0 +1,108 @@
+/**
+ * Settings sidebar — pane selector with 12 entries (Issue #40).
+ * Extracted from SettingsView.tsx and expanded with 4 new panes:
+ * Models, Privacy, Shortcuts, Account.
+ *
+ * Mirrors upstream `SettingsView` sidebar (220pt wide, icon + label rows,
+ * active capsule on the left edge).
+ */
+
+import {
+ Settings as SettingsIcon,
+ Palette,
+ Download,
+ Sparkles,
+ Terminal,
+ HardDrive,
+ Bell,
+ Info,
+ Cpu,
+ Shield,
+ Keyboard,
+ User,
+} from "lucide-react";
+import { Icon } from "../ui/Icon";
+import { useT } from "../../i18n";
+import type { PaneId } from "./shared";
+
+export function SettingsSidebar({
+ active,
+ onSelect,
+}: {
+ active: PaneId;
+ onSelect: (id: PaneId) => void;
+}) {
+ const t = useT();
+ const items: Array<{ id: PaneId; icon: typeof SettingsIcon; label: string }> = [
+ { id: "general", icon: SettingsIcon, label: t("settings.section.general") },
+ { id: "appearance", icon: Palette, label: t("settings.section.appearance") },
+ { id: "import", icon: Download, label: t("settings.section.import") },
+ { id: "models", icon: Cpu, label: t("settings.section.models") },
+ { id: "ai", icon: Sparkles, label: t("settings.section.ai") },
+ { id: "mcp", icon: Terminal, label: t("settings.section.mcp") },
+ { id: "storage", icon: HardDrive, label: t("settings.section.storage") },
+ { id: "notifications", icon: Bell, label: t("settings.section.notifications") },
+ { id: "privacy", icon: Shield, label: t("settings.section.privacy") },
+ { id: "shortcuts", icon: Keyboard, label: t("settings.section.shortcuts") },
+ { id: "account", icon: User, label: t("settings.section.account") },
+ { id: "about", icon: Info, label: t("settings.section.about") },
+ ];
+ return (
+
+ );
+}
\ No newline at end of file
diff --git a/web/src/components/settings/SettingsView.tsx b/web/src/components/settings/SettingsView.tsx
index c610d39..d0fc811 100644
--- a/web/src/components/settings/SettingsView.tsx
+++ b/web/src/components/settings/SettingsView.tsx
@@ -1,30 +1,34 @@
/**
* Settings view. Reachable from both the Home sidebar and the editor title bar.
- * Panes (single scrollable page in this phase): General (language), Appearance
- * (theme), Import (default folder), AI (BYOK key), and About (version / license).
- * Preferences persist via `settingsStore` / `i18nStore`; the BYOK key is stored
- * in the OS keychain via the `secret_*` Tauri commands (see `lib/api.ts`) — the
- * plaintext key never reaches this component's persisted state.
+ * Thin shell: sidebar + detail layout, each pane in its own file (Issue #40
+ * review — "SettingsView.tsx > 800 行规约").
+ *
+ * 12 panes: General, Appearance, Import, Models, AI, MCP, Storage,
+ * Notifications, Privacy, Shortcuts, Account, About.
*/
-import { useEffect, useState } from "react";
-import { Check, FolderOpen, Trash2 } from "lucide-react";
-import { Icon } from "../ui/Icon";
-import { Dropdown } from "../ui/Dropdown";
-import { useT, useI18nStore, LOCALES } from "../../i18n";
-import {
- useSettingsStore,
- type Theme,
- type ByokProvider,
-} from "../../store/settingsStore";
+import { useState } from "react";
+import { useT } from "../../i18n";
import { useEditorUiStore } from "../../store/uiStore";
-import { openDialog } from "../../lib/dialog";
-import { secretSave, secretLoad, secretDelete } from "../../lib/api";
-import type { SecretStatus } from "../../lib/types";
+import type { PaneId } from "./shared";
+import { SettingsSidebar } from "./SettingsSidebar";
+import { GeneralPane } from "./panes/GeneralPane";
+import { AppearancePane } from "./panes/AppearancePane";
+import { ImportPane } from "./panes/ImportPane";
+import { ModelsPane } from "./panes/ModelsPane";
+import { AiPane } from "./panes/AiPane";
+import { McpInstructionsPane } from "./panes/McpInstructionsPane";
+import { StoragePane } from "./panes/StoragePane";
+import { NotificationsPane } from "./panes/NotificationsPane";
+import { PrivacyPane } from "./panes/PrivacyPane";
+import { ShortcutsPane } from "./panes/ShortcutsPane";
+import { AccountPane } from "./panes/AccountPane";
+import { AboutPane } from "./panes/AboutPane";
export function SettingsView() {
const t = useT();
const setView = useEditorUiStore((s) => s.setView);
+ const [active, setActive] = useState
("general");
return (
- );
-}
-
-function Section({ title, children }: { title: string; children: React.ReactNode }) {
- return (
-
-
- {title}
-
-
- {children}
-
-
- );
-}
-
-function Field({
- label,
- description,
- control,
-}: {
- label: string;
- description?: string;
- control: React.ReactNode;
-}) {
- return (
-
-
-
{label}
- {description && (
-
- {description}
-
- )}
-
-
{control}
-
- );
-}
-
-/** Segmented control used for enum settings (language/theme). */
-function Segmented({
- value,
- options,
- onChange,
-}: {
- value: T;
- options: Array<{ id: T; label: string }>;
- onChange: (id: T) => void;
-}) {
- return (
-
- {options.map((opt) => {
- const active = opt.id === value;
- return (
-
+ }
+ />
+
{provider}}
+ />
+
+ );
+}
\ No newline at end of file
diff --git a/web/src/components/settings/panes/AiPane.tsx b/web/src/components/settings/panes/AiPane.tsx
new file mode 100644
index 0000000..580bc97
--- /dev/null
+++ b/web/src/components/settings/panes/AiPane.tsx
@@ -0,0 +1,184 @@
+/**
+ * AI / BYOK settings pane — provider selection + API key management.
+ * Extracted from SettingsView.tsx (Issue #40 review).
+ */
+
+import { useEffect, useState } from "react";
+import { Trash2 } from "lucide-react";
+import { Icon } from "../../ui/Icon";
+import { useT } from "../../../i18n";
+import { useSettingsStore, type ByokProvider } from "../../../store/settingsStore";
+import { secretSave, secretLoad, secretDelete } from "../../../lib/api";
+import type { SecretStatus } from "../../../lib/types";
+import { Section, Field, Segmented } from "../shared";
+
+const PROVIDERS: Array<{ id: ByokProvider; label: string }> = [
+ { id: "anthropic", label: "Anthropic" },
+ { id: "openai", label: "OpenAI" },
+ { id: "google", label: "Google" },
+];
+
+/** Narrow a rejected-promise reason (a `String` from the Tauri boundary, or an
+ * `Error`) to a displayable message. */
+function errorMessage(error: unknown): string {
+ if (typeof error === "string") return error;
+ if (error instanceof Error) return error.message;
+ return String(error);
+}
+
+export function AiPane() {
+ const t = useT();
+ const provider = useSettingsStore((s) => s.byokProvider);
+ const setProvider = useSettingsStore((s) => s.setByokProvider);
+ const [draft, setDraft] = useState("");
+ const [status, setStatus] = useState({ hasKey: false, masked: "" });
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState(null);
+
+ // Reflect the keychain status for the active provider; reload on switch. The
+ // plaintext key is never fetched — only `hasKey` and the masked form.
+ useEffect(() => {
+ let alive = true;
+ setDraft("");
+ setError(null);
+ void secretLoad(provider).then(
+ (s) => {
+ if (alive) setStatus(s);
+ },
+ () => {
+ if (alive) setStatus({ hasKey: false, masked: "" });
+ },
+ );
+ return () => {
+ alive = false;
+ };
+ }, [provider]);
+
+ const trimmed = draft.trim();
+
+ const save = async () => {
+ if (trimmed.length === 0 || busy) return;
+ setBusy(true);
+ setError(null);
+ try {
+ setStatus(await secretSave(provider, trimmed));
+ setDraft("");
+ } catch (e) {
+ setError(t("settings.byokSaveFailed", { error: errorMessage(e) }));
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ const remove = async () => {
+ if (busy) return;
+ setBusy(true);
+ setError(null);
+ try {
+ setStatus(await secretDelete(provider));
+ setDraft("");
+ } catch (e) {
+ setError(t("settings.byokSaveFailed", { error: errorMessage(e) }));
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ return (
+
+
+ {t("settings.byokDesc")}
+
+ value={provider} options={PROVIDERS} onChange={setProvider} />
+ }
+ />
+
+
+
+ {
+ setDraft(e.target.value);
+ setError(null);
+ }}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") void save();
+ }}
+ placeholder={status.hasKey ? status.masked : t("settings.byokKeyPlaceholder")}
+ className="tabular"
+ style={{
+ flex: 1,
+ height: 28,
+ background: "var(--bg-base)",
+ border: "var(--bw-thin) solid var(--border-primary)",
+ borderRadius: "var(--radius-sm)",
+ color: "var(--text-primary)",
+ fontSize: "var(--fs-sm)",
+ padding: "0 var(--space-sm)",
+ }}
+ />
+ {trimmed.length > 0 ? (
+ void save()}
+ className="hover-area"
+ style={{
+ height: 28,
+ padding: "0 var(--space-lg)",
+ borderRadius: "var(--radius-sm)",
+ border: "var(--bw-thin) solid var(--border-primary)",
+ color: "var(--text-primary)",
+ fontSize: "var(--fs-sm)",
+ fontWeight: "var(--fw-medium)",
+ opacity: busy ? 0.4 : 1,
+ }}
+ >
+ {t("settings.byokSave")}
+
+ ) : (
+ status.hasKey && (
+ void remove()}
+ className="hover-area"
+ title={t("settings.byokDelete")}
+ aria-label={t("settings.byokDelete")}
+ style={{
+ display: "inline-flex",
+ alignItems: "center",
+ justifyContent: "center",
+ width: 28,
+ height: 28,
+ borderRadius: "var(--radius-sm)",
+ border: "var(--bw-thin) solid var(--border-primary)",
+ color: "var(--text-secondary)",
+ opacity: busy ? 0.4 : 1,
+ }}
+ >
+
+
+ )
+ )}
+
+ {error ? (
+
{error}
+ ) : (
+ status.hasKey &&
+ trimmed.length === 0 && (
+
+ {t("settings.byokSaved")}
+
+ )
+ )}
+
+
+ );
+}
\ No newline at end of file
diff --git a/web/src/components/settings/panes/AppearancePane.tsx b/web/src/components/settings/panes/AppearancePane.tsx
new file mode 100644
index 0000000..50a7961
--- /dev/null
+++ b/web/src/components/settings/panes/AppearancePane.tsx
@@ -0,0 +1,32 @@
+/**
+ * Appearance settings pane — theme toggle.
+ * Extracted from SettingsView.tsx (Issue #40 review).
+ */
+
+import { useT } from "../../../i18n";
+import { useSettingsStore, type Theme } from "../../../store/settingsStore";
+import { Section, Field, Segmented } from "../shared";
+
+export function AppearancePane() {
+ const t = useT();
+ const theme = useSettingsStore((s) => s.theme);
+ const setTheme = useSettingsStore((s) => s.setTheme);
+ return (
+
+
+ value={theme}
+ options={[
+ { id: "dark", label: t("settings.theme.dark") },
+ { id: "light", label: t("settings.theme.light") },
+ ]}
+ onChange={setTheme}
+ />
+ }
+ />
+
+ );
+}
\ No newline at end of file
diff --git a/web/src/components/settings/panes/GeneralPane.tsx b/web/src/components/settings/panes/GeneralPane.tsx
new file mode 100644
index 0000000..cfe84bd
--- /dev/null
+++ b/web/src/components/settings/panes/GeneralPane.tsx
@@ -0,0 +1,30 @@
+/**
+ * General settings pane — language selection.
+ * Extracted from SettingsView.tsx (Issue #40 review).
+ */
+
+import { useT, useI18nStore, LOCALES } from "../../../i18n";
+import { Dropdown } from "../../ui/Dropdown";
+import { Section, Field } from "../shared";
+
+export function GeneralPane() {
+ const t = useT();
+ const locale = useI18nStore((s) => s.locale);
+ const setLocale = useI18nStore((s) => s.setLocale);
+ return (
+
+ );
+}
\ No newline at end of file
diff --git a/web/src/components/settings/panes/ImportPane.tsx b/web/src/components/settings/panes/ImportPane.tsx
new file mode 100644
index 0000000..bc27e95
--- /dev/null
+++ b/web/src/components/settings/panes/ImportPane.tsx
@@ -0,0 +1,73 @@
+/**
+ * Import settings pane — default import folder.
+ * Extracted from SettingsView.tsx (Issue #40 review).
+ */
+
+import { FolderOpen } from "lucide-react";
+import { Icon } from "../../ui/Icon";
+import { useT } from "../../../i18n";
+import { useSettingsStore } from "../../../store/settingsStore";
+import { openDialog } from "../../../lib/dialog";
+import { Section, Field } from "../shared";
+
+export function ImportPane() {
+ const t = useT();
+ const folder = useSettingsStore((s) => s.defaultImportFolder);
+ const setFolder = useSettingsStore((s) => s.setDefaultImportFolder);
+
+ const choose = async () => {
+ const open = await openDialog();
+ if (!open) return;
+ const selected = await open({ directory: true, multiple: false });
+ if (typeof selected === "string") setFolder(selected);
+ };
+
+ return (
+
+
+ void choose()}
+ className="hover-area"
+ style={{
+ display: "inline-flex",
+ alignItems: "center",
+ gap: 4,
+ height: 26,
+ padding: "0 var(--space-md)",
+ borderRadius: "var(--radius-sm)",
+ border: "var(--bw-thin) solid var(--border-primary)",
+ color: "var(--text-secondary)",
+ fontSize: "var(--fs-sm)",
+ fontWeight: "var(--fw-medium)",
+ }}
+ >
+
+ {t("settings.chooseFolder")}
+
+ {folder && (
+ setFolder(null)}
+ className="hover-area"
+ style={{
+ height: 26,
+ padding: "0 var(--space-md)",
+ borderRadius: "var(--radius-sm)",
+ color: "var(--text-tertiary)",
+ fontSize: "var(--fs-sm)",
+ }}
+ >
+ {t("settings.clear")}
+
+ )}
+
+ }
+ />
+
+ );
+}
\ No newline at end of file
diff --git a/web/src/components/settings/panes/McpInstructionsPane.tsx b/web/src/components/settings/panes/McpInstructionsPane.tsx
new file mode 100644
index 0000000..f4c1e12
--- /dev/null
+++ b/web/src/components/settings/panes/McpInstructionsPane.tsx
@@ -0,0 +1,129 @@
+/**
+ * MCP Instructions pane. Surfaces the built-in MCP server URL and one-line
+ * install commands for Cursor / Claude Code / Codex / Claude Desktop.
+ *
+ * Extracted from SettingsView.tsx (Issue #40 review). Fixes:
+ * - Cursor config: added `type: "http"` field (review #4)
+ * - Claude Desktop: changed to `npx mcp-remote` format (review #5)
+ */
+
+import { useState } from "react";
+import { Copy } from "lucide-react";
+import { Icon } from "../../ui/Icon";
+import { useT } from "../../../i18n";
+import { Section, Field, CodeBlock, Subsection } from "../shared";
+
+/** MCP server endpoint. The Rust server (`opentake-agent::mcp::server`) binds
+ * to 127.0.0.1:19789/mcp at startup; the URL is fixed so we hardcode it here
+ * rather than round-tripping to Tauri to query. */
+const MCP_SERVER_URL = "http://127.0.0.1:19789/mcp";
+
+export function McpInstructionsPane() {
+ const t = useT();
+ const [copied, setCopied] = useState(false);
+
+ const copy = async (text: string) => {
+ try {
+ await navigator.clipboard.writeText(text);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 1500);
+ } catch {
+ // Clipboard may be unavailable (non-Tauri / permissions); silently no-op.
+ }
+ };
+
+ const claudeCodeCmd = `claude mcp add --transport http opentake ${MCP_SERVER_URL}`;
+ const codexCmd = `codex mcp add opentake --url ${MCP_SERVER_URL}`;
+ // Fix #4: Cursor config with `type: "http"` field (reviewer 反馈).
+ const cursorConfig = JSON.stringify(
+ {
+ mcpServers: {
+ opentake: { type: "http", url: MCP_SERVER_URL },
+ },
+ },
+ null,
+ 2,
+ );
+ // Fix #5: Claude Desktop uses `npx mcp-remote` (reviewer 反馈), not the
+ // same url-based format as Cursor.
+ const claudeDesktopConfig = JSON.stringify(
+ {
+ mcpServers: {
+ opentake: {
+ command: "npx",
+ args: ["mcp-remote", MCP_SERVER_URL],
+ },
+ },
+ },
+ null,
+ 2,
+ );
+
+ return (
+
+
+ {t("mcp.overview")}
+
+
+ void copy(MCP_SERVER_URL)}
+ className="hover-area"
+ style={{
+ display: "inline-flex",
+ alignItems: "center",
+ gap: 4,
+ height: 26,
+ padding: "0 var(--space-md)",
+ borderRadius: "var(--radius-sm)",
+ border: "var(--bw-thin) solid var(--border-primary)",
+ color: "var(--text-secondary)",
+ fontSize: "var(--fs-sm)",
+ fontWeight: "var(--fw-medium)",
+ }}
+ >
+
+ {copied ? t("mcp.copied") : t("mcp.copy")}
+
+ }
+ />
+
+
+
+
+ {t("mcp.claudeCodeCmd")}
+
+ void copy(claudeCodeCmd)} />
+
+
+
+
+ {t("mcp.codexCmd")}
+
+ void copy(codexCmd)} />
+
+
+
+
+ {t("mcp.cursorManual")}
+
+ void copy(cursorConfig)} />
+
+
+
+
+ {t("mcp.claudeDesktopManual")}
+
+ void copy(claudeDesktopConfig)} />
+
+
+
+ {t("mcp.note")}
+
+
+ );
+}
\ No newline at end of file
diff --git a/web/src/components/settings/panes/ModelsPane.tsx b/web/src/components/settings/panes/ModelsPane.tsx
new file mode 100644
index 0000000..31721e9
--- /dev/null
+++ b/web/src/components/settings/panes/ModelsPane.tsx
@@ -0,0 +1,52 @@
+/**
+ * Models pane — AI model selection (Issue #40 review #2).
+ * Hardcoded model list per provider; persisted to settingsStore.
+ */
+
+import { useT } from "../../../i18n";
+import { useSettingsStore } from "../../../store/settingsStore";
+import { Section, Field, Segmented } from "../shared";
+
+const MODELS: Record> = {
+ anthropic: [
+ { id: "claude-sonnet-4-20250514", label: "Claude Sonnet 4" },
+ { id: "claude-opus-4-20250514", label: "Claude Opus 4" },
+ ],
+ openai: [
+ { id: "gpt-4o", label: "GPT-4o" },
+ { id: "gpt-4o-mini", label: "GPT-4o mini" },
+ ],
+ google: [
+ { id: "gemini-2.5-pro", label: "Gemini 2.5 Pro" },
+ { id: "gemini-2.0-flash", label: "Gemini 2.0 Flash" },
+ ],
+};
+
+export function ModelsPane() {
+ const t = useT();
+ const provider = useSettingsStore((s) => s.byokProvider);
+ const model = useSettingsStore((s) => s.byokModel);
+ const setModel = useSettingsStore((s) => s.setByokModel);
+
+ const options = MODELS[provider] ?? MODELS.anthropic;
+ const current = model || (options[0]?.id ?? "");
+
+ return (
+
+
+ {t("settings.modelsDesc")}
+
+
+ value={current}
+ options={options}
+ onChange={setModel}
+ />
+ }
+ />
+
+ );
+}
\ No newline at end of file
diff --git a/web/src/components/settings/panes/NotificationsPane.tsx b/web/src/components/settings/panes/NotificationsPane.tsx
new file mode 100644
index 0000000..6d9df99
--- /dev/null
+++ b/web/src/components/settings/panes/NotificationsPane.tsx
@@ -0,0 +1,59 @@
+/**
+ * Notifications pane. Generation-complete notification toggle.
+ *
+ * Extracted from SettingsView.tsx (Issue #40 review). Fix:
+ * - Toggle now persists to settingsStore (localStorage) instead of
+ * local useState (review #7 — "开关未持久化").
+ */
+
+import { useT } from "../../../i18n";
+import { useSettingsStore } from "../../../store/settingsStore";
+import { Section, Field } from "../shared";
+
+export function NotificationsPane() {
+ const t = useT();
+ const enabled = useSettingsStore((s) => s.notificationsGeneration);
+ const setEnabled = useSettingsStore((s) => s.setNotificationsGeneration);
+
+ return (
+
+ setEnabled(!enabled)}
+ style={{
+ width: 36,
+ height: 20,
+ borderRadius: 10,
+ background: enabled ? "var(--accent-primary)" : "var(--bg-base)",
+ border: "var(--bw-thin) solid var(--border-primary)",
+ position: "relative",
+ transition: "background var(--anim-transition) var(--ease-out)",
+ }}
+ >
+
+
+ }
+ />
+
+ {t("notifications.restartHint")}
+
+
+ );
+}
\ No newline at end of file
diff --git a/web/src/components/settings/panes/PrivacyPane.tsx b/web/src/components/settings/panes/PrivacyPane.tsx
new file mode 100644
index 0000000..da99e49
--- /dev/null
+++ b/web/src/components/settings/panes/PrivacyPane.tsx
@@ -0,0 +1,73 @@
+/**
+ * Privacy pane — telemetry toggles (Issue #40 review #2).
+ * Two switches: anonymous usage telemetry + crash reports.
+ * Both default off; persisted to settingsStore (localStorage).
+ */
+
+import { useT } from "../../../i18n";
+import { useSettingsStore } from "../../../store/settingsStore";
+import { Section, Field } from "../shared";
+
+export function PrivacyPane() {
+ const t = useT();
+ const usage = useSettingsStore((s) => s.telemetryUsage);
+ const crash = useSettingsStore((s) => s.telemetryCrash);
+ const setUsage = useSettingsStore((s) => s.setTelemetryUsage);
+ const setCrash = useSettingsStore((s) => s.setTelemetryCrash);
+
+ const toggleStyle = (enabled: boolean) => ({
+ width: 36,
+ height: 20,
+ borderRadius: 10,
+ background: enabled ? "var(--accent-primary)" : "var(--bg-base)",
+ border: "var(--bw-thin) solid var(--border-primary)",
+ position: "relative" as const,
+ transition: "background var(--anim-transition) var(--ease-out)",
+ });
+
+ const knobStyle = (enabled: boolean) => ({
+ position: "absolute" as const,
+ top: 1,
+ left: enabled ? 17 : 1,
+ width: 16,
+ height: 16,
+ borderRadius: "50%",
+ background: "var(--text-primary)",
+ transition: "left var(--anim-transition) var(--ease-out)",
+ });
+
+ return (
+
+ setUsage(!usage)}
+ style={toggleStyle(usage)}
+ >
+
+
+ }
+ />
+ setCrash(!crash)}
+ style={toggleStyle(crash)}
+ >
+
+
+ }
+ />
+
+ );
+}
\ No newline at end of file
diff --git a/web/src/components/settings/panes/ShortcutsPane.tsx b/web/src/components/settings/panes/ShortcutsPane.tsx
new file mode 100644
index 0000000..671c487
--- /dev/null
+++ b/web/src/components/settings/panes/ShortcutsPane.tsx
@@ -0,0 +1,65 @@
+/**
+ * Shortcuts pane — read-only keyboard shortcut reference (Issue #40 review #2).
+ * Displays common shortcuts using Field components with tags.
+ */
+
+import { useT } from "../../../i18n";
+import { Section, Field } from "../shared";
+
+const KBD: React.CSSProperties = {
+ display: "inline-flex",
+ alignItems: "center",
+ justifyContent: "center",
+ height: 22,
+ minWidth: 22,
+ padding: "0 6px",
+ background: "var(--bg-base)",
+ border: "var(--bw-thin) solid var(--border-primary)",
+ borderRadius: "var(--radius-xs)",
+ fontSize: "var(--fs-xs)",
+ fontFamily: "var(--font-mono, ui-monospace, monospace)",
+ color: "var(--text-secondary)",
+ whiteSpace: "nowrap",
+};
+
+function Kbd({ children }: { children: string }) {
+ return {children};
+}
+
+function KbdGroup({ keys }: { keys: string[] }) {
+ return (
+
+ {keys.map((k, i) => (
+
+ {i > 0 && +}
+ {k}
+
+ ))}
+
+ );
+}
+
+export function ShortcutsPane() {
+ const t = useT();
+
+ const shortcuts = [
+ { label: t("settings.shortcutsPlay"), keys: ["Space"] },
+ { label: t("settings.shortcutsUndo"), keys: ["Ctrl", "Z"] },
+ { label: t("settings.shortcutsRedo"), keys: ["Ctrl", "Shift", "Z"] },
+ { label: t("settings.shortcutsDelete"), keys: ["Delete"] },
+ { label: t("settings.shortcutsSave"), keys: ["Ctrl", "S"] },
+ { label: t("settings.shortcutsNew"), keys: ["Ctrl", "N"] },
+ ];
+
+ return (
+
+ {shortcuts.map((s) => (
+ }
+ />
+ ))}
+
+ );
+}
\ No newline at end of file
diff --git a/web/src/components/settings/panes/StoragePane.tsx b/web/src/components/settings/panes/StoragePane.tsx
new file mode 100644
index 0000000..92a2732
--- /dev/null
+++ b/web/src/components/settings/panes/StoragePane.tsx
@@ -0,0 +1,63 @@
+/**
+ * Storage pane. Surfaces cache location and a clear-cache action.
+ *
+ * Extracted from SettingsView.tsx (Issue #40 review). Fix:
+ * - Clear cache button now shows "unavailable" feedback instead of
+ * fake success (review #6 — "清缓存是 no-op 却显示成功").
+ */
+
+import { useState } from "react";
+import { useT } from "../../../i18n";
+import { Section, Field } from "../shared";
+
+export function StoragePane() {
+ const t = useT();
+ const [showUnavailable, setShowUnavailable] = useState(false);
+
+ const clear = () => {
+ // Fix #6: Instead of fake success, surface that the feature is not yet
+ // available (the cache-clear Tauri command hasn't been wired).
+ setShowUnavailable(true);
+ setTimeout(() => setShowUnavailable(false), 3000);
+ };
+
+ return (
+
+
+ {t("storage.clearCache")}
+
+ }
+ />
+ {showUnavailable && (
+
+ {t("storage.clearCacheUnavailable")}
+
+ )}
+ —}
+ />
+
+ {t("storage.placeholder")}
+
+
+ );
+}
\ No newline at end of file
diff --git a/web/src/components/settings/shared.tsx b/web/src/components/settings/shared.tsx
new file mode 100644
index 0000000..d7553aa
--- /dev/null
+++ b/web/src/components/settings/shared.tsx
@@ -0,0 +1,221 @@
+/**
+ * Shared UI primitives for Settings panes. Extracted from SettingsView.tsx
+ * to keep each pane file focused and the main view ≤ 300 lines (Issue #40
+ * review — "SettingsView.tsx > 800 行规约").
+ *
+ * Exports: Section, Field, Segmented, CodeBlock, Subsection, Value, PaneId.
+ */
+
+import { useState } from "react";
+import { Check, Copy } from "lucide-react";
+import { Icon } from "../ui/Icon";
+import { useT } from "../../i18n";
+
+export type PaneId =
+ | "general"
+ | "appearance"
+ | "import"
+ | "ai"
+ | "mcp"
+ | "storage"
+ | "notifications"
+ | "about"
+ | "models"
+ | "privacy"
+ | "shortcuts"
+ | "account";
+
+export function Section({ title, children }: { title: string; children: React.ReactNode }) {
+ return (
+
+
+ {title}
+
+
+ {children}
+
+
+ );
+}
+
+export function Field({
+ label,
+ description,
+ control,
+}: {
+ label: string;
+ description?: string;
+ control: React.ReactNode;
+}) {
+ return (
+
+
+
{label}
+ {description && (
+
+ {description}
+
+ )}
+
+
{control}
+
+ );
+}
+
+/** Segmented control used for enum settings (language/theme). */
+export function Segmented({
+ value,
+ options,
+ onChange,
+}: {
+ value: T;
+ options: Array<{ id: T; label: string }>;
+ onChange: (id: T) => void;
+}) {
+ return (
+
+ {options.map((opt) => {
+ const active = opt.id === value;
+ return (
+ onChange(opt.id)}
+ style={{
+ display: "inline-flex",
+ alignItems: "center",
+ gap: 4,
+ height: 24,
+ padding: "0 var(--space-md)",
+ borderRadius: "var(--radius-xs-sm)",
+ background: active ? "var(--bg-prominent)" : "transparent",
+ color: active ? "var(--text-primary)" : "var(--text-tertiary)",
+ fontSize: "var(--fs-sm)",
+ fontWeight: "var(--fw-medium)",
+ }}
+ >
+ {active && }
+ {opt.label}
+
+ );
+ })}
+
+ );
+}
+
+export function Subsection({ label, children }: { label: string; children: React.ReactNode }) {
+ return (
+
+
+ {label}
+
+ {children}
+
+ );
+}
+
+/** Read-only code block with optional copy button. Used for MCP commands and
+ * JSON configs. */
+export function CodeBlock({ text, onCopy }: { text: string; onCopy?: () => void }) {
+ const t = useT();
+ const [copied, setCopied] = useState(false);
+ const handleCopy = () => {
+ if (onCopy) {
+ onCopy();
+ setCopied(true);
+ setTimeout(() => setCopied(false), 1500);
+ }
+ };
+ return (
+
+
+ {text}
+
+ {onCopy && (
+
+
+
+ )}
+
+ );
+}
+
+export function Value({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
\ No newline at end of file
diff --git a/web/src/i18n/dict.ts b/web/src/i18n/dict.ts
index 7aac2fa..675a53c 100644
--- a/web/src/i18n/dict.ts
+++ b/web/src/i18n/dict.ts
@@ -193,6 +193,99 @@ const zh: Dict = {
"settings.aboutLicense": "许可",
"settings.aboutDesc": "OpenTake 是 Palmier Pro 的开源跨平台分支。",
+ // Settings panes (sidebar labels)
+ "settings.section.mcp": "MCP 说明",
+ "settings.section.storage": "存储",
+ "settings.section.notifications": "通知",
+ "settings.section.models": "模型",
+ "settings.section.privacy": "隐私",
+ "settings.section.shortcuts": "快捷键",
+ "settings.section.account": "账户",
+
+ // MCP Instructions pane
+ "mcp.title": "MCP 服务器连接说明",
+ "mcp.overview":
+ "OpenTake 内置一个 MCP (Model Context Protocol) 服务器,将当前项目暴露给外部 AI 客户端。启用后,AI 助手可以直接读取时间线、添加片段、执行工作流。",
+ "mcp.serverUrl": "服务器地址",
+ "mcp.serverUrlDesc": "在支持的客户端中粘贴此地址,或使用下方一键命令。",
+ "mcp.copy": "复制",
+ "mcp.copied": "已复制",
+ "mcp.cursor": "Cursor",
+ "mcp.cursorInstall": "一键安装到 Cursor",
+ "mcp.cursorManual": "手动配置(settings.json 的 mcpServers 字段)",
+ "mcp.claudeCode": "Claude Code",
+ "mcp.claudeCodeCmd": "在终端运行",
+ "mcp.codex": "Codex",
+ "mcp.codexCmd": "在终端运行",
+ "mcp.claudeDesktop": "Claude Desktop",
+ "mcp.claudeDesktopManual": "手动配置(claude_desktop_config.json 的 mcpServers 字段)",
+ "mcp.note": "服务器仅在应用运行时可用,绑定 127.0.0.1,不接受外部连接。",
+
+ // Storage pane
+ "storage.cache": "缓存",
+ "storage.cacheDesc": "导入与生成过程中产生的临时文件缓存。",
+ "storage.cachePath": "缓存位置",
+ "storage.clearCache": "清除缓存",
+ "storage.clearCacheConfirm": "确定清除缓存?这不会影响已导入的媒体。",
+ "storage.cacheCleared": "缓存已清除。",
+ "storage.searchIndex": "搜索索引",
+ "storage.searchIndexDesc": "媒体库的语义搜索索引(如启用)。",
+ "storage.placeholder": "暂未提供运行时统计,将在后续版本补齐。",
+
+ // Notifications pane
+ "notifications.generation": "生成完成通知",
+ "notifications.generationDesc": "当 AI 生成任务完成时显示系统通知。",
+ "notifications.restartHint": "更改将在下次启动应用时生效。",
+
+ // Storage extra
+ "storage.clearCacheUnavailable": "清除缓存功能将在后续版本上线。",
+
+ // Models pane
+ "settings.modelsDesc": "选择 AI 生成使用的模型。",
+ "settings.modelsProviderModel": "模型",
+ "settings.modelsModelDesc": "当前服务商可用的模型。",
+
+ // Privacy pane
+ "settings.privacyTelemetry": "发送匿名使用统计",
+ "settings.privacyTelemetryDesc": "帮助我们改进 OpenTake。不会发送任何项目内容。",
+ "settings.privacyCrash": "发送崩溃报告",
+ "settings.privacyCrashDesc": "应用崩溃时自动发送报告。",
+
+ // Shortcuts pane
+ "settings.shortcutsPlay": "播放/暂停",
+ "settings.shortcutsUndo": "撤销",
+ "settings.shortcutsRedo": "重做",
+ "settings.shortcutsDelete": "删除片段",
+ "settings.shortcutsSave": "保存",
+ "settings.shortcutsNew": "新建",
+
+ // Account pane
+ "settings.accountSignIn": "使用 Google 登录",
+ "settings.accountSignOut": "退出登录",
+ "settings.accountSignedInAs": "已登录为",
+ "settings.accountNotSignedIn": "未登录",
+
+ // Home extended
+ "home.signIn": "登录",
+ "home.signOut": "退出",
+ "home.fileMissing": "文件缺失",
+ "home.samples": "示例项目",
+ "home.sampleDemo": "产品演示",
+ "home.sampleTutorial": "快速教程",
+ "home.sampleTemplate": "模板项目",
+ "home.sampleComingSoon": "示例项目即将上线",
+ "home.newInVersion": "v{version} 新功能",
+ "home.welcomeOverlayTitle": "欢迎使用 OpenTake",
+ "home.welcomeOverlayBody": "点击「新建」开始你的第一个项目。",
+ "home.welcomeOverlayStart": "开始",
+
+ // Home relative time
+ "home.relative.today": "今天",
+ "home.relative.yesterday": "昨天",
+ "home.relative.daysAgo": "{count} 天前",
+ "home.relative.weeksAgo": "{count} 周前",
+ "home.relative.monthsAgo": "{count} 个月前",
+
// Common
"common.cancel": "取消",
"common.open": "打开",
@@ -386,6 +479,99 @@ const en: Dict = {
"settings.aboutLicense": "License",
"settings.aboutDesc": "OpenTake is the open-source, cross-platform fork of Palmier Pro.",
+ // Settings panes (sidebar labels)
+ "settings.section.mcp": "MCP Instructions",
+ "settings.section.storage": "Storage",
+ "settings.section.notifications": "Notifications",
+ "settings.section.models": "Models",
+ "settings.section.privacy": "Privacy",
+ "settings.section.shortcuts": "Shortcuts",
+ "settings.section.account": "Account",
+
+ // MCP Instructions pane
+ "mcp.title": "MCP Server Connection Guide",
+ "mcp.overview":
+ "OpenTake ships a built-in MCP (Model Context Protocol) server that exposes the current project to external AI clients. Once connected, an AI assistant can read the timeline, add clips, and run workflows.",
+ "mcp.serverUrl": "Server URL",
+ "mcp.serverUrlDesc": "Paste this URL into a supported client, or use one of the one-line commands below.",
+ "mcp.copy": "Copy",
+ "mcp.copied": "Copied",
+ "mcp.cursor": "Cursor",
+ "mcp.cursorInstall": "Install in Cursor",
+ "mcp.cursorManual": "Manual config (mcpServers field of settings.json)",
+ "mcp.claudeCode": "Claude Code",
+ "mcp.claudeCodeCmd": "Run in terminal",
+ "mcp.codex": "Codex",
+ "mcp.codexCmd": "Run in terminal",
+ "mcp.claudeDesktop": "Claude Desktop",
+ "mcp.claudeDesktopManual": "Manual config (mcpServers field of claude_desktop_config.json)",
+ "mcp.note": "The server is only available while the app is running, bound to 127.0.0.1, and does not accept external connections.",
+
+ // Storage pane
+ "storage.cache": "Cache",
+ "storage.cacheDesc": "Temporary files produced during import and generation.",
+ "storage.cachePath": "Cache location",
+ "storage.clearCache": "Clear cache",
+ "storage.clearCacheConfirm": "Clear the cache? This does not affect imported media.",
+ "storage.cacheCleared": "Cache cleared.",
+ "storage.searchIndex": "Search index",
+ "storage.searchIndexDesc": "Semantic search index for the media library (if enabled).",
+ "storage.placeholder": "Runtime statistics are not yet available; they will arrive in a later release.",
+
+ // Notifications pane
+ "notifications.generation": "Generation-complete notifications",
+ "notifications.generationDesc": "Show a system notification when an AI generation task finishes.",
+ "notifications.restartHint": "Changes take effect on the next app launch.",
+
+ // Storage extra
+ "storage.clearCacheUnavailable": "Cache clearing will arrive in a later release.",
+
+ // Models pane
+ "settings.modelsDesc": "Select the model for AI generation.",
+ "settings.modelsProviderModel": "Model",
+ "settings.modelsModelDesc": "Available models for the current provider.",
+
+ // Privacy pane
+ "settings.privacyTelemetry": "Anonymous usage telemetry",
+ "settings.privacyTelemetryDesc": "Help us improve OpenTake. No project content is ever sent.",
+ "settings.privacyCrash": "Crash reports",
+ "settings.privacyCrashDesc": "Automatically send reports when the app crashes.",
+
+ // Shortcuts pane
+ "settings.shortcutsPlay": "Play/Pause",
+ "settings.shortcutsUndo": "Undo",
+ "settings.shortcutsRedo": "Redo",
+ "settings.shortcutsDelete": "Delete clip",
+ "settings.shortcutsSave": "Save",
+ "settings.shortcutsNew": "New",
+
+ // Account pane
+ "settings.accountSignIn": "Sign in with Google",
+ "settings.accountSignOut": "Sign out",
+ "settings.accountSignedInAs": "Signed in as",
+ "settings.accountNotSignedIn": "Not signed in",
+
+ // Home extended
+ "home.signIn": "Sign in",
+ "home.signOut": "Sign out",
+ "home.fileMissing": "File missing",
+ "home.samples": "Sample projects",
+ "home.sampleDemo": "Product demo",
+ "home.sampleTutorial": "Quick tutorial",
+ "home.sampleTemplate": "Template project",
+ "home.sampleComingSoon": "Sample projects coming soon",
+ "home.newInVersion": "New in v{version}",
+ "home.welcomeOverlayTitle": "Welcome to OpenTake",
+ "home.welcomeOverlayBody": "Click \"New Project\" to start your first project.",
+ "home.welcomeOverlayStart": "Get started",
+
+ // Home relative time
+ "home.relative.today": "Today",
+ "home.relative.yesterday": "Yesterday",
+ "home.relative.daysAgo": "{count} days ago",
+ "home.relative.weeksAgo": "{count} weeks ago",
+ "home.relative.monthsAgo": "{count} months ago",
+
"common.cancel": "Cancel",
"common.open": "Open",
diff --git a/web/src/store/settingsStore.ts b/web/src/store/settingsStore.ts
index 7698137..8964514 100644
--- a/web/src/store/settingsStore.ts
+++ b/web/src/store/settingsStore.ts
@@ -1,10 +1,11 @@
/**
* App-level settings (UI preferences only — never editing truth). Persisted to
* localStorage so they survive restarts: theme, the default folder the import
- * dialog opens to, and the BYOK provider choice. Only the *provider choice* is
- * stored here — the API key itself never touches this store or localStorage; it
- * lives in the OS keychain via the `secret_*` Tauri commands (see
- * `lib/api.ts` / `src-tauri/src/secret.rs`).
+ * dialog opens to, the BYOK provider choice, model selection, telemetry toggles,
+ * and notification preferences. Only the *provider choice* is stored here —
+ * the API key itself never touches this store or localStorage; it lives in the
+ * OS keychain via the `secret_*` Tauri commands (see `lib/api.ts` /
+ * `src-tauri/src/secret.rs`).
*/
import { create } from "zustand";
@@ -16,6 +17,10 @@ const LS = {
theme: "theme",
defaultImportFolder: "defaultImportFolder",
byokProvider: "byokProvider",
+ byokModel: "byokModel",
+ telemetryUsage: "telemetryUsage",
+ telemetryCrash: "telemetryCrash",
+ notificationsGeneration: "notificationsGeneration",
} as const;
function loadTheme(): Theme {
@@ -26,6 +31,12 @@ function loadString(key: string): string | null {
if (typeof localStorage === "undefined") return null;
return localStorage.getItem(key);
}
+function loadBool(key: string, fallback: boolean): boolean {
+ if (typeof localStorage === "undefined") return fallback;
+ const v = localStorage.getItem(key);
+ if (v === null) return fallback;
+ return v === "true";
+}
function loadProvider(): ByokProvider {
const v = loadString(LS.byokProvider);
return v === "openai" || v === "google" ? v : "anthropic";
@@ -35,20 +46,35 @@ function persist(key: string, value: string | null) {
if (value === null) localStorage.removeItem(key);
else localStorage.setItem(key, value);
}
+function persistBool(key: string, value: boolean) {
+ persist(key, value ? "true" : "false");
+}
interface SettingsState {
theme: Theme;
defaultImportFolder: string | null;
byokProvider: ByokProvider;
+ byokModel: string;
+ telemetryUsage: boolean;
+ telemetryCrash: boolean;
+ notificationsGeneration: boolean;
setTheme: (theme: Theme) => void;
setDefaultImportFolder: (path: string | null) => void;
setByokProvider: (provider: ByokProvider) => void;
+ setByokModel: (model: string) => void;
+ setTelemetryUsage: (v: boolean) => void;
+ setTelemetryCrash: (v: boolean) => void;
+ setNotificationsGeneration: (v: boolean) => void;
}
export const useSettingsStore = create((set) => ({
theme: loadTheme(),
defaultImportFolder: loadString(LS.defaultImportFolder),
byokProvider: loadProvider(),
+ byokModel: loadString(LS.byokModel) ?? "claude-sonnet-4-20250514",
+ telemetryUsage: loadBool(LS.telemetryUsage, false),
+ telemetryCrash: loadBool(LS.telemetryCrash, false),
+ notificationsGeneration: loadBool(LS.notificationsGeneration, true),
setTheme: (theme) => {
persist(LS.theme, theme);
applyTheme(theme);
@@ -62,6 +88,22 @@ export const useSettingsStore = create((set) => ({
persist(LS.byokProvider, byokProvider);
set({ byokProvider });
},
+ setByokModel: (byokModel) => {
+ persist(LS.byokModel, byokModel);
+ set({ byokModel });
+ },
+ setTelemetryUsage: (telemetryUsage) => {
+ persistBool(LS.telemetryUsage, telemetryUsage);
+ set({ telemetryUsage });
+ },
+ setTelemetryCrash: (telemetryCrash) => {
+ persistBool(LS.telemetryCrash, telemetryCrash);
+ set({ telemetryCrash });
+ },
+ setNotificationsGeneration: (notificationsGeneration) => {
+ persistBool(LS.notificationsGeneration, notificationsGeneration);
+ set({ notificationsGeneration });
+ },
}));
/** Reflect the theme onto the document root so tokens can switch on it. */
@@ -74,4 +116,4 @@ export function applyTheme(theme: Theme): void {
/** Apply the persisted theme at startup. */
export function initTheme(): void {
applyTheme(useSettingsStore.getState().theme);
-}
+}
\ No newline at end of file