feat(ui): Add project theme selection to context menu with clean code refactoring

Implement per-project theme override capability in the Discord-like layout:
- Add theme submenu to project context menu with live preview
- Reuse existing theme constants and useThemePreview hook from sidebar
- Extract reusable ThemeButton and ThemeColumn components (DRY principle)
- Replace magic z-index values with named constants

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Shirone
2026-01-14 15:33:51 +01:00
parent 3689eb969d
commit 8419b12f3f

View File

@@ -1,9 +1,105 @@
import { useEffect, useRef, useState } from 'react';
import { Edit2, Trash2 } from 'lucide-react';
import { useEffect, useRef, useState, memo } from 'react';
import type { LucideIcon } from 'lucide-react';
import { Edit2, Trash2, Palette, ChevronRight, Moon, Sun, Monitor } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useAppStore } from '@/store/app-store';
import { type ThemeMode, useAppStore } from '@/store/app-store';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import type { Project } from '@/lib/electron';
import { PROJECT_DARK_THEMES, PROJECT_LIGHT_THEMES } from '@/components/layout/sidebar/constants';
import { useThemePreview } from '@/components/layout/sidebar/hooks';
// Constants for z-index values
const Z_INDEX = {
CONTEXT_MENU: 100,
THEME_SUBMENU: 101,
} as const;
// Theme option type from sidebar constants
interface ThemeOption {
value: string;
label: string;
icon: LucideIcon;
color: string;
}
// Reusable theme button component to avoid duplication (DRY principle)
interface ThemeButtonProps {
option: ThemeOption;
isSelected: boolean;
onPointerEnter: () => void;
onPointerLeave: (e: React.PointerEvent) => void;
onClick: () => void;
}
const ThemeButton = memo(function ThemeButton({
option,
isSelected,
onPointerEnter,
onPointerLeave,
onClick,
}: ThemeButtonProps) {
const Icon = option.icon;
return (
<button
onPointerEnter={onPointerEnter}
onPointerLeave={onPointerLeave}
onClick={onClick}
className={cn(
'w-full flex items-center gap-1.5 px-2 py-1.5 rounded-md',
'text-xs text-left',
'hover:bg-accent transition-colors',
'focus:outline-none focus:bg-accent',
isSelected && 'bg-accent'
)}
data-testid={`project-theme-${option.value}`}
>
<Icon className="w-3.5 h-3.5" style={{ color: option.color }} />
<span>{option.label}</span>
</button>
);
});
// Reusable theme column component
interface ThemeColumnProps {
title: string;
icon: LucideIcon;
themes: ThemeOption[];
selectedTheme: string | null;
onPreviewEnter: (value: string) => void;
onPreviewLeave: (e: React.PointerEvent) => void;
onSelect: (value: string) => void;
}
const ThemeColumn = memo(function ThemeColumn({
title,
icon: Icon,
themes,
selectedTheme,
onPreviewEnter,
onPreviewLeave,
onSelect,
}: ThemeColumnProps) {
return (
<div className="flex-1">
<div className="flex items-center gap-1.5 px-2 py-1.5 text-xs font-medium text-muted-foreground">
<Icon className="w-3 h-3" />
{title}
</div>
<div className="space-y-0.5">
{themes.map((option) => (
<ThemeButton
key={option.value}
option={option}
isSelected={selectedTheme === option.value}
onPointerEnter={() => onPreviewEnter(option.value)}
onPointerLeave={onPreviewLeave}
onClick={() => onSelect(option.value)}
/>
))}
</div>
</div>
);
});
interface ProjectContextMenuProps {
project: Project;
@@ -19,18 +115,30 @@ export function ProjectContextMenu({
onEdit,
}: ProjectContextMenuProps) {
const menuRef = useRef<HTMLDivElement>(null);
const { moveProjectToTrash } = useAppStore();
const {
moveProjectToTrash,
theme: globalTheme,
setTheme,
setProjectTheme,
setPreviewTheme,
} = useAppStore();
const [showRemoveDialog, setShowRemoveDialog] = useState(false);
const [showThemeSubmenu, setShowThemeSubmenu] = useState(false);
const themeSubmenuRef = useRef<HTMLDivElement>(null);
const { handlePreviewEnter, handlePreviewLeave } = useThemePreview({ setPreviewTheme });
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
setPreviewTheme(null);
onClose();
}
};
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setPreviewTheme(null);
onClose();
}
};
@@ -42,7 +150,7 @@ export function ProjectContextMenu({
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('keydown', handleEscape);
};
}, [onClose]);
}, [onClose, setPreviewTheme]);
const handleEdit = () => {
onEdit(project);
@@ -52,6 +160,17 @@ export function ProjectContextMenu({
setShowRemoveDialog(true);
};
const handleThemeSelect = (value: string) => {
setPreviewTheme(null);
if (value !== '') {
setTheme(value as ThemeMode);
} else {
setTheme(globalTheme);
}
setProjectTheme(project.id, value === '' ? null : (value as ThemeMode));
setShowThemeSubmenu(false);
};
const handleConfirmRemove = () => {
moveProjectToTrash(project.id);
onClose();
@@ -62,7 +181,7 @@ export function ProjectContextMenu({
<div
ref={menuRef}
className={cn(
'fixed z-[100] min-w-48 rounded-lg',
'fixed min-w-48 rounded-lg',
'bg-popover text-popover-foreground',
'border border-border shadow-lg',
'animate-in fade-in zoom-in-95 duration-100'
@@ -70,6 +189,7 @@ export function ProjectContextMenu({
style={{
top: position.y,
left: position.x,
zIndex: Z_INDEX.CONTEXT_MENU,
}}
data-testid="project-context-menu"
>
@@ -88,6 +208,96 @@ export function ProjectContextMenu({
<span>Edit Name & Icon</span>
</button>
{/* Theme Submenu Trigger */}
<div className="relative">
<button
onMouseEnter={() => setShowThemeSubmenu(true)}
onClick={() => setShowThemeSubmenu(!showThemeSubmenu)}
className={cn(
'w-full flex items-center gap-2 px-3 py-2 rounded-md',
'text-sm font-medium text-left',
'hover:bg-accent transition-colors',
'focus:outline-none focus:bg-accent'
)}
data-testid="theme-project-button"
>
<Palette className="w-4 h-4" />
<span className="flex-1">Project Theme</span>
{project.theme && (
<span className="text-[10px] text-muted-foreground capitalize">
{project.theme}
</span>
)}
<ChevronRight className="w-4 h-4 text-muted-foreground" />
</button>
{/* Theme Submenu */}
{showThemeSubmenu && (
<div
ref={themeSubmenuRef}
className={cn(
'absolute left-full top-0 ml-1 min-w-[420px] rounded-lg',
'bg-popover text-popover-foreground',
'border border-border shadow-lg',
'animate-in fade-in zoom-in-95 duration-100'
)}
style={{ zIndex: Z_INDEX.THEME_SUBMENU }}
onMouseLeave={() => {
setShowThemeSubmenu(false);
setPreviewTheme(null);
}}
data-testid="project-theme-submenu"
>
<div className="p-2">
{/* Use Global Option */}
<button
onPointerEnter={() => handlePreviewEnter(globalTheme)}
onPointerLeave={() => setPreviewTheme(null)}
onClick={() => handleThemeSelect('')}
className={cn(
'w-full flex items-center gap-2 px-3 py-2 rounded-md',
'text-sm font-medium text-left',
'hover:bg-accent transition-colors',
'focus:outline-none focus:bg-accent',
!project.theme && 'bg-accent'
)}
data-testid="project-theme-global"
>
<Monitor className="w-4 h-4" />
<span>Use Global</span>
<span className="text-[10px] text-muted-foreground ml-1 capitalize">
({globalTheme})
</span>
</button>
<div className="h-px bg-border my-2" />
{/* Two Column Layout - Using reusable ThemeColumn component */}
<div className="flex gap-2">
<ThemeColumn
title="Dark"
icon={Moon}
themes={PROJECT_DARK_THEMES}
selectedTheme={project.theme ?? null}
onPreviewEnter={handlePreviewEnter}
onPreviewLeave={handlePreviewLeave}
onSelect={handleThemeSelect}
/>
<ThemeColumn
title="Light"
icon={Sun}
themes={PROJECT_LIGHT_THEMES}
selectedTheme={project.theme ?? null}
onPreviewEnter={handlePreviewEnter}
onPreviewLeave={handlePreviewLeave}
onSelect={handleThemeSelect}
/>
</div>
</div>
</div>
)}
</div>
<button
onClick={handleRemove}
className={cn(