feat(web): move system information into personal settings
This commit is contained in:
1 parent
b8a0b88d88
commit
d04814009f
13 files changed
+70
-281
No files matched your search
@@ -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 && (
|
||||
|
||||
@@ -15,7 +15,8 @@ describe("shared memo editor header actions", () => {
|
||||
test("owns every action shared by text and diagram notes", () => {
|
||||
expect(actionsSource).toContain("<Search");
|
||||
expect(actionsSource).toContain("<GitHubRepositoryLink");
|
||||
expect(actionsSource).toContain("<SystemInfoDialog");
|
||||
expect(actionsSource).not.toContain("SystemInfoDialog");
|
||||
expect(actionsSource).not.toContain("systemInfo.title");
|
||||
expect(actionsSource).toContain("<ExecutionCenterButton");
|
||||
expect(actionsSource).toContain("setPreference(nextTheme)");
|
||||
expect(actionsSource).toContain("<MoreHorizontal");
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { Info, MoonStar, MoreHorizontal, Search, SunMedium } from "lucide-react";
|
||||
import { type ReactNode } from "react";
|
||||
import { MoonStar, MoreHorizontal, Search, SunMedium } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ExecutionCenterButton } from "@/components/execution/ExecutionCenterButton";
|
||||
import { GitHubRepositoryLink } from "@/components/GitHubRepositoryLink";
|
||||
import { SystemInfoDialog } from "@/components/SystemInfoDialog";
|
||||
import { useAppearanceTheme } from "@/components/ThemeProvider";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -12,7 +11,6 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useDeployedUpdateNotice } from "@/hooks/useDeployedUpdateNotice";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export const MemoEditorHeaderActions = ({
|
||||
@@ -21,7 +19,6 @@ export const MemoEditorHeaderActions = ({
|
||||
moreMenuItems,
|
||||
onOpenExecutionCenter,
|
||||
onSearch,
|
||||
onSystemInfoOpenChange,
|
||||
textNoteActions,
|
||||
textNoteMenuItems,
|
||||
}: {
|
||||
@@ -30,20 +27,13 @@ export const MemoEditorHeaderActions = ({
|
||||
moreMenuItems: ReactNode;
|
||||
onOpenExecutionCenter: () => 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 = ({
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
className={cn("relative", moreButtonClassName)}
|
||||
className={moreButtonClassName}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
title={t("editor.more")}
|
||||
aria-label={t("editor.moreAria")}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
{deployedUpdateUnseen ? <span className="absolute right-1 top-1 h-1.5 w-1.5 rounded-full bg-emerald-500 ring-2 ring-card" /> : null}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
@@ -96,19 +85,8 @@ export const MemoEditorHeaderActions = ({
|
||||
{t("common.githubRepository")}
|
||||
</GitHubRepositoryLink>
|
||||
{moreMenuItems}
|
||||
<DropdownMenuItem
|
||||
className="flex h-9 w-full items-center gap-2 px-3 text-left text-xs text-slate-700 hover:bg-slate-50 cursor-pointer outline-none"
|
||||
onClick={() => handleSystemInfoOpenChange(true)}
|
||||
>
|
||||
<span className="relative flex h-4 w-4 shrink-0 items-center justify-center">
|
||||
<Info className="h-4 w-4 text-slate-500" />
|
||||
{deployedUpdateUnseen ? <span className="absolute -right-0.5 -top-0.5 h-1.5 w-1.5 rounded-full bg-emerald-500 ring-2 ring-card" /> : null}
|
||||
</span>
|
||||
{t("systemInfo.title")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<SystemInfoDialog open={systemInfoOpen} onOpenChange={handleSystemInfoOpenChange} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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 ? <span className="absolute right-1 top-1 h-1.5 w-1.5 rounded-full bg-emerald-500 ring-2 ring-[var(--workspace-sidebar)]" /> : null}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">{label}</TooltipContent>
|
||||
@@ -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<HTMLDivElement | null>(null);
|
||||
@@ -926,6 +931,7 @@ export const NotebookPane = ({
|
||||
<SidebarRailButton
|
||||
icon={<CircleUserRound className="h-4 w-4" />}
|
||||
label={t("notebookPane.profile")}
|
||||
notice={deployedUpdateUnseen}
|
||||
onClick={onOpenSettings}
|
||||
/>
|
||||
<SidebarCollapseButton collapsed onToggle={onToggleCollapsed} className="h-9 w-9" tooltipSide="right" />
|
||||
@@ -1153,8 +1159,9 @@ export const NotebookPane = ({
|
||||
type="button"
|
||||
aria-label={t("notebookPane.profile")}
|
||||
>
|
||||
<span className="flex h-4 w-4 shrink-0 items-center justify-center">
|
||||
<span className="relative flex h-4 w-4 shrink-0 items-center justify-center">
|
||||
<CircleUserRound className="h-4 w-4" />
|
||||
{deployedUpdateUnseen ? <span className="absolute -right-1 -top-1 h-1.5 w-1.5 rounded-full bg-emerald-500 ring-2 ring-[var(--workspace-sidebar)]" /> : null}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate">{t("notebookPane.profile")}</span>
|
||||
</button>
|
||||
|
||||
@@ -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 }) => (
|
||||
</div>
|
||||
);
|
||||
|
||||
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<TabKey>("general");
|
||||
const [activeMobileTab, setActiveMobileTab] = useState<TabKey | null>(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 = ({
|
||||
)}
|
||||
</SettingsGroup>
|
||||
);
|
||||
case "system":
|
||||
return <SystemInfoPanel />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
@@ -309,7 +315,10 @@ export const SettingsPane = ({
|
||||
: "font-normal text-slate-600 hover:bg-workspace-hover hover:text-slate-900"
|
||||
)}
|
||||
>
|
||||
<Icon className={cn("h-4 w-4 shrink-0 transition-colors", isSelected ? "text-slate-950" : "text-slate-400")} />
|
||||
<span className="relative flex h-4 w-4 shrink-0 items-center justify-center">
|
||||
<Icon className={cn("h-4 w-4 transition-colors", isSelected ? "text-slate-950" : "text-slate-400")} />
|
||||
{item.key === "system" && deployedUpdateUnseen ? <span className="absolute -right-1 -top-1 h-1.5 w-1.5 rounded-full bg-emerald-500 ring-2 ring-workspace-canvas" /> : null}
|
||||
</span>
|
||||
<span className="flex-1 truncate">{item.label}</span>
|
||||
{item.badge ? (
|
||||
<span
|
||||
@@ -380,8 +389,9 @@ export const SettingsPane = ({
|
||||
className="flex w-full items-center justify-between gap-4 p-4 text-left transition-colors hover:bg-slate-50/50"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-slate-100">
|
||||
<div className="relative flex h-8 w-8 items-center justify-center rounded-lg bg-slate-100">
|
||||
<Icon className="h-4 w-4 text-slate-700" />
|
||||
{item.key === "system" && deployedUpdateUnseen ? <span className="absolute right-0.5 top-0.5 h-1.5 w-1.5 rounded-full bg-emerald-500 ring-2 ring-card" /> : null}
|
||||
</div>
|
||||
<span className="text-xs font-normal leading-5 text-slate-800">{item.label}</span>
|
||||
{item.badge ? (
|
||||
@@ -393,24 +403,7 @@ export const SettingsPane = ({
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="divide-y divide-slate-100 overflow-hidden rounded-2xl border border-slate-200 bg-card">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSystemInfoOpen(true)}
|
||||
className="flex min-h-16 w-full items-center justify-between gap-3 px-4 py-2.5 text-left text-slate-600 transition-colors hover:bg-slate-200/50 hover:text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-900/20"
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-3">
|
||||
<span className="relative flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-slate-100">
|
||||
<Info className="h-4 w-4 text-slate-700" />
|
||||
{deployedUpdateUnseen ? <span className="absolute right-0.5 top-0.5 h-1.5 w-1.5 rounded-full bg-emerald-500 ring-2 ring-card" /> : null}
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-xs font-normal leading-5">{t("systemInfo.title")}</span>
|
||||
<span className="mt-0.5 block truncate text-xs text-slate-500">{t("systemInfo.description")}</span>
|
||||
</span>
|
||||
</span>
|
||||
<ChevronRight className="h-4 w-4 shrink-0 text-slate-400" />
|
||||
</button>
|
||||
<div className="overflow-hidden rounded-2xl border border-slate-200 bg-card">
|
||||
<FeedbackLink />
|
||||
</div>
|
||||
</div>
|
||||
@@ -422,7 +415,6 @@ export const SettingsPane = ({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<SystemInfoDialog open={systemInfoOpen} onOpenChange={setSystemInfoOpen} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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(
|
||||
/<DropdownMenuItem[\s\S]*?handleSystemInfoOpenChange\(true\)[\s\S]*?systemInfo\.title[\s\S]*?<\/DropdownMenuItem>/,
|
||||
);
|
||||
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("<SystemInfoPanel");
|
||||
expect(settingsPaneSource).not.toContain("SystemInfoDialog");
|
||||
expect(settingsPaneSource).toContain('item.key !== "shortcuts"');
|
||||
expect(settingsPaneSource).not.toContain('item.key !== "system"');
|
||||
|
||||
expect(desktopSettings).not.toContain("setSystemInfoOpen(true)");
|
||||
expect(desktopSettings).not.toContain('t("systemInfo.title")');
|
||||
const desktopSettings = settingsPaneSource.match(/桌面端布局:双栏[\s\S]*?移动端布局/)?.[0] ?? "";
|
||||
const mobileSettings = settingsPaneSource.match(/移动端布局[\s\S]*$/)?.[0] ?? "";
|
||||
expect(desktopSettings).toContain("tabItems.map");
|
||||
expect(mobileSettings).toContain("mobileTabItems.map");
|
||||
expect(desktopSettings).toContain('item.key === "system" && deployedUpdateUnseen');
|
||||
expect(mobileSettings).toContain('item.key === "system" && deployedUpdateUnseen');
|
||||
});
|
||||
|
||||
test("keeps the entry in mobile settings", () => {
|
||||
const mobileSettings = settingsPaneSource.match(/移动端布局[\s\S]*?<SystemInfoDialog/)?.[0];
|
||||
|
||||
expect(mobileSettings).toContain("setSystemInfoOpen(true)");
|
||||
expect(mobileSettings).toContain('t("systemInfo.title")');
|
||||
test("points the deployed-update dot at personal settings until the page is opened", () => {
|
||||
expect(notebookPaneSource).toContain("notice={deployedUpdateUnseen}");
|
||||
expect(notebookPaneSource).toContain("deployedUpdateUnseen ?");
|
||||
expect(mobileChromeSource).toContain("notice={deployedUpdateUnseen}");
|
||||
expect(panelSource).toContain("if (active) markSeen()");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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<HTMLDivElement>(null);
|
||||
const dragRef = useRef<DialogDragSession | null>(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<HTMLDivElement>) => {
|
||||
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<HTMLDivElement>) => {
|
||||
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<HTMLDivElement>) => {
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
ref={setContentNode}
|
||||
unstyledPosition
|
||||
className={cn(
|
||||
"grid max-h-[min(720px,calc(100dvh-2rem))] max-w-2xl grid-rows-[auto_minmax(0,1fr)_auto] gap-4 overflow-hidden p-0",
|
||||
dragging && "cursor-grabbing",
|
||||
)}
|
||||
data-offset-x={position?.x ?? ""}
|
||||
data-offset-y={position?.y ?? ""}
|
||||
onLostPointerCapture={stopDragging}
|
||||
onPointerCancel={stopDragging}
|
||||
onPointerDown={handleContentPointerDown}
|
||||
onPointerMove={handleDragMove}
|
||||
onPointerUp={stopDragging}
|
||||
style={position
|
||||
? { left: position.x, top: position.y, transform: "none" }
|
||||
: { left: "50%", top: "50%", transform: "translate(-50%, -50%)" }}
|
||||
>
|
||||
<DialogHeader
|
||||
className={cn(
|
||||
"touch-none select-none px-4 pr-12 pt-4 text-left sm:px-6 sm:pt-6",
|
||||
dragging ? "cursor-grabbing" : "cursor-grab",
|
||||
)}
|
||||
data-dialog-drag-handle="true"
|
||||
>
|
||||
<DialogTitle>{t("systemInfo.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="min-h-0 overflow-y-auto px-4 sm:px-6">
|
||||
<SystemInfoPanel />
|
||||
</div>
|
||||
<div className="flex justify-end px-4 pb-4 sm:px-6 sm:pb-6">
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
{t("common.close")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}) => (
|
||||
<button
|
||||
@@ -46,7 +49,10 @@ const MobileBottomNavButton = ({
|
||||
onClick={onClick}
|
||||
>
|
||||
{active ? <span aria-hidden="true" className="absolute top-0 h-0.5 w-5 rounded-full bg-slate-950" /> : null}
|
||||
{icon}
|
||||
<span className="relative">
|
||||
{icon}
|
||||
{notice ? <span className="absolute -right-1 -top-1 h-1.5 w-1.5 rounded-full bg-emerald-500 ring-2 ring-card" /> : null}
|
||||
</span>
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
);
|
||||
@@ -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 = ({
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<MobileBottomNavButton active={activeItem === "settings"} icon={<UserRound className="h-5 w-5" />} label={t("nav.mine")} onClick={onOpenSettings} />
|
||||
<MobileBottomNavButton active={activeItem === "settings"} icon={<UserRound className="h-5 w-5" />} label={t("nav.mine")} notice={deployedUpdateUnseen} onClick={onOpenSettings} />
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -27,16 +27,14 @@ const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean;
|
||||
unstyledPosition?: boolean;
|
||||
}
|
||||
>(({ className, children, showCloseButton = true, unstyledPosition = false, ...props }, ref) => (
|
||||
>(({ className, children, showCloseButton = true, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed z-50 grid w-full max-w-lg gap-4 rounded-lg border border-slate-200 bg-card p-6 shadow-lg",
|
||||
!unstyledPosition && "left-[50%] top-[50%] translate-x-[-50%] translate-y-[-50%] duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-1/2 data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-1/2",
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border border-slate-200 bg-card p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-1/2 data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-1/2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { clampDialogPixelPosition } from "./dialog-drag.ts";
|
||||
|
||||
describe("clampDialogPixelPosition", () => {
|
||||
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 });
|
||||
});
|
||||
});
|
||||
@@ -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)),
|
||||
};
|
||||
};
|
||||
Reference in new issue
Block a user