Makelore 2.0 initial clean snapshot
This commit is contained in:
297
src/components/layout/ImageWorkspaceSidebar.tsx
Normal file
297
src/components/layout/ImageWorkspaceSidebar.tsx
Normal file
@@ -0,0 +1,297 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Bot, FolderKanban, Loader2, Plus, RefreshCw, X } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { IMAGE_WORKSPACE_CREATE_PROJECT_EVENT } from '@/lib/image-workspace';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useImageWorkspaceStore } from '@/stores/image-workspace';
|
||||
|
||||
type ImageWorkspaceSidebarProps = {
|
||||
sidebarCollapsed: boolean;
|
||||
};
|
||||
|
||||
export function ImageWorkspaceSidebar({ sidebarCollapsed }: ImageWorkspaceSidebarProps) {
|
||||
const navigate = useNavigate();
|
||||
const status = useImageWorkspaceStore((state) => state.status);
|
||||
const snapshot = useImageWorkspaceStore((state) => state.snapshot);
|
||||
const activeProjectId = useImageWorkspaceStore((state) => state.activeProjectId);
|
||||
const activeAgentIds = useImageWorkspaceStore((state) => state.activeAgentIds);
|
||||
const workspaceError = useImageWorkspaceStore((state) => state.error);
|
||||
const load = useImageWorkspaceStore((state) => state.load);
|
||||
const createProject = useImageWorkspaceStore((state) => state.createProject);
|
||||
const addAgent = useImageWorkspaceStore((state) => state.addAgent);
|
||||
const selectProject = useImageWorkspaceStore((state) => state.selectProject);
|
||||
const selectAgent = useImageWorkspaceStore((state) => state.selectAgent);
|
||||
const [projectsOpen, setProjectsOpen] = useState(true);
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||
const [projectName, setProjectName] = useState('');
|
||||
const [createError, setCreateError] = useState<string | null>(null);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [addingAgentProjectId, setAddingAgentProjectId] = useState<string | null>(null);
|
||||
const [agentError, setAgentError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (status === 'idle') void load();
|
||||
}, [load, status]);
|
||||
|
||||
useEffect(() => {
|
||||
const openCreateDialog = () => {
|
||||
setProjectName('');
|
||||
setCreateError(null);
|
||||
setCreateDialogOpen(true);
|
||||
};
|
||||
window.addEventListener(IMAGE_WORKSPACE_CREATE_PROJECT_EVENT, openCreateDialog);
|
||||
return () => window.removeEventListener(IMAGE_WORKSPACE_CREATE_PROJECT_EVENT, openCreateDialog);
|
||||
}, []);
|
||||
|
||||
const openCreateDialog = () => {
|
||||
setProjectName('');
|
||||
setCreateError(null);
|
||||
setCreateDialogOpen(true);
|
||||
};
|
||||
|
||||
const closeCreateDialog = () => {
|
||||
if (creating) return;
|
||||
setCreateDialogOpen(false);
|
||||
setCreateError(null);
|
||||
};
|
||||
|
||||
const handleCreateProject = async () => {
|
||||
const name = projectName.trim();
|
||||
if (!name) {
|
||||
setCreateError('请输入项目名称');
|
||||
return;
|
||||
}
|
||||
setCreating(true);
|
||||
setCreateError(null);
|
||||
try {
|
||||
await createProject(name);
|
||||
setCreateDialogOpen(false);
|
||||
navigate('/image-canvas');
|
||||
} catch (error) {
|
||||
setCreateError(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddAgent = async (projectId: string) => {
|
||||
setAddingAgentProjectId(projectId);
|
||||
setAgentError(null);
|
||||
try {
|
||||
await addAgent(projectId);
|
||||
} catch (error) {
|
||||
setAgentError(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
setAddingAgentProjectId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectProject = (projectId: string) => {
|
||||
selectProject(projectId);
|
||||
navigate('/image-canvas');
|
||||
};
|
||||
|
||||
return (
|
||||
<div data-testid="sidebar-image-workspace" className="min-h-0">
|
||||
<Button
|
||||
type="button"
|
||||
data-testid="sidebar-create-image-project"
|
||||
className={cn(
|
||||
'mb-2 w-full border-2 border-[#26384D] bg-[#E9EFF5] text-[#26384D] shadow-[3px_3px_0_#26384D] hover:bg-[#DCE6EF]',
|
||||
sidebarCollapsed && 'px-0',
|
||||
)}
|
||||
aria-label="新建项目"
|
||||
onClick={openCreateDialog}
|
||||
disabled={status !== 'ready'}
|
||||
>
|
||||
<Plus className={cn('h-4 w-4', !sidebarCollapsed && 'mr-2')} />
|
||||
{!sidebarCollapsed && '新建项目'}
|
||||
</Button>
|
||||
|
||||
<div className="mt-4 border-t-2 border-[#DCE6EF] pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setProjectsOpen((open) => !open)}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2.5 rounded-lg px-2.5 py-2 text-left text-sm font-black transition-colors hover:bg-[#FFFFFF]',
|
||||
sidebarCollapsed && 'justify-center px-0',
|
||||
)}
|
||||
>
|
||||
<FolderKanban className="h-[18px] w-[18px] shrink-0" />
|
||||
{!sidebarCollapsed ? (
|
||||
<>
|
||||
<span className="min-w-0 flex-1 truncate">项目</span>
|
||||
<span className="text-xs">{projectsOpen ? '收起' : '展开'}</span>
|
||||
</>
|
||||
) : null}
|
||||
</button>
|
||||
|
||||
{projectsOpen && !sidebarCollapsed ? (
|
||||
<div data-testid="sidebar-image-projects" className="mt-2 space-y-2">
|
||||
{status === 'idle' || status === 'loading' ? (
|
||||
<div className="flex items-center gap-2 rounded-lg border-2 border-dashed border-[#DCE6EF] bg-white/70 p-3 text-xs font-bold text-[#68788B]">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
正在读取创作空间
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{status === 'unavailable' || status === 'error' ? (
|
||||
<div
|
||||
data-testid="sidebar-image-workspace-unavailable"
|
||||
className="rounded-lg border-2 border-dashed border-[#26384D] bg-[#FFFFFF] p-3 text-xs font-bold text-[#68788B]"
|
||||
>
|
||||
<p className="font-black text-[#26384D]">创作空间暂不可用</p>
|
||||
{workspaceError ? <p className="mt-1 break-words">{workspaceError}</p> : null}
|
||||
<button
|
||||
type="button"
|
||||
className="mt-3 flex items-center gap-1.5 rounded-md border-2 border-[#26384D] bg-white px-2 py-1.5 font-black text-[#26384D]"
|
||||
onClick={() => void load()}
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{status === 'ready' && snapshot?.projects.length === 0 ? (
|
||||
<div className="rounded-lg border-2 border-dashed border-[#DCE6EF] bg-white/70 p-3 text-xs font-bold text-[#68788B]">
|
||||
暂无项目。新建项目后,创作空间会自动创建默认 Agent。
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{status === 'ready' ? snapshot?.projects.map((project) => {
|
||||
const active = project.id === activeProjectId;
|
||||
const selectedAgentId = activeAgentIds[project.id];
|
||||
return (
|
||||
<section
|
||||
key={project.id}
|
||||
data-testid={`sidebar-image-project-${project.id}`}
|
||||
className={cn(
|
||||
'rounded-lg border-2 p-3 text-left',
|
||||
active
|
||||
? 'border-[#26384D] bg-[#DCE6EF] shadow-[4px_4px_0_#26384D]'
|
||||
: 'border-[#DCE6EF] bg-[#FFFFFF]',
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 text-left"
|
||||
onClick={() => handleSelectProject(project.id)}
|
||||
>
|
||||
<FolderKanban className="h-4 w-4 shrink-0" />
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-black">{project.name}</span>
|
||||
</button>
|
||||
|
||||
<div className="mt-3 space-y-1.5" aria-label={`${project.name} 的 Agent`}>
|
||||
{project.agents.map((agent) => (
|
||||
<button
|
||||
key={agent.id}
|
||||
type="button"
|
||||
aria-pressed={active && selectedAgentId === agent.id}
|
||||
onClick={() => {
|
||||
selectProject(project.id);
|
||||
selectAgent(project.id, agent.id);
|
||||
navigate('/image-canvas');
|
||||
}}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 rounded-md border-2 border-[#26384D] px-2 py-1.5 text-left text-xs font-black',
|
||||
active && selectedAgentId === agent.id ? 'bg-white' : 'bg-white/60',
|
||||
)}
|
||||
>
|
||||
<span className="flex h-6 w-6 shrink-0 items-center justify-center overflow-hidden rounded-full border-2 border-[#26384D] bg-[#E9EFF5]">
|
||||
{agent.avatarUrl ? (
|
||||
<img src={agent.avatarUrl} alt="" className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<Bot className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate">{agent.name}</span>
|
||||
</button>
|
||||
))}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`为 ${project.name} 添加 Agent`}
|
||||
className="flex w-full items-center justify-center gap-1.5 rounded-md border-2 border-dashed border-[#26384D] bg-white/70 px-2 py-1.5 text-xs font-black"
|
||||
onClick={() => void handleAddAgent(project.id)}
|
||||
disabled={addingAgentProjectId === project.id}
|
||||
>
|
||||
{addingAgentProjectId === project.id
|
||||
? <Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
: <Plus className="h-3.5 w-3.5" />}
|
||||
添加 Agent
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}) : null}
|
||||
|
||||
{agentError ? <p role="alert" className="rounded-md bg-[#ffd6d6] p-2 text-xs font-black">{agentError}</p> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{createDialogOpen ? (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/55 px-4">
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="create-image-project-dialog-title"
|
||||
className="w-full max-w-sm rounded-lg border-4 border-[#26384D] bg-[#FFFFFF] p-4 text-[#26384D] shadow-[8px_8px_0_#26384D]"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 id="create-image-project-dialog-title" className="text-xl font-black">新建项目</h2>
|
||||
<p className="mt-1 text-xs font-bold text-[#68788B]">只需填写名称,创作空间会自动创建默认 Agent。</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="关闭新建项目"
|
||||
className="flex h-8 w-8 items-center justify-center rounded-md border-2 border-[#26384D] bg-white"
|
||||
onClick={closeCreateDialog}
|
||||
disabled={creating}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="mt-4 space-y-3"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void handleCreateProject();
|
||||
}}
|
||||
>
|
||||
<label className="block text-xs font-black" htmlFor="image-project-name">项目名称</label>
|
||||
<input
|
||||
id="image-project-name"
|
||||
value={projectName}
|
||||
onChange={(event) => {
|
||||
setProjectName(event.target.value);
|
||||
setCreateError(null);
|
||||
}}
|
||||
autoFocus
|
||||
disabled={creating}
|
||||
className="w-full rounded-md border-2 border-[#26384D] bg-white px-3 py-2 text-sm font-bold outline-none focus:ring-2 focus:ring-[#DCE6EF]"
|
||||
/>
|
||||
{createError ? <p role="alert" className="rounded-md bg-[#ffd6d6] p-2 text-xs font-black">{createError}</p> : null}
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button type="button" variant="outline" onClick={closeCreateDialog} disabled={creating}>取消</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={creating || !projectName.trim()}
|
||||
className="border-2 border-[#26384D] bg-[#DCE6EF] font-black text-[#26384D]"
|
||||
>
|
||||
{creating ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||||
创建项目
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
53
src/components/layout/MainLayout.tsx
Normal file
53
src/components/layout/MainLayout.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Main Layout Component
|
||||
* TitleBar at top, then sidebar + content below.
|
||||
*/
|
||||
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useEffect } from 'react';
|
||||
import { LockKeyhole, Settings2 } from 'lucide-react';
|
||||
import { Sidebar } from './Sidebar';
|
||||
import { TitleBar } from './TitleBar';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { getAiModuleForPath } from '@/lib/ai-modules';
|
||||
import { useOpencodeStore } from '@/stores/opencode';
|
||||
import { useProjectConfigStore } from '@/stores/project-config';
|
||||
|
||||
export function MainLayout() {
|
||||
const activeProject = useOpencodeStore((state) => state.activeProject);
|
||||
const config = useProjectConfigStore((state) => activeProject ? state.configsByProjectId[activeProject.id] : undefined);
|
||||
const load = useProjectConfigStore((state) => state.load);
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const isPaintingModule = getAiModuleForPath(location.pathname) === 'painting';
|
||||
const isInitializationSafeRoute = location.pathname === '/project-config' || isPaintingModule;
|
||||
|
||||
useEffect(() => {
|
||||
if (activeProject && !isPaintingModule) void load(activeProject.id).catch(() => undefined);
|
||||
}, [activeProject, isPaintingModule, load]);
|
||||
|
||||
const initializationBlocked = Boolean(activeProject && (!config || !config.initialized) && !isInitializationSafeRoute);
|
||||
return (
|
||||
<div data-testid="main-layout" className="flex h-screen flex-col overflow-hidden bg-background">
|
||||
{/* Title bar: drag region on macOS, icon + controls on Windows */}
|
||||
<TitleBar integrated />
|
||||
|
||||
{/* Below the title bar: sidebar + content */}
|
||||
<div className="flex min-h-0 flex-1 overflow-hidden">
|
||||
<Sidebar />
|
||||
<main data-testid="main-content" className="relative min-h-0 flex-1 overflow-auto p-6">
|
||||
<Outlet />
|
||||
{initializationBlocked ? (
|
||||
<div data-testid="project-initialization-gate" className="absolute inset-0 z-50 flex items-center justify-center bg-white/95 p-6">
|
||||
<div className="max-w-md rounded-2xl border-2 border-[#26384D] bg-[#FFFFFF] p-8 text-center shadow-[8px_8px_0_#26384D]">
|
||||
<LockKeyhole className="mx-auto h-10 w-10" />
|
||||
<h1 className="mt-4 text-2xl font-black">项目尚未初始化</h1>
|
||||
<p className="mt-3 text-sm font-bold text-[#68788B]">完成模板 Agent 名称确认后,聊天、任务和其他项目功能才会启用。</p>
|
||||
<Button onClick={() => navigate('/project-config')} className="mt-6 border-2 border-[#26384D] bg-[#3A5578] font-black text-white shadow-[4px_4px_0_#26384D]"><Settings2 className="mr-2 h-4 w-4" />打开项目配置</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
1208
src/components/layout/Sidebar.tsx
Normal file
1208
src/components/layout/Sidebar.tsx
Normal file
File diff suppressed because it is too large
Load Diff
88
src/components/layout/SidebarUpdateButton.tsx
Normal file
88
src/components/layout/SidebarUpdateButton.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Download, Loader2, RotateCcw } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useUpdateStore } from '@/stores/update';
|
||||
|
||||
const ACTIONABLE_STATUSES = new Set(['available', 'downloading', 'downloaded']);
|
||||
|
||||
function iconState(active: boolean): string {
|
||||
return active
|
||||
? 'scale-100 opacity-100 blur-0'
|
||||
: 'scale-25 opacity-0 blur-[4px]';
|
||||
}
|
||||
|
||||
export function SidebarUpdateButton({ collapsed }: { collapsed: boolean }) {
|
||||
const status = useUpdateStore((state) => state.status);
|
||||
const version = useUpdateStore((state) => state.updateInfo?.version);
|
||||
const init = useUpdateStore((state) => state.init);
|
||||
const downloadUpdate = useUpdateStore((state) => state.downloadUpdate);
|
||||
const installUpdate = useUpdateStore((state) => state.installUpdate);
|
||||
|
||||
useEffect(() => {
|
||||
void init();
|
||||
}, [init]);
|
||||
|
||||
if (!ACTIONABLE_STATUSES.has(status)) return null;
|
||||
|
||||
const downloaded = status === 'downloaded';
|
||||
const downloading = status === 'downloading';
|
||||
const label = downloaded
|
||||
? `重启并安装新版本 ${version ?? ''}`.trim()
|
||||
: downloading
|
||||
? `正在下载新版本 ${version ?? ''}`.trim()
|
||||
: `下载新版本 ${version ?? ''}`.trim();
|
||||
|
||||
const handleClick = async () => {
|
||||
if (downloaded) {
|
||||
installUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
await downloadUpdate();
|
||||
const state = useUpdateStore.getState();
|
||||
if (state.status === 'error') {
|
||||
toast.error('更新下载失败', {
|
||||
description: (state.error ?? '请前往设置查看详情并重试').slice(0, 160),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="sidebar-update-button"
|
||||
aria-label={label}
|
||||
title={label}
|
||||
disabled={downloading}
|
||||
onClick={() => void handleClick()}
|
||||
className={cn(
|
||||
'flex h-10 w-10 shrink-0 items-center justify-center rounded-full border-2 border-[#26384D] bg-[#3b9cff] text-white shadow-[2px_2px_0_#26384D] transition-[transform,background-color,box-shadow,opacity] duration-200 hover:bg-[#237ed8] active:scale-[0.96] disabled:cursor-wait disabled:opacity-80',
|
||||
collapsed && 'shadow-none',
|
||||
)}
|
||||
>
|
||||
<span className="relative h-4 w-4" aria-hidden="true">
|
||||
<Download
|
||||
className={cn(
|
||||
'absolute inset-0 h-4 w-4 transition-[transform,opacity,filter] duration-300 [transition-timing-function:cubic-bezier(0.2,0,0,1)]',
|
||||
iconState(status === 'available'),
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
'absolute inset-0 transition-[transform,opacity,filter] duration-300 [transition-timing-function:cubic-bezier(0.2,0,0,1)]',
|
||||
iconState(downloading),
|
||||
)}
|
||||
>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
</span>
|
||||
<RotateCcw
|
||||
className={cn(
|
||||
'absolute inset-0 h-4 w-4 transition-[transform,opacity,filter] duration-300 [transition-timing-function:cubic-bezier(0.2,0,0,1)]',
|
||||
iconState(downloaded),
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
108
src/components/layout/TitleBar.tsx
Normal file
108
src/components/layout/TitleBar.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* TitleBar Component
|
||||
* macOS: empty drag region (native traffic lights handled by hiddenInset).
|
||||
* Windows: drag region with custom minimize/maximize/close controls.
|
||||
* Linux: use native window chrome (no custom title bar).
|
||||
*/
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Minus, Square, X, Copy } from 'lucide-react';
|
||||
import { invokeIpc } from '@/lib/api-client';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useSettingsStore } from '@/stores/settings';
|
||||
|
||||
export function TitleBar({ integrated = false }: { integrated?: boolean }) {
|
||||
const platform = window.electron?.platform;
|
||||
|
||||
if (platform === 'darwin') {
|
||||
return <ProductTitleBar integrated={integrated} />;
|
||||
}
|
||||
|
||||
// Linux keeps the native frame/title bar for better IME compatibility.
|
||||
if (platform !== 'win32') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <WindowsTitleBar integrated={integrated} />;
|
||||
}
|
||||
|
||||
function ProductTitleBar({ integrated, children }: { integrated: boolean; children?: React.ReactNode }) {
|
||||
const sidebarCollapsed = useSettingsStore((state) => state.sidebarCollapsed);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="product-titlebar"
|
||||
className={cn(
|
||||
'drag-region flex h-10 shrink-0 bg-[#FFFFFF] text-[#26384D]',
|
||||
integrated && 'bg-[#FFFFFF]',
|
||||
)}
|
||||
>
|
||||
{integrated ? (
|
||||
<div
|
||||
data-testid="titlebar-sidebar-surface"
|
||||
className={cn(
|
||||
'h-full shrink-0 border-r-4 border-[#26384D] bg-[#F6F8FA] transition-[width] duration-300',
|
||||
sidebarCollapsed ? 'w-16' : 'w-72',
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
<div className="flex min-w-0 flex-1 items-center justify-end bg-[#FFFFFF]">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WindowsTitleBar({ integrated }: { integrated: boolean }) {
|
||||
const [maximized, setMaximized] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Check initial state
|
||||
invokeIpc('window:isMaximized').then((val) => {
|
||||
setMaximized(val as boolean);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleMinimize = () => {
|
||||
invokeIpc('window:minimize');
|
||||
};
|
||||
|
||||
const handleMaximize = () => {
|
||||
invokeIpc('window:maximize').then(() => {
|
||||
invokeIpc('window:isMaximized').then((val) => {
|
||||
setMaximized(val as boolean);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
invokeIpc('window:close');
|
||||
};
|
||||
|
||||
return (
|
||||
<ProductTitleBar integrated={integrated}>
|
||||
<div className="no-drag flex h-full">
|
||||
<button
|
||||
onClick={handleMinimize}
|
||||
className="flex h-full w-11 items-center justify-center text-muted-foreground hover:bg-accent transition-colors"
|
||||
title="Minimize"
|
||||
>
|
||||
<Minus className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleMaximize}
|
||||
className="flex h-full w-11 items-center justify-center text-muted-foreground hover:bg-accent transition-colors"
|
||||
title={maximized ? 'Restore' : 'Maximize'}
|
||||
>
|
||||
{maximized ? <Copy className="h-3.5 w-3.5" /> : <Square className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="flex h-full w-11 items-center justify-center text-muted-foreground hover:bg-red-500 hover:text-white transition-colors"
|
||||
title="Close"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</ProductTitleBar>
|
||||
);
|
||||
}
|
||||
48
src/components/layout/session-buckets.ts
Normal file
48
src/components/layout/session-buckets.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { ChatSession } from '@/types/chat';
|
||||
|
||||
export type SessionBucketKey =
|
||||
| 'today'
|
||||
| 'yesterday'
|
||||
| 'withinWeek'
|
||||
| 'withinTwoWeeks'
|
||||
| 'withinMonth'
|
||||
| 'older';
|
||||
|
||||
export function getSessionBucket(activityMs: number, nowMs: number): SessionBucketKey {
|
||||
if (!activityMs || activityMs <= 0) return 'older';
|
||||
|
||||
const now = new Date(nowMs);
|
||||
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
|
||||
const startOfYesterday = startOfToday - 24 * 60 * 60 * 1000;
|
||||
|
||||
if (activityMs >= startOfToday) return 'today';
|
||||
if (activityMs >= startOfYesterday) return 'yesterday';
|
||||
|
||||
const daysAgo = (startOfToday - activityMs) / (24 * 60 * 60 * 1000);
|
||||
if (daysAgo <= 7) return 'withinWeek';
|
||||
if (daysAgo <= 14) return 'withinTwoWeeks';
|
||||
if (daysAgo <= 30) return 'withinMonth';
|
||||
return 'older';
|
||||
}
|
||||
|
||||
function getSessionCreatedAtMsFromKey(sessionKey: string): number | undefined {
|
||||
const match = sessionKey.match(/(?:^|:)session-(\d{11,})(?=$|:)/);
|
||||
if (!match) return undefined;
|
||||
|
||||
const createdAtMs = Number(match[1]);
|
||||
return Number.isFinite(createdAtMs) && createdAtMs > 0 ? createdAtMs : undefined;
|
||||
}
|
||||
|
||||
export function getSessionActivityMs(
|
||||
session: ChatSession,
|
||||
sessionLastActivity: Record<string, number>,
|
||||
): number {
|
||||
const lastActivityMs = sessionLastActivity[session.key];
|
||||
if (Number.isFinite(lastActivityMs) && lastActivityMs > 0) return lastActivityMs;
|
||||
|
||||
if (typeof session.updatedAt === 'number' && Number.isFinite(session.updatedAt) && session.updatedAt > 0) {
|
||||
return session.updatedAt;
|
||||
}
|
||||
|
||||
return getSessionCreatedAtMsFromKey(session.key) ?? 0;
|
||||
}
|
||||
Reference in New Issue
Block a user