Makelore 2.0 initial clean snapshot
This commit is contained in:
110
src/components/layout/ModuleSwitcher.tsx
Normal file
110
src/components/layout/ModuleSwitcher.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { ArrowLeftRight, ChevronUp } from 'lucide-react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { aiModules, getAiModuleForPath, type AiModuleId } from '@/lib/ai-modules';
|
||||
|
||||
const moduleToneClasses: Record<AiModuleId, { active: string; icon: string }> = {
|
||||
programming: { active: 'bg-[#DCE6EF]', icon: 'bg-[#DCE6EF]' },
|
||||
painting: { active: 'bg-[#DCE6EF]', icon: 'bg-[#DCE6EF]' },
|
||||
};
|
||||
|
||||
export function ModuleSwitcher({ sidebarCollapsed }: { sidebarCollapsed: boolean }) {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [open, setOpen] = useState(false);
|
||||
const switcherRef = useRef<HTMLDivElement | null>(null);
|
||||
const activeModuleId = getAiModuleForPath(location.pathname);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return undefined;
|
||||
|
||||
const handleMouseDown = (event: MouseEvent) => {
|
||||
const target = event.target;
|
||||
if (target instanceof Node && !switcherRef.current?.contains(target)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') setOpen(false);
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleMouseDown);
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleMouseDown);
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div ref={switcherRef} data-testid="sidebar-module-switcher" className="relative">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="sidebar-module-switcher-trigger"
|
||||
aria-label="切换模块"
|
||||
aria-expanded={open}
|
||||
aria-controls="sidebar-module-switcher-menu"
|
||||
onClick={() => setOpen((current) => !current)}
|
||||
className={cn(
|
||||
'flex min-h-10 w-full items-center gap-2 rounded-lg border-2 border-[#26384D] bg-[#FFFFFF] px-2.5 py-2 text-left text-sm font-black text-[#26384D] shadow-[3px_3px_0_#26384D] transition-[background-color,color,transform] hover:bg-[#EEF3F7] active:scale-[0.96]',
|
||||
open && 'bg-[#EEF3F7]',
|
||||
sidebarCollapsed ? 'justify-center px-0' : '',
|
||||
)}
|
||||
>
|
||||
<ArrowLeftRight className="h-4 w-4 shrink-0" strokeWidth={2.4} />
|
||||
{!sidebarCollapsed ? <span className="min-w-0 flex-1 truncate">切换模块</span> : null}
|
||||
{!sidebarCollapsed ? <ChevronUp className={cn('h-4 w-4 shrink-0 transition-transform', !open && 'rotate-180')} /> : null}
|
||||
</button>
|
||||
|
||||
{open ? (
|
||||
<div
|
||||
id="sidebar-module-switcher-menu"
|
||||
role="menu"
|
||||
data-testid="sidebar-module-switcher-menu"
|
||||
className={cn(
|
||||
'absolute bottom-[calc(100%+0.5rem)] z-50 grid gap-1.5 rounded-lg border-2 border-[#26384D] bg-[#FFFFFF] p-2 text-[#26384D] shadow-[6px_6px_0_#26384D]',
|
||||
sidebarCollapsed ? 'left-0 w-64' : 'inset-x-0',
|
||||
)}
|
||||
>
|
||||
<div className="px-2 py-1 text-[11px] font-black text-[#68788B]">选择模块</div>
|
||||
{aiModules.map((module) => {
|
||||
const active = module.id === activeModuleId;
|
||||
const tone = moduleToneClasses[module.id];
|
||||
const ModuleIcon = module.Icon;
|
||||
return (
|
||||
<button
|
||||
key={module.id}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
data-testid={`sidebar-module-${module.id}`}
|
||||
aria-label={`${module.title}${module.enabled ? '' : ',即将上线'}`}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
disabled={!module.enabled}
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
if (module.route) navigate(module.route);
|
||||
}}
|
||||
className={cn(
|
||||
'flex min-h-9 w-full items-center gap-2 rounded-md border-2 px-2 py-1.5 text-left text-xs font-black text-[#26384D] transition-[background-color,color,transform,opacity] hover:bg-[#FFFFFF] active:scale-[0.97]',
|
||||
active
|
||||
? `border-[#26384D] ${tone.active} shadow-[2px_2px_0_#26384D]`
|
||||
: 'border-transparent',
|
||||
!module.enabled && 'cursor-not-allowed border-[#26384D]/20 bg-[#EEF3F7] text-[#8A98A8] opacity-70 hover:bg-[#EEF3F7]',
|
||||
)}
|
||||
>
|
||||
<span className={cn('flex h-6 w-6 shrink-0 items-center justify-center rounded-md border border-[#26384D]/30', tone.icon)}>
|
||||
<ModuleIcon className="h-3.5 w-3.5" strokeWidth={2.4} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
<span className="block truncate">{module.title}</span>
|
||||
{!module.enabled ? <span className="mt-0.5 block text-[10px] font-bold opacity-80">{module.subtitle}</span> : null}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user