mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
2d65c65596
* feat(ui): pair a light theme and a dark theme, switched by mode
ThemeProvider stored one palette plus a mode, so picking a dark-only
palette pinned the mode and greyed out the Light/System buttons. Store a
pair instead: { mode, light, dark }, resolved as pair[preferredMode], so
System flips between the two choices as the OS scheme changes.
The Settings Theme tab now assigns one half at a time. A Light/Dark
switch decides which half the grid is filling, the grid lists only the
palettes that can render that half (from the registry's modeSupport), and
a summary line names both halves with each side clickable. Every mode
button is permanently enabled: a dark-only palette simply never occupies
the light slot, so no mode coercion is left to do.
The pair round-trips through the SETTINGS registry to the `theme` key in
~/.plannotator/config.json the way diffOptions does. A user upgrading
seeds both halves from their stored single palette, and the legacy
plannotator-color-theme key keeps tracking the active palette so a
downgrade never lands on an unstyled first frame.
Addresses part 1 of #1211.
* fix(ui): make the theme pair seed local, and keep the legacy API non-destructive
Review of #1217 found a data-loss path and three published-API regressions.
Seeding: ThemeProvider handed its resolved pair to the config store through
set(), which queues a debounced POST. configStore.init() applies the server
config but never cancelled that queued write, so a single cookie-less visit
(fresh profile, incognito, cleared cookies) flushed a default pair to
~/.plannotator/config.json AFTER the real one had arrived, and the next
session restored those defaults over the user's cookies. The provider now
uses a new configStore.seed(): memory plus cookie, never the server, and
never over a value init() already applied. init() additionally retracts
queued writes for the leaves the server just spoke for, which closes the
same race for every server-synced setting rather than this one key.
Deprecated APIs: isThemeModeAvailable() and normalizeThemeMode() are back as
one-line wrappers with @deprecated notes, since packages/ui exports utils/*.
setColorTheme: assigns exactly one half and nothing else. A both-mode palette
goes to the half on screen instead of clobbering both; a mode-restricted one
goes to its half without yanking a System user to an explicit mode (render
time already resolves that). It persists through configStore.setLocal(), so
it stays cookie-only as it was before the pair, unless a host installed its
own serverSync transport.
storageKey / colorThemeStorageKey are honored on the read path, so a host's
stored pre-pair preference is migrated rather than discarded. The two halves
have no pre-pair equivalent and stay on fixed keys, documented on the props.
Tests: a fresh-mount case that pins zero POSTs (the previous helper pre-seeded
cookies, which is why this was invisible), a case that pins a real choice
still reaching config.json, direct setColorTheme cases for all three
semantics, a host-storage-keys migration case, and configStore seed/retract
unit tests. All of them fail against the code they replace.
300 lines
9.9 KiB
TypeScript
300 lines
9.9 KiB
TypeScript
import React from 'react';
|
|
import {
|
|
ActionMenu,
|
|
ActionMenuDivider,
|
|
ActionMenuItem,
|
|
ActionMenuSectionLabel,
|
|
} from './ActionMenu';
|
|
import { useTheme } from './ThemeProvider';
|
|
import { THEME_MODES } from './themeModes';
|
|
import { ReviewAgentsIcon } from './ReviewAgentsIcon';
|
|
import { MenuVersionSection } from './MenuVersionSection';
|
|
import { TextShimmer } from './TextShimmer';
|
|
import type { UpdateInfo } from '../hooks/useUpdateCheck';
|
|
import type { Origin } from '@plannotator/core/agents';
|
|
|
|
interface PlanHeaderMenuProps {
|
|
appVersion: string;
|
|
updateInfo?: UpdateInfo | null;
|
|
origin?: Origin | null;
|
|
isWSL?: boolean;
|
|
onOpenSettings: () => void;
|
|
onOpenExport: () => void;
|
|
onCopyAgentInstructions: () => void;
|
|
onDownloadAnnotations: () => void;
|
|
onPrint: () => void;
|
|
onCopyShareLink: () => void;
|
|
onOpenImport: () => void;
|
|
onSaveToObsidian: () => void;
|
|
onSaveToBear: () => void;
|
|
onSaveToOctarine: () => void;
|
|
sharingEnabled: boolean;
|
|
isApiMode: boolean;
|
|
agentInstructionsEnabled: boolean;
|
|
obsidianConfigured: boolean;
|
|
bearConfigured: boolean;
|
|
octarineConfigured: boolean;
|
|
}
|
|
|
|
export const PlanHeaderMenu: React.FC<PlanHeaderMenuProps> = ({
|
|
appVersion,
|
|
updateInfo,
|
|
origin,
|
|
isWSL = false,
|
|
onOpenSettings,
|
|
onOpenExport,
|
|
onCopyAgentInstructions,
|
|
onDownloadAnnotations,
|
|
onPrint,
|
|
onCopyShareLink,
|
|
onOpenImport,
|
|
onSaveToObsidian,
|
|
onSaveToBear,
|
|
onSaveToOctarine,
|
|
sharingEnabled,
|
|
isApiMode,
|
|
agentInstructionsEnabled,
|
|
obsidianConfigured,
|
|
bearConfigured,
|
|
octarineConfigured,
|
|
}) => {
|
|
const { theme, setTheme } = useTheme();
|
|
|
|
const showUpdateDot = !!updateInfo?.updateAvailable && !updateInfo.dismissed;
|
|
|
|
const anyNotesAppConfigured =
|
|
isApiMode && (obsidianConfigured || bearConfigured || octarineConfigured);
|
|
|
|
return (
|
|
<ActionMenu
|
|
panelWidth="wide"
|
|
renderTrigger={({ isOpen, toggleMenu }) => (
|
|
<button
|
|
onClick={() => {
|
|
if (!isOpen && showUpdateDot) updateInfo?.dismiss();
|
|
toggleMenu();
|
|
}}
|
|
className={`relative flex items-center gap-1.5 p-1.5 md:px-2.5 md:py-1 rounded-md text-xs font-medium transition-colors ${
|
|
isOpen
|
|
? 'bg-muted text-foreground'
|
|
: 'text-muted-foreground hover:text-foreground hover:bg-muted'
|
|
}`}
|
|
title="Options"
|
|
aria-label="Options"
|
|
aria-expanded={isOpen}
|
|
>
|
|
{isOpen ? <CloseIcon /> : <MenuIcon />}
|
|
{showUpdateDot ? (
|
|
<TextShimmer className="hidden md:inline text-xs font-medium" duration={2.5} spread={1.5}>
|
|
Options
|
|
</TextShimmer>
|
|
) : (
|
|
<span className="hidden md:inline">Options</span>
|
|
)}
|
|
{showUpdateDot && (
|
|
<span className="absolute top-0.5 right-0.5 md:-top-0.5 md:-right-0.5 w-2 h-2 rounded-full bg-primary ring-2 ring-background" />
|
|
)}
|
|
</button>
|
|
)}
|
|
>
|
|
{({ closeMenu }) => (
|
|
<>
|
|
<div className="px-3 py-2 space-y-1.5">
|
|
<ActionMenuSectionLabel>Theme</ActionMenuSectionLabel>
|
|
<div className="flex items-center gap-1 rounded-lg bg-muted/50 p-0.5">
|
|
{THEME_MODES.map(({ id, label, Icon }) => (
|
|
<button
|
|
key={id}
|
|
onClick={() => {
|
|
closeMenu();
|
|
setTheme(id);
|
|
}}
|
|
className={`flex flex-1 items-center justify-center gap-1.5 rounded-md px-2 py-1 text-[11px] font-medium transition-colors ${
|
|
theme === id
|
|
? 'bg-background text-foreground shadow-sm'
|
|
: 'text-muted-foreground hover:text-foreground'
|
|
}`}
|
|
>
|
|
<Icon />
|
|
<span>{label}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<ActionMenuDivider />
|
|
|
|
<ActionMenuItem
|
|
onClick={() => {
|
|
closeMenu();
|
|
onOpenSettings();
|
|
}}
|
|
icon={<SettingsIcon />}
|
|
label="Settings"
|
|
/>
|
|
<ActionMenuItem
|
|
onClick={() => {
|
|
closeMenu();
|
|
onOpenExport();
|
|
}}
|
|
icon={<ExportIcon />}
|
|
label="Export"
|
|
/>
|
|
{agentInstructionsEnabled && (
|
|
<ActionMenuItem
|
|
onClick={() => {
|
|
closeMenu();
|
|
onCopyAgentInstructions();
|
|
}}
|
|
icon={<ReviewAgentsIcon />}
|
|
label="Agent Instructions"
|
|
subtitle="Copy agent instructions for external annotations"
|
|
/>
|
|
)}
|
|
|
|
<ActionMenuDivider />
|
|
|
|
<ActionMenuItem
|
|
onClick={() => {
|
|
closeMenu();
|
|
onDownloadAnnotations();
|
|
}}
|
|
icon={<DownloadIcon />}
|
|
label="Download Annotations"
|
|
/>
|
|
<ActionMenuItem
|
|
onClick={() => {
|
|
closeMenu();
|
|
onPrint();
|
|
}}
|
|
icon={<PrintIcon />}
|
|
label="Print / Save as PDF"
|
|
subtitle="Choose 'Save as PDF' in the print dialog"
|
|
/>
|
|
{sharingEnabled && (
|
|
<ActionMenuItem
|
|
onClick={() => {
|
|
closeMenu();
|
|
onCopyShareLink();
|
|
}}
|
|
icon={<LinkIcon />}
|
|
label="Copy Share Link"
|
|
/>
|
|
)}
|
|
{sharingEnabled && (
|
|
<ActionMenuItem
|
|
onClick={() => {
|
|
closeMenu();
|
|
onOpenImport();
|
|
}}
|
|
icon={<ImportIcon />}
|
|
label="Import Review"
|
|
/>
|
|
)}
|
|
|
|
{anyNotesAppConfigured && (
|
|
<>
|
|
<ActionMenuDivider />
|
|
{obsidianConfigured && (
|
|
<ActionMenuItem
|
|
onClick={() => {
|
|
closeMenu();
|
|
onSaveToObsidian();
|
|
}}
|
|
icon={<NoteIcon />}
|
|
label="Save to Obsidian"
|
|
/>
|
|
)}
|
|
{bearConfigured && (
|
|
<ActionMenuItem
|
|
onClick={() => {
|
|
closeMenu();
|
|
onSaveToBear();
|
|
}}
|
|
icon={<NoteIcon />}
|
|
label="Save to Bear"
|
|
/>
|
|
)}
|
|
{octarineConfigured && (
|
|
<ActionMenuItem
|
|
onClick={() => {
|
|
closeMenu();
|
|
onSaveToOctarine();
|
|
}}
|
|
icon={<NoteIcon />}
|
|
label="Save to Octarine"
|
|
/>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
<ActionMenuDivider />
|
|
|
|
<MenuVersionSection
|
|
appVersion={appVersion}
|
|
updateInfo={updateInfo}
|
|
origin={origin}
|
|
isWSL={isWSL}
|
|
closeMenu={closeMenu}
|
|
/>
|
|
</>
|
|
)}
|
|
</ActionMenu>
|
|
);
|
|
};
|
|
|
|
const MenuIcon = () => (
|
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 12h16M4 18h16" />
|
|
</svg>
|
|
);
|
|
|
|
const CloseIcon = () => (
|
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
);
|
|
|
|
const SettingsIcon = () => (
|
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
|
</svg>
|
|
);
|
|
|
|
const ExportIcon = () => (
|
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
|
|
</svg>
|
|
);
|
|
|
|
const DownloadIcon = () => (
|
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
|
</svg>
|
|
);
|
|
|
|
const PrintIcon = () => (
|
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z" />
|
|
</svg>
|
|
);
|
|
|
|
const LinkIcon = () => (
|
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
|
|
</svg>
|
|
);
|
|
|
|
const ImportIcon = () => (
|
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15 3h4a2 2 0 012 2v14a2 2 0 01-2 2h-4M10 17l5-5-5-5M15 12H3" />
|
|
</svg>
|
|
);
|
|
|
|
const NoteIcon = () => (
|
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" />
|
|
</svg>
|
|
);
|