From d04814009ffbc6f00cf72ce1afc4f2eb8de51fe1 Mon Sep 17 00:00:00 2001 From: tianma-if Date: Tue, 29 Sep 2026 17:42:28 +0800 Subject: [PATCH] feat(web): move system information into personal settings --- apps/web/src/components/EditorPane.tsx | 3 - .../MemoEditorHeaderActions.test.mjs | 3 +- .../components/MemoEditorHeaderActions.tsx | 28 +--- apps/web/src/components/NotebookPane.tsx | 11 +- apps/web/src/components/SettingsPane.tsx | 40 ++--- .../src/components/SystemInfoAccess.test.mjs | 39 +++-- .../src/components/SystemInfoDialog.test.mjs | 15 -- apps/web/src/components/SystemInfoDialog.tsx | 142 ------------------ .../src/components/WorkspaceMobileChrome.tsx | 11 +- .../components/settings/SystemInfoPanel.tsx | 5 +- apps/web/src/components/ui/dialog.tsx | 6 +- apps/web/src/lib/dialog-drag.test.mjs | 20 --- apps/web/src/lib/dialog-drag.ts | 28 ---- 13 files changed, 70 insertions(+), 281 deletions(-) delete mode 100644 apps/web/src/components/SystemInfoDialog.test.mjs delete mode 100644 apps/web/src/components/SystemInfoDialog.tsx delete mode 100644 apps/web/src/lib/dialog-drag.test.mjs delete mode 100644 apps/web/src/lib/dialog-drag.ts diff --git a/apps/web/src/components/EditorPane.tsx b/apps/web/src/components/EditorPane.tsx index 597ced59..0167e325 100644 --- a/apps/web/src/components/EditorPane.tsx +++ b/apps/web/src/components/EditorPane.tsx @@ -419,7 +419,6 @@ const RichEditorPane = ({ const resourceInsertionLimit = useMemo(createFileBatchQueue, []); const isSelectionMode = Boolean(selectionActionBar); const [title, setTitle] = useState(""); - const [systemInfoOpen, setSystemInfoOpen] = useState(false); const [tagsText, setTagsText] = useState(""); const { dirtyVersion, @@ -2918,7 +2917,6 @@ const RichEditorPane = ({ historyOpen || shareOpen || aiAssistantOpen || - systemInfoOpen || mobileNotebookSheetOpen || noteLinkPickerOpen || externalLinkDialogOpen || @@ -3853,7 +3851,6 @@ const RichEditorPane = ({ moreMenuClassName="w-56 rounded-md" onOpenExecutionCenter={onOpenExecutionCenter} onSearch={() => openNoteSearch()} - onSystemInfoOpenChange={setSystemInfoOpen} textNoteActions={( <> {!effectiveReadOnly && ( diff --git a/apps/web/src/components/MemoEditorHeaderActions.test.mjs b/apps/web/src/components/MemoEditorHeaderActions.test.mjs index 904890bc..81a1bfe6 100644 --- a/apps/web/src/components/MemoEditorHeaderActions.test.mjs +++ b/apps/web/src/components/MemoEditorHeaderActions.test.mjs @@ -15,7 +15,8 @@ describe("shared memo editor header actions", () => { test("owns every action shared by text and diagram notes", () => { expect(actionsSource).toContain(" void; onSearch: () => void; - onSystemInfoOpenChange?: (open: boolean) => void; textNoteActions?: ReactNode; textNoteMenuItems?: ReactNode; }) => { const { t } = useTranslation(); - const [systemInfoOpen, setSystemInfoOpen] = useState(false); - const { unseen: deployedUpdateUnseen } = useDeployedUpdateNotice(); const { resolvedTheme, setPreference } = useAppearanceTheme(); const nextTheme = resolvedTheme === "dark" ? "light" : "dark"; const themeLabel = nextTheme === "dark" ? t("settings.themeToggleToDark") : t("settings.themeToggleToLight"); - const handleSystemInfoOpenChange = (open: boolean) => { - setSystemInfoOpen(open); - onSystemInfoOpenChange?.(open); - }; return ( <> @@ -59,14 +49,13 @@ export const MemoEditorHeaderActions = ({ {moreMenuItems} - handleSystemInfoOpenChange(true)} - > - - - {deployedUpdateUnseen ? : null} - - {t("systemInfo.title")} - - ); }; diff --git a/apps/web/src/components/NotebookPane.tsx b/apps/web/src/components/NotebookPane.tsx index b52beffa..65da7c18 100644 --- a/apps/web/src/components/NotebookPane.tsx +++ b/apps/web/src/components/NotebookPane.tsx @@ -67,6 +67,7 @@ import type { EdgeEverRepository } from "@/lib/repository"; import type { EdgeEverPluginHost } from "@/lib/plugins/plugin-host"; import { statusSettleMotion } from "@/lib/motion"; import { DesktopUpdateNotice } from "./DesktopUpdateNotice"; +import { useDeployedUpdateNotice } from "@/hooks/useDeployedUpdateNotice"; import { PluginToolbarMenu } from "./plugins/PluginToolbarMenu"; const DesktopSyncIssuesDialog = lazy(() => import("./DesktopSyncIssuesDialog").then((module) => ({ default: module.DesktopSyncIssuesDialog }))); @@ -274,12 +275,14 @@ const SidebarRailButton = ({ active = false, icon, label, + notice = false, onClick, disabled = false, }: { active?: boolean; icon: ReactNode; label: string; + notice?: boolean; onClick: () => void; disabled?: boolean; }) => ( @@ -292,11 +295,12 @@ const SidebarRailButton = ({ aria-label={label} onClick={onClick} className={cn( - "flex h-9 w-9 items-center justify-center rounded-lg text-slate-600 transition-colors duration-150 hover:bg-slate-100 hover:text-slate-950 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-900/20 disabled:cursor-not-allowed disabled:opacity-50", + "relative flex h-9 w-9 items-center justify-center rounded-lg text-slate-600 transition-colors duration-150 hover:bg-slate-100 hover:text-slate-950 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-900/20 disabled:cursor-not-allowed disabled:opacity-50", active && "edgeever-workspace-selection text-slate-950" )} > {icon} + {notice ? : null} {label} @@ -538,6 +542,7 @@ export const NotebookPane = ({ onToggleCollapsed?: () => void; }) => { const { t } = useTranslation(); + const { unseen: deployedUpdateUnseen } = useDeployedUpdateNotice(); // Temporarily keep template actions out of the primary workspace navigation. const showTemplateEntry = true; const notebookScrollRef = useRef(null); @@ -926,6 +931,7 @@ export const NotebookPane = ({ } label={t("notebookPane.profile")} + notice={deployedUpdateUnseen} onClick={onOpenSettings} /> @@ -1153,8 +1159,9 @@ export const NotebookPane = ({ type="button" aria-label={t("notebookPane.profile")} > - + + {deployedUpdateUnseen ? : null} {t("notebookPane.profile")} diff --git a/apps/web/src/components/SettingsPane.tsx b/apps/web/src/components/SettingsPane.tsx index 93d24600..8785f93d 100644 --- a/apps/web/src/components/SettingsPane.tsx +++ b/apps/web/src/components/SettingsPane.tsx @@ -16,7 +16,6 @@ import { import { useState, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import * as m from "motion/react-m"; -import { SystemInfoDialog } from "@/components/SystemInfoDialog"; import { Button } from "@/components/ui/button"; import type { ShortcutSettings } from "@/lib/app-helpers"; @@ -30,6 +29,7 @@ import { DesktopLocalDataCard } from "./settings/DesktopLocalDataCard"; import { LoginDevicesCard } from "./settings/LoginDevicesCard"; import { EvernoteImportGuideCard } from "./settings/EvernoteImportGuideCard"; import { FeedbackLink } from "./settings/FeedbackLink"; +import { SystemInfoPanel } from "./settings/SystemInfoPanel"; import { McpConfigCard } from "./settings/McpConfigCard"; import { PreferenceCard } from "./settings/PreferenceCard"; import { ShortcutSettingsItem } from "./settings/ShortcutSettingsItem"; @@ -72,7 +72,7 @@ const SettingsGroup = ({ children }: { children: ReactNode }) => ( ); -type TabKey = "general" | "shortcuts" | "users" | "data" | "ai" | "mcp" | "advanced" | "account"; +type TabKey = "general" | "shortcuts" | "users" | "data" | "ai" | "mcp" | "advanced" | "account" | "system"; interface TabItem { key: TabKey; @@ -105,7 +105,6 @@ export const SettingsPane = ({ const { t } = useTranslation(); const [activeTab, setActiveTab] = useState("general"); const [activeMobileTab, setActiveMobileTab] = useState(null); - const [systemInfoOpen, setSystemInfoOpen] = useState(false); const { unseen: deployedUpdateUnseen } = useDeployedUpdateNotice(); const canClearLocalData = Boolean(window.edgeeverDesktop?.canClearLocalData); @@ -158,6 +157,11 @@ export const SettingsPane = ({ label: t("settings.tabs.account"), icon: Shield, }, + { + key: "system", + label: t("systemInfo.title"), + icon: Info, + }, ]; const mobileTabItems = tabItems.filter((item) => item.key !== "shortcuts"); @@ -257,6 +261,8 @@ export const SettingsPane = ({ )} ); + case "system": + return ; default: return null; } @@ -309,7 +315,10 @@ export const SettingsPane = ({ : "font-normal text-slate-600 hover:bg-workspace-hover hover:text-slate-900" )} > - + + + {item.key === "system" && deployedUpdateUnseen ? : null} + {item.label} {item.badge ? (
-
+
+ {item.key === "system" && deployedUpdateUnseen ? : null}
{item.label} {item.badge ? ( @@ -393,24 +403,7 @@ export const SettingsPane = ({ ); })}
-
- +
@@ -422,7 +415,6 @@ export const SettingsPane = ({ )}
- ); }; diff --git a/apps/web/src/components/SystemInfoAccess.test.mjs b/apps/web/src/components/SystemInfoAccess.test.mjs index 5d7c16c4..19d8944e 100644 --- a/apps/web/src/components/SystemInfoAccess.test.mjs +++ b/apps/web/src/components/SystemInfoAccess.test.mjs @@ -3,26 +3,37 @@ import { readFileSync } from "node:fs"; const headerActionsSource = readFileSync(new URL("./MemoEditorHeaderActions.tsx", import.meta.url), "utf8"); const settingsPaneSource = readFileSync(new URL("./SettingsPane.tsx", import.meta.url), "utf8"); +const notebookPaneSource = readFileSync(new URL("./NotebookPane.tsx", import.meta.url), "utf8"); +const mobileChromeSource = readFileSync(new URL("./WorkspaceMobileChrome.tsx", import.meta.url), "utf8"); +const panelSource = readFileSync(new URL("./settings/SystemInfoPanel.tsx", import.meta.url), "utf8"); describe("system information access", () => { - test("keeps a compact-workspace entry in the editor overflow menu", () => { - expect(headerActionsSource).toMatch( - //, - ); - expect(headerActionsSource).not.toContain("min-[1600px]:inline-flex"); + test("keeps system information out of the note overflow menu", () => { + expect(headerActionsSource).not.toContain("systemInfo.title"); + expect(headerActionsSource).not.toContain("SystemInfoDialog"); + expect(headerActionsSource).not.toContain("deployedUpdateUnseen"); }); - test("does not duplicate the entry in desktop settings", () => { - const desktopSettings = settingsPaneSource.match(/桌面端布局:双栏[\s\S]*?移动端布局/)?.[0]; + test("adds system information to the settings navigation on desktop and mobile", () => { + expect(settingsPaneSource).toContain('key: "system"'); + expect(settingsPaneSource).toContain('label: t("systemInfo.title")'); + expect(settingsPaneSource).toContain(" { - const mobileSettings = settingsPaneSource.match(/移动端布局[\s\S]*? { + expect(notebookPaneSource).toContain("notice={deployedUpdateUnseen}"); + expect(notebookPaneSource).toContain("deployedUpdateUnseen ?"); + expect(mobileChromeSource).toContain("notice={deployedUpdateUnseen}"); + expect(panelSource).toContain("if (active) markSeen()"); }); }); diff --git a/apps/web/src/components/SystemInfoDialog.test.mjs b/apps/web/src/components/SystemInfoDialog.test.mjs deleted file mode 100644 index a66913be..00000000 --- a/apps/web/src/components/SystemInfoDialog.test.mjs +++ /dev/null @@ -1,15 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { readFileSync } from "node:fs"; - -const source = readFileSync(new URL("./SystemInfoDialog.tsx", import.meta.url), "utf8"); - -describe("system information dialog dragging", () => { - test("moves from the title bar and keeps the body scrollable", () => { - expect(source).toContain("handleContentPointerDown"); - expect(source).toContain("data-dialog-drag-handle"); - expect(source).toContain("clampDialogPixelPosition"); - expect(source).toContain("cursor-grab"); - expect(source).toContain("grid-rows-[auto_minmax(0,1fr)_auto]"); - expect(source).toContain("min-h-0 overflow-y-auto"); - }); -}); diff --git a/apps/web/src/components/SystemInfoDialog.tsx b/apps/web/src/components/SystemInfoDialog.tsx deleted file mode 100644 index 9a98ddc1..00000000 --- a/apps/web/src/components/SystemInfoDialog.tsx +++ /dev/null @@ -1,142 +0,0 @@ -import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from "react"; -import { useTranslation } from "react-i18next"; -import { Button } from "@/components/ui/button"; -import { useDeployedUpdateNotice } from "@/hooks/useDeployedUpdateNotice"; -import { clampDialogPixelPosition } from "@/lib/dialog-drag"; -import { cn } from "@/lib/utils"; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { SystemInfoPanel } from "./settings/SystemInfoPanel"; - -type DialogDragSession = { - pointerId: number; - startX: number; - startY: number; - startPointerX: number; - startPointerY: number; -}; - -export const SystemInfoDialog = ({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) => { - const { t } = useTranslation(); - const { markSeen } = useDeployedUpdateNotice(); - const contentRef = useRef(null); - const dragRef = useRef(null); - const positionRef = useRef<{ x: number; y: number } | null>(null); - const [position, setPosition] = useState<{ x: number; y: number } | null>(null); - const [dragging, setDragging] = useState(false); - - useEffect(() => { - if (open) markSeen(); - }, [markSeen, open]); - - useEffect(() => { - if (open) return; - dragRef.current = null; - positionRef.current = null; - setPosition(null); - setDragging(false); - }, [open]); - - const setContentNode = (node: HTMLDivElement | null) => { - contentRef.current = node; - if (!node || positionRef.current) return; - const rect = node.getBoundingClientRect(); - const next = { x: rect.left, y: rect.top }; - positionRef.current = next; - setPosition(next); - }; - - const updatePosition = (next: { x: number; y: number }) => { - positionRef.current = next; - setPosition(next); - }; - - const handleDragMove = (event: ReactPointerEvent) => { - const drag = dragRef.current; - const content = contentRef.current; - if (!drag || !content || event.pointerId !== drag.pointerId) return; - updatePosition(clampDialogPixelPosition({ - x: drag.startX + (event.clientX - drag.startPointerX), - y: drag.startY + (event.clientY - drag.startPointerY), - width: content.offsetWidth, - height: content.offsetHeight, - viewportWidth: window.innerWidth, - viewportHeight: window.innerHeight, - })); - }; - - const stopDragging = (event: ReactPointerEvent) => { - const drag = dragRef.current; - if (!drag || event.pointerId !== drag.pointerId) return; - dragRef.current = null; - setDragging(false); - if (event.currentTarget.hasPointerCapture(event.pointerId)) { - event.currentTarget.releasePointerCapture(event.pointerId); - } - }; - - const handleContentPointerDown = (event: ReactPointerEvent) => { - if (event.button !== 0) return; - const target = event.target as Element | null; - if (!target?.closest("[data-dialog-drag-handle]")) return; - if (target.closest("button, a, input, textarea, [role='button']")) return; - const content = contentRef.current; - const current = positionRef.current; - if (!content || !current) return; - event.preventDefault(); - event.currentTarget.setPointerCapture(event.pointerId); - dragRef.current = { - pointerId: event.pointerId, - startX: current.x, - startY: current.y, - startPointerX: event.clientX, - startPointerY: event.clientY, - }; - setDragging(true); - }; - - return ( - - - - {t("systemInfo.title")} - -
- -
-
- -
-
-
- ); -}; diff --git a/apps/web/src/components/WorkspaceMobileChrome.tsx b/apps/web/src/components/WorkspaceMobileChrome.tsx index 2edf9d14..50e6096d 100644 --- a/apps/web/src/components/WorkspaceMobileChrome.tsx +++ b/apps/web/src/components/WorkspaceMobileChrome.tsx @@ -11,6 +11,7 @@ import { DrawerTitle, } from "@/components/ui/drawer"; import { cn } from "@/lib/utils"; +import { useDeployedUpdateNotice } from "@/hooks/useDeployedUpdateNotice"; import { BETA_BADGE_CLASSNAME } from "@/lib/workspace-ui"; import type { NoteCreateKind, Notebook } from "@edgeever/shared"; import type { MobileBottomNavItem, NotebookNode } from "@/lib/app-helpers"; @@ -26,11 +27,13 @@ const MobileBottomNavButton = ({ active = false, icon, label, + notice = false, onClick, }: { active?: boolean; icon: ReactNode; label: string; + notice?: boolean; onClick: () => void; }) => ( ); @@ -67,6 +73,7 @@ export const MobileBottomNav = ({ onOpenSettings: () => void; }) => { const { t } = useTranslation(); + const { unseen: deployedUpdateUnseen } = useDeployedUpdateNotice(); const createMemoLabel = !canCreateMemo ? t("nav.createDisabled") : isCreating ? t("nav.creating") : t("nav.createMemo"); return ( @@ -117,7 +124,7 @@ export const MobileBottomNav = ({ - } label={t("nav.mine")} onClick={onOpenSettings} /> + } label={t("nav.mine")} notice={deployedUpdateUnseen} onClick={onOpenSettings} /> ); diff --git a/apps/web/src/components/settings/SystemInfoPanel.tsx b/apps/web/src/components/settings/SystemInfoPanel.tsx index 99734817..5802c09d 100644 --- a/apps/web/src/components/settings/SystemInfoPanel.tsx +++ b/apps/web/src/components/settings/SystemInfoPanel.tsx @@ -269,7 +269,7 @@ export const SystemInfoPanel = ({ active = true }: { active?: boolean }) => { const [desktopUpdateChecked, setDesktopUpdateChecked] = useState(false); const [viewportRevision, setViewportRevision] = useState(0); const queryClient = useQueryClient(); - const { release } = useDeployedUpdateNotice(); + const { markSeen, release } = useDeployedUpdateNotice(); const desktopBridge = window.edgeeverDesktop; const desktopAvailable = desktopBridge?.isAvailable === true; const instanceUrl = desktopAvailable ? getConfiguredDesktopApiBaseUrl() : window.location.origin; @@ -305,6 +305,9 @@ export const SystemInfoPanel = ({ active = true }: { active?: boolean }) => { refetchInterval: (query) => query.state.data?.state === "available" ? 1_000 : false, retry: 1, }); + useEffect(() => { + if (active) markSeen(); + }, [active, markSeen]); useEffect(() => { if (!active) return; const onResize = () => setViewportRevision((value) => value + 1); diff --git a/apps/web/src/components/ui/dialog.tsx b/apps/web/src/components/ui/dialog.tsx index 1f6f27c2..6f630ca0 100644 --- a/apps/web/src/components/ui/dialog.tsx +++ b/apps/web/src/components/ui/dialog.tsx @@ -27,16 +27,14 @@ const DialogContent = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef & { showCloseButton?: boolean; - unstyledPosition?: boolean; } ->(({ className, children, showCloseButton = true, unstyledPosition = false, ...props }, ref) => ( +>(({ className, children, showCloseButton = true, ...props }, ref) => ( { - const base = { - width: 400, - height: 300, - viewportWidth: 1000, - viewportHeight: 800, - }; - - test("keeps unconstrained movement", () => { - expect(clampDialogPixelPosition({ ...base, x: 240, y: 80 })).toEqual({ x: 240, y: 80 }); - }); - - test("keeps a slice of the dialog inside the viewport", () => { - expect(clampDialogPixelPosition({ ...base, x: 2000, y: 2000 })).toEqual({ x: 952, y: 752 }); - expect(clampDialogPixelPosition({ ...base, x: -2000, y: -2000 })).toEqual({ x: -352, y: 0 }); - }); -}); diff --git a/apps/web/src/lib/dialog-drag.ts b/apps/web/src/lib/dialog-drag.ts deleted file mode 100644 index 7d6795d7..00000000 --- a/apps/web/src/lib/dialog-drag.ts +++ /dev/null @@ -1,28 +0,0 @@ -export const DIALOG_DRAG_MIN_VISIBLE = 48; - -export const clampDialogPixelPosition = ({ - x, - y, - width, - height, - viewportWidth, - viewportHeight, - minVisible = DIALOG_DRAG_MIN_VISIBLE, -}: { - x: number; - y: number; - width: number; - height: number; - viewportWidth: number; - viewportHeight: number; - minVisible?: number; -}) => { - const minX = minVisible - width; - const maxX = viewportWidth - minVisible; - const minY = 0; - const maxY = viewportHeight - minVisible; - return { - x: Math.min(Math.max(x, minX), Math.max(minX, maxX)), - y: Math.min(Math.max(y, minY), Math.max(minY, maxY)), - }; -};