diff --git a/design-research-folder-card.md b/design-research-folder-card.md new file mode 100644 index 000000000..83509d30f --- /dev/null +++ b/design-research-folder-card.md @@ -0,0 +1,655 @@ +# 画布列表卡片设计调研报告 + +## 一、图片风格分析 + +### 核心设计元素识别 + +通过对提供的文件夹卡片图片分析,识别出以下关键设计特征: + +#### 1. **玻璃拟态(Glassmorphism)效果** +- 半透明背景:使用 `backdrop-filter: blur()` 实现背景模糊 +- 磨砂玻璃质感:白色半透明叠加层(rgba(255,255,255,0.1) ~ 0.15) +- 边框高光:顶部和左侧有微妙的白色边框(1px solid rgba(255,255,255,0.18)) + +#### 2. **渐变网格背景(Gradient Mesh)** +- 每个卡片顶部有独特的渐变色带(红橙、蓝青、橙红等色系) +- 渐变呈现流动的、非线性的网格状分布 +- 类似 macOS Ventura 的壁纸风格,具有深度和立体感 +- 渐变从顶部向下过渡到白色/透明 + +#### 3. **多层阴影系统** +- **主阴影**:较大的扩散阴影 `0 8px 32px rgba(0,0,0,0.12)` +- **环境阴影**:模拟物理深度 `0 2px 8px rgba(0,0,0,0.06)` +- **内阴影**:卡片内部微妙的阴影增强立体感 +- 悬停时阴影加深并向上提升:`transform: translateY(-4px)` + +#### 4. **圆角处理** +- 大圆角设计:约 `16px - 20px` (r-2xl ~ r-3xl) +- 所有元素保持一致的圆角系统 +- 内部按钮使用更小的圆角(8-12px) + +#### 5. **布局结构** +- **三态展示**: + 1. 正常态(左上):紧凑文件夹样式 + 悬浮按钮 + 2. 堆叠态(右侧):多层卡片堆叠效果,展示深度 + 3. 展开态(左下):完整信息展示 + 时间戳 + +#### 6. **交互反馈** +- 悬停提升效果(Y轴 -4px) +- 鼠标跟随的光泽效果(Glare) +- 3D 倾斜动画(轻微的 rotateX/rotateY) +- 按钮悬浮显示/隐藏 + +--- + +## 二、当前项目状态分析 + +### 现有组件架构 + +#### 1. **CanvasProjectCard** (`canvas-project-card.tsx`) +```typescript +// 当前结构 +
+
+ + 复选框 +
节点数量
+
+
+

标题

+
时间/类型
+
+
+``` + +**当前特点**: +- 扁平化设计,缺少层次感 +- 简单的边框和阴影 +- 预览区域占比大(aspect-video) +- 无玻璃拟态效果 +- 无渐变背景装饰 + +#### 2. **CometCard** (`comet-card.tsx`) +**已有能力**: +- ✅ 3D 倾斜动画(rotateX/rotateY) +- ✅ 鼠标跟随效果 +- ✅ 光泽层(glare) +- ✅ 悬停提升动画 +- ✅ 平滑的弹簧动画 + +**缺少**: +- ❌ 玻璃拟态背景 +- ❌ 渐变网格装饰 +- ❌ 多层阴影系统 + +--- + +## 三、Aceternity UI 调研 + +### 相关组件清单 + +#### 1. **3D Card Effect** +- 组件路径:`/components/3d-card-effect` +- 功能:鼠标跟随的3D倾斜效果 +- 与 CometCard 功能重叠,我们已有类似实现 + +#### 2. **Card Hover Effect** +- 组件路径:`/components/card-hover-effect` +- 功能:悬停时的边框光晕和背景渐变 +- 可借鉴:边框渐变的实现方式 + +#### 3. **Background Gradient** +- 组件路径:`/components/background-gradient` +- 功能:动态渐变背景 +- **重要**:可用于卡片顶部的渐变网格效果 + +#### 4. **Meteors** +- 组件路径:`/components/meteors` +- 功能:流星划过的动画效果 +- 可选:为卡片增加动态装饰 + +#### 5. **Glass Morphism** +- 虽然 Aceternity 没有专门的玻璃拟态组件 +- 但在多个卡片组件中使用了 `backdrop-blur` 和半透明背景 +- 需要自行实现 + +--- + +## 四、设计方案 + +### 方案 A:渐进式升级(推荐) + +**目标**:在现有 CanvasProjectCard 基础上,逐步添加玻璃拟态和渐变效果。 + +#### 4.1 布局设计 + +**网格布局**: +```css +/* 响应式网格 */ +.canvas-library-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: var(--space-6); /* 24px */ + padding: var(--space-6); +} + +/* 断点优化 */ +@media (min-width: 640px) { + /* 2列:640px - 1024px */ + grid-template-columns: repeat(2, 1fr); +} + +@media (min-width: 1024px) { + /* 3列:1024px - 1440px */ + grid-template-columns: repeat(3, 1fr); +} + +@media (min-width: 1440px) { + /* 4列:>1440px */ + grid-template-columns: repeat(4, 1fr); +} +``` + +**每行显示数量**: +- 移动端(<640px):1列(自适应宽度) +- 平板(640-1024px):2列 +- 桌面(1024-1440px):3列 +- 大屏(>1440px):4列 + +#### 4.2 卡片尺寸 + +```typescript +// 卡片结构 +
+ // 宽高比:16:10 (比现有的16:9更接近文件夹比例) + // 最小宽度:280px + // 最大宽度:360px(防止在大屏上过度拉伸) +
+``` + +**尺寸规范**: +- **宽度**:280-360px(由网格自动控制) +- **高度**:自适应(由内容决定) +- **预览区高度**:aspect-[16/10] (~175-225px) +- **内容区高度**:~80px(标题 + 元信息) + +#### 4.3 视觉效果分层 + +``` +[层级从底到顶] +├─ 1. 基础背景(白色/暗色,根据主题) +├─ 2. 渐变网格层(顶部装饰) +├─ 3. 玻璃拟态层(backdrop-blur + 半透明) +├─ 4. 内容层(文字、图标、预览) +├─ 5. 边框高光层(顶部和左侧) +└─ 6. 光泽层(hover时的glare效果) +``` + +--- + +## 五、CSS 实现方案 + +### 5.1 玻璃拟态基础样式 + +```css +.canvas-card-glass { + position: relative; + background: rgba(255, 255, 255, 0.08); + backdrop-filter: blur(12px) saturate(180%); + border: 1px solid rgba(255, 255, 255, 0.18); + border-radius: var(--r-2xl); /* 16px */ + box-shadow: + 0 8px 32px rgba(0, 0, 0, 0.12), + 0 2px 8px rgba(0, 0, 0, 0.06), + inset 0 1px 0 rgba(255, 255, 255, 0.1); + transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1), + box-shadow 0.3s ease; +} + +/* 暗色主题 */ +.dark .canvas-card-glass { + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.12); + box-shadow: + 0 8px 32px rgba(0, 0, 0, 0.4), + 0 2px 8px rgba(0, 0, 0, 0.2), + inset 0 1px 0 rgba(255, 255, 255, 0.08); +} + +.canvas-card-glass:hover { + transform: translateY(-4px); + box-shadow: + 0 16px 48px rgba(0, 0, 0, 0.18), + 0 4px 12px rgba(0, 0, 0, 0.1), + inset 0 1px 0 rgba(255, 255, 255, 0.15); +} +``` + +### 5.2 渐变网格装饰 + +```css +.canvas-card-gradient-mesh { + position: absolute; + top: 0; + left: 0; + right: 0; + height: 120px; + border-radius: var(--r-2xl) var(--r-2xl) 0 0; + overflow: hidden; + pointer-events: none; + z-index: 0; +} + +.canvas-card-gradient-mesh::before { + content: ''; + position: absolute; + inset: 0; + background: + radial-gradient(circle at 20% 30%, rgba(255, 100, 80, 0.6) 0%, transparent 50%), + radial-gradient(circle at 80% 20%, rgba(100, 180, 255, 0.5) 0%, transparent 50%), + radial-gradient(circle at 50% 70%, rgba(255, 180, 100, 0.4) 0%, transparent 60%); + filter: blur(40px); + opacity: 0.8; +} + +.canvas-card-gradient-mesh::after { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(180deg, + rgba(255,255,255,0) 0%, + rgba(255,255,255,0.95) 80% + ); +} + +/* 每张卡片随机不同的渐变色 */ +.canvas-card-gradient-mesh[data-variant="1"]::before { + background: + radial-gradient(circle at 25% 25%, rgba(255, 50, 100, 0.7) 0%, transparent 50%), + radial-gradient(circle at 75% 30%, rgba(80, 120, 255, 0.6) 0%, transparent 50%); +} + +.canvas-card-gradient-mesh[data-variant="2"]::before { + background: + radial-gradient(circle at 30% 20%, rgba(0, 200, 180, 0.6) 0%, transparent 50%), + radial-gradient(circle at 70% 40%, rgba(100, 255, 150, 0.5) 0%, transparent 50%); +} + +.canvas-card-gradient-mesh[data-variant="3"]::before { + background: + radial-gradient(circle at 20% 35%, rgba(255, 150, 0, 0.7) 0%, transparent 50%), + radial-gradient(circle at 80% 25%, rgba(200, 50, 80, 0.6) 0%, transparent 50%); +} +``` + +### 5.3 完整卡片样式 + +```css +.canvas-card-folder { + position: relative; + width: 100%; + height: 100%; + min-height: 280px; + cursor: pointer; + isolation: isolate; +} + +.canvas-card-folder-inner { + position: relative; + width: 100%; + height: 100%; + background: rgba(255, 255, 255, 0.08); + backdrop-filter: blur(12px) saturate(180%); + border: 1px solid rgba(255, 255, 255, 0.18); + border-radius: var(--r-2xl); + overflow: hidden; + box-shadow: + 0 8px 32px rgba(0, 0, 0, 0.12), + 0 2px 8px rgba(0, 0, 0, 0.06); + transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); +} + +.canvas-card-folder:hover .canvas-card-folder-inner { + transform: translateY(-4px) scale(1.01); + box-shadow: + 0 16px 48px rgba(0, 0, 0, 0.18), + 0 4px 12px rgba(0, 0, 0, 0.1); + border-color: rgba(255, 255, 255, 0.25); +} + +/* 预览区域 */ +.canvas-card-preview { + position: relative; + width: 100%; + aspect-ratio: 16 / 10; + overflow: hidden; + z-index: 1; +} + +.canvas-card-preview img, +.canvas-card-preview video { + width: 100%; + height: 100%; + object-fit: cover; + transition: transform 0.3s ease; +} + +.canvas-card-folder:hover .canvas-card-preview img, +.canvas-card-folder:hover .canvas-card-preview video { + transform: scale(1.05); +} + +/* 内容区域 */ +.canvas-card-content { + position: relative; + padding: var(--space-4); + z-index: 1; +} + +.canvas-card-title { + font-size: var(--fs-body); + font-weight: 600; + line-height: 1.4; + color: var(--color-neutral-900); + margin-bottom: var(--space-2); + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.dark .canvas-card-title { + color: var(--color-neutral-50); +} + +.canvas-card-meta { + display: flex; + align-items: center; + gap: var(--space-2); + font-size: var(--fs-caption); + color: var(--color-neutral-600); +} + +.dark .canvas-card-meta { + color: var(--color-neutral-400); +} + +/* 悬浮按钮 */ +.canvas-card-action-button { + position: absolute; + bottom: var(--space-3); + right: var(--space-3); + width: 40px; + height: 40px; + display: grid; + place-items: center; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(8px); + border-radius: var(--r-lg); + color: white; + opacity: 0; + transform: translateY(8px); + transition: all 0.25s ease; + z-index: 10; +} + +.canvas-card-folder:hover .canvas-card-action-button { + opacity: 1; + transform: translateY(0); +} + +.canvas-card-action-button:hover { + background: rgba(0, 0, 0, 0.8); + transform: scale(1.1); +} +``` + +--- + +## 六、组件实现方案 + +### 6.1 新建组件:CanvasFolderCard + +```tsx +// web/src/components/canvas/canvas-folder-card.tsx + +import { motion } from "motion/react"; +import { Plus, MoreHorizontal } from "lucide-react"; +import { CometCard } from "@/components/ui/aceternity/comet-card"; +import type { CanvasProject } from "@/stores/canvas/use-canvas-store"; + +interface CanvasFolderCardProps { + project: CanvasProject; + variant?: number; // 1-3,决定渐变色方案 + onClick?: () => void; + onAction?: () => void; +} + +export function CanvasFolderCard({ + project, + variant = 1, + onClick, + onAction +}: CanvasFolderCardProps) { + return ( + +
+ {/* 渐变网格装饰 */} + + + ); +} + +// 预览组件保持原有逻辑 +function ProjectPreview({ project }: { project: CanvasProject }) { + // ... 复用现有的 ProjectPreview 逻辑 +} + +function formatTime(value: string) { + // ... 复用现有的时间格式化逻辑 +} +``` + +### 6.2 集成到现有页面 + +```tsx +// 在 pages/home/index.tsx 或 pages/canvas/index.tsx 中 + +import { CanvasFolderCard } from "@/components/canvas/canvas-folder-card"; + +// 替换现有的 CanvasProjectCard +
+ {canvasProjects.map((project, index) => ( + navigate(`/canvas/${project.id}`)} + onAction={() => handleMore(project)} + /> + ))} +
+``` + +--- + +## 七、技术要点 + +### 7.1 性能优化 + +```typescript +// 1. 渐变色方案预计算 +const GRADIENT_VARIANTS = [ + { id: 1, colors: ['rgba(255,50,100,0.7)', 'rgba(80,120,255,0.6)'] }, + { id: 2, colors: ['rgba(0,200,180,0.6)', 'rgba(100,255,150,0.5)'] }, + { id: 3, colors: ['rgba(255,150,0,0.7)', 'rgba(200,50,80,0.6)'] }, +]; + +// 2. 使用 CSS 变量动态注入 +
+ +// 3. backdrop-filter 回退方案 +@supports not (backdrop-filter: blur(12px)) { + .canvas-card-folder-inner { + background: rgba(255, 255, 255, 0.95); /* 完全不透明 */ + } +} +``` + +### 7.2 无障碍支持 + +```tsx +// 完整的 ARIA 标签 +
e.key === 'Enter' && onClick?.()} +> + {/* ... */} +
+``` + +### 7.3 暗色主题适配 + +```css +/* 自动适配当前主题 */ +@media (prefers-color-scheme: dark) { + .canvas-card-folder-inner { + background: rgba(255, 255, 255, 0.05); + border-color: rgba(255, 255, 255, 0.12); + } + + .canvas-card-gradient-mesh::after { + background: linear-gradient(180deg, + rgba(10, 10, 15, 0) 0%, + rgba(10, 10, 15, 0.95) 80% + ); + } +} +``` + +--- + +## 八、实施建议 + +### 阶段 1:基础重构(1-2天) +1. ✅ 创建 `CanvasFolderCard` 组件 +2. ✅ 实现玻璃拟态样式 +3. ✅ 集成 `CometCard` 实现3D效果 +4. ✅ 调整网格布局为新的响应式方案 + +### 阶段 2:视觉增强(2-3天) +1. ✅ 添加渐变网格装饰层 +2. ✅ 实现多层阴影系统 +3. ✅ 优化悬停动画和过渡 +4. ✅ 添加光泽层(glare effect) + +### 阶段 3:细节打磨(1-2天) +1. ✅ 暗色主题适配 +2. ✅ 无障碍测试和优化 +3. ✅ 性能测试(backdrop-filter 的性能影响) +4. ✅ 跨浏览器兼容性测试 + +### 阶段 4:用户测试(1天) +1. ✅ A/B 测试(新旧卡片对比) +2. ✅ 收集用户反馈 +3. ✅ 根据反馈微调 + +--- + +## 九、风险和注意事项 + +### 9.1 性能风险 +- **backdrop-filter 性能消耗**:在大量卡片(50+)时可能影响滚动性能 + - 解决方案:使用虚拟滚动或懒加载 + - 回退方案:低性能设备禁用 backdrop-filter + +### 9.2 兼容性 +- **Safari < 15**:backdrop-filter 支持不完整 + - 解决方案:检测支持度,提供回退样式 + +### 9.3 可访问性 +- 确保玻璃拟态效果不影响文字对比度(WCAG AA 标准:4.5:1) +- 光泽效果不应干扰屏幕阅读器 + +--- + +## 十、参考资源 + +### 设计灵感 +- macOS Ventura 文件夹设计 +- iOS 16+ 小组件玻璃拟态 +- Figma Community: Glassmorphism UI Kit + +### 技术文档 +- [MDN: backdrop-filter](https://developer.mozilla.org/en-US/docs/Web/CSS/backdrop-filter) +- [CSS Tricks: Glassmorphism](https://css-tricks.com/glassmorphism/) +- [Aceternity UI Components](https://ui.aceternity.com/components) + +### 现有代码 +- `/web/src/components/canvas/canvas-project-card.tsx` +- `/web/src/components/ui/aceternity/comet-card.tsx` +- `/web/src/styles/globals.css` (token system) + +--- + +## 总结 + +这套设计方案结合了: +1. **玻璃拟态**的现代感和层次感 +2. **渐变网格**的视觉吸引力和品牌差异化 +3. **3D交互**的沉浸感(已有CometCard支持) +4. **响应式布局**的灵活性(1-4列自适应) + +建议采用**渐进式升级**策略,先实现核心玻璃拟态效果,再逐步添加渐变装饰和高级交互,确保每个阶段都能交付可用版本。 diff --git a/web/src/components/canvas/canvas-folder-card.tsx b/web/src/components/canvas/canvas-folder-card.tsx new file mode 100644 index 000000000..e8d9b55a0 --- /dev/null +++ b/web/src/components/canvas/canvas-folder-card.tsx @@ -0,0 +1,129 @@ +import { Dropdown, Input } from "antd"; +import { Download, MoreHorizontal, Pencil, Trash2 } from "lucide-react"; +import type { KeyboardEvent } from "react"; + +import { ProjectPreview, formatProjectTime } from "@/components/canvas/canvas-project-card"; +import { exportCanvasProjects } from "@/lib/canvas/canvas-export"; +import { useCanvasStore, type CanvasProject } from "@/stores/canvas/use-canvas-store"; +import { useCanvasUiStore } from "@/stores/canvas/use-canvas-ui-store"; +import { cn } from "@/lib/utils"; + +type CanvasFolderCardProps = { + project: CanvasProject; + projectName?: string; + onClick: () => void; +}; + +/** 画布库中的文件夹封面:单一卡片表面承载预览和信息,避免相邻卡片互相侵入。 */ +export function CanvasFolderCard({ project, projectName, onClick }: CanvasFolderCardProps) { + const renameProject = useCanvasStore((state) => state.renameProject); + const selectedIds = useCanvasUiStore((state) => state.selectedProjectIds); + const editingId = useCanvasUiStore((state) => state.editingProjectId); + const editingTitle = useCanvasUiStore((state) => state.editingProjectTitle); + const startEditing = useCanvasUiStore((state) => state.startEditingProject); + const setEditingTitle = useCanvasUiStore((state) => state.setEditingProjectTitle); + const stopEditing = useCanvasUiStore((state) => state.stopEditingProject); + const toggleSelected = useCanvasUiStore((state) => state.toggleSelectedProjectId); + const setDeleteIds = useCanvasUiStore((state) => state.setDeleteProjectIds); + const editing = editingId === project.id; + const selected = selectedIds.includes(project.id); + + const saveTitle = () => { + renameProject(project.id, editingTitle); + stopEditing(); + }; + + const handleOpenKeyDown = (event: KeyboardEvent) => { + if (event.target !== event.currentTarget) return; + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + if (!editing) onClick(); + } + }; + + return ( +
+
!editing && onClick()} onKeyDown={handleOpenKeyDown}> + +
+
+ {editing ? ( + setEditingTitle(event.target.value)} + onClick={(event) => event.stopPropagation()} + onBlur={saveTitle} + onKeyDown={(event) => { + if (event.key === "Enter") saveTitle(); + if (event.key === "Escape") stopEditing(); + }} + autoFocus + /> + ) : ( + {project.title} + )} +
+
+ {projectName || "自由画布"} + + {project.nodes.length} 节点 + + +
+
+
+ + event.stopPropagation()}> + toggleSelected(project.id, event.target.checked)} + aria-label={`选择 ${project.title}`} + /> + + +
event.stopPropagation()}> + {!editing ? ( + + ) : null} + + domEvent.stopPropagation(), + items: [ + { key: "export", icon: , label: "导出画布", onClick: () => void exportCanvasProjects([project], project.title || "影策画布") }, + { type: "divider" }, + { key: "delete", danger: true, icon: , label: "删除", onClick: () => setDeleteIds([project.id]) }, + ], + }} + > + + +
+
+ ); +} diff --git a/web/src/components/canvas/canvas-project-card.tsx b/web/src/components/canvas/canvas-project-card.tsx index 18066aba0..87a70d28a 100644 --- a/web/src/components/canvas/canvas-project-card.tsx +++ b/web/src/components/canvas/canvas-project-card.tsx @@ -105,14 +105,17 @@ export function CanvasProjectCard({ project, projectName, variant = "library", r ); } -function ProjectPreview({ project }: { project: CanvasProject }) { +export function ProjectPreview({ project, preferLatestImage = false }: { project: CanvasProject; preferLatestImage?: boolean }) { const mediaNodes = project.nodes .flatMap((node) => { if (node.type !== CanvasNodeType.Image && node.type !== CanvasNodeType.Video) return []; const url = getNodeMediaUrl(node); return isPreviewUrl(url) ? [{ node, url, storageKey: node.metadata?.storageKey }] : []; }); - const media = mediaNodes.find(({ node }) => node.type === CanvasNodeType.Image) || mediaNodes[0]; + const imageNodes = mediaNodes.filter(({ node }) => node.type === CanvasNodeType.Image); + const media = preferLatestImage + ? imageNodes[imageNodes.length - 1] || mediaNodes[mediaNodes.length - 1] + : imageNodes[0] || mediaNodes[0]; if (media) { const { node, url, storageKey } = media; return ( @@ -145,7 +148,7 @@ function ProjectPreview({ project }: { project: CanvasProject }) { function getNodeMediaUrl(node: CanvasNodeData) { const resourceId = resourceIdFromStorageKey(node.metadata?.storageKey); if (resourceId) return resourceFileUrl(resourceId); - return resolveBackendApiUrl(node.metadata?.content || ""); + return resolveBackendApiUrl(node.metadata?.previewContent || node.metadata?.content || ""); } function buildNodePreviewLayout(nodes: CanvasNodeData[]) { @@ -191,7 +194,7 @@ function isPreviewUrl(value?: string) { return Boolean(value && (/^(https?:|blob:|data:image\/|data:video\/|\/api\/)/.test(value))); } -function formatProjectTime(value: string) { +export function formatProjectTime(value: string) { const timestamp = new Date(value).getTime(); if (!Number.isFinite(timestamp)) return "刚刚修改"; const elapsed = Math.max(0, Date.now() - timestamp); diff --git a/web/src/components/layout/app-top-nav.tsx b/web/src/components/layout/app-top-nav.tsx index 158f434e2..edb0e3a7a 100644 --- a/web/src/components/layout/app-top-nav.tsx +++ b/web/src/components/layout/app-top-nav.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, type CSSProperties, type ReactNode } from "react"; +import { useEffect, useState, type ReactNode } from "react"; import { useLocation, useNavigate } from "react-router"; import { ModelSetupGuide } from "@/components/layout/model-setup-guide"; @@ -84,9 +84,13 @@ export function AppWorkspaceShell({ children }: { children: ReactNode }) { mobileSidebarExpanded && "is-mobile-expanded", desktopSidebarCollapsed && "is-collapsed", )} - style={desktopSidebarCollapsed ? ({ "--workspace-sidebar-width": "0px" } as CSSProperties) : undefined} > - setPaletteOpen(true)} /> + setPaletteOpen(true)} + onExpand={() => setDesktopSidebarCollapsed(false)} + /> ) : null} diff --git a/web/src/components/layout/workspace-account-menu.tsx b/web/src/components/layout/workspace-account-menu.tsx index 8e6863f7d..19fc30b1d 100644 --- a/web/src/components/layout/workspace-account-menu.tsx +++ b/web/src/components/layout/workspace-account-menu.tsx @@ -1,17 +1,16 @@ import { Popover, Switch } from "antd"; -import { ChevronRight, CircleUserRound, LogIn, LogOut, Moon, ShieldCheck, Sun } from "lucide-react"; -import { useEffect, useState, type ReactNode } from "react"; +import { CircleUserRound, LogIn, Moon, Sun } from "lucide-react"; +import { useEffect, useState } from "react"; import { Link } from "react-router"; import { AppChangelogButton } from "@/components/layout/app-changelog-modal"; import { IdentityProviderBadge } from "@/components/layout/identity-provider-badge"; import { useWalletBalance } from "@/hooks/use-wallet-balance"; -import { useWorkspaceLogout } from "@/hooks/use-workspace-logout"; import { cn } from "@/lib/utils"; import { useThemeStore } from "@/stores/use-theme-store"; import { useUserStore, type LocalUser } from "@/stores/use-user-store"; -/** 顶部栏头像账户菜单:账户信息、管理入口、版本、深色模式与退出登录。 */ +/** 顶部栏头像账户菜单:只承载账户信息、版本和显示偏好;管理与退出入口统一放在侧栏。 */ export function WorkspaceAccountMenu() { const theme = useThemeStore((state) => state.theme); const setTheme = useThemeStore((state) => state.setTheme); @@ -19,7 +18,6 @@ export function WorkspaceAccountMenu() { const hydrated = useUserStore((state) => state.hydrated); const creditsEnabled = useUserStore((state) => state.features.creditsEnabled); const { availableMicrocredits } = useWalletBalance(user?.id, creditsEnabled); - const { handleLogout } = useWorkspaceLogout(); const [menuOpen, setMenuOpen] = useState(false); const balance = availableMicrocredits === null @@ -34,11 +32,12 @@ export function WorkspaceAccountMenu() { -
+
{user.displayName || user.username}
@@ -46,13 +45,7 @@ export function WorkspaceAccountMenu() {
- {user.role === "admin" ? ( - - ) : null} - -
+
@@ -61,14 +54,6 @@ export function WorkspaceAccountMenu() { 深色模式 setTheme(checked ? "dark" : "light")} aria-label="深色模式" />
- -
)} > @@ -83,10 +68,6 @@ export function WorkspaceAccountMenu() { ); } -function MenuLink({ to, icon, label, onNavigate }: { to: string; icon: ReactNode; label: string; onNavigate: () => void }) { - return {icon}{label}; -} - function UserAvatar({ user, className }: { user: LocalUser; className?: string }) { const [failed, setFailed] = useState(false); const avatarUrl = /^https?:\/\//i.test(user.avatarUrl || "") ? user.avatarUrl : ""; diff --git a/web/src/components/layout/workspace-sidebar-nav.tsx b/web/src/components/layout/workspace-sidebar-nav.tsx index d46adf695..13867cc16 100644 --- a/web/src/components/layout/workspace-sidebar-nav.tsx +++ b/web/src/components/layout/workspace-sidebar-nav.tsx @@ -1,4 +1,4 @@ -import { ChevronDown, ChevronRight, Home, Infinity as InfinityIcon, LogOut, Plus, Search } from "lucide-react"; +import { ChevronDown, ChevronRight, Home, Infinity as InfinityIcon, LogOut, PanelLeftOpen, Plus, Search, ShieldCheck } from "lucide-react"; import { useEffect, useMemo, useRef, useState, type ComponentType, type CSSProperties } from "react"; import { Link, useLocation, useNavigate, useSearchParams } from "react-router"; @@ -40,7 +40,7 @@ function toolItem(slug: NavigationToolSlug, to: string): WorkspaceNavItem { return { id: slug, title: tool?.label ?? slug, icon: tool?.icon, to }; } -function buildNav(features: FeatureAvailability, balance: string): { groups: WorkspaceNavGroup[]; footer: WorkspaceNavItem[] } { +function buildNav(features: FeatureAvailability, balance: string, isAdmin: boolean): { groups: WorkspaceNavGroup[]; footer: WorkspaceNavItem[] } { const groups: WorkspaceNavGroup[] = [ { items: [ @@ -63,6 +63,7 @@ function buildNav(features: FeatureAvailability, balance: string): { groups: Wor const settingsSections = SETTINGS_SECTIONS.filter((section) => section.key !== "channels" || features.customChannelsEnabled); const footer: WorkspaceNavItem[] = [ + ...(isAdmin ? [{ id: "admin", title: "管理员后台", icon: ShieldCheck, to: "/admin" }] : []), { ...toolItem("settings", "/settings"), children: settingsSections.map((section) => ({ @@ -77,7 +78,7 @@ function buildNav(features: FeatureAvailability, balance: string): { groups: Wor return { groups, footer }; } -function WorkspaceSwitcher({ onNavigate }: { onNavigate: () => void }) { +function WorkspaceSwitcher({ collapsed, onNavigate, onExpand }: { collapsed: boolean; onNavigate: () => void; onExpand: () => void }) { const [isOpen, setIsOpen] = useState(false); const navigate = useNavigate(); @@ -87,6 +88,22 @@ function WorkspaceSwitcher({ onNavigate }: { onNavigate: () => void }) { navigate(to); }; + if (collapsed) { + return ( +
+ +
+ ); + } + return (
)} - {hasChildren ? ( + {hasChildren && !collapsed ? (
@@ -257,6 +280,7 @@ function NavItem({ item, activeId, onSelect, onOpenSearch, onLogout, level = 0 } onOpenSearch={onOpenSearch} onLogout={onLogout} level={level + 1} + collapsed={false} /> ))}
@@ -266,12 +290,13 @@ function NavItem({ item, activeId, onSelect, onOpenSearch, onLogout, level = 0 } ); } -function NavGroup({ group, activeId, onNavigate, onOpenSearch, onLogout }: { +function NavGroup({ group, activeId, onNavigate, onOpenSearch, onLogout, collapsed }: { group: WorkspaceNavGroup; activeId: string; onNavigate: () => void; onOpenSearch: () => void; onLogout: () => void; + collapsed: boolean; }) { const [isOpen, setIsOpen] = useState(true); const hasActive = group.items.some((item) => item.id === activeId || (item.id === "settings" && activeId.startsWith("settings:"))); @@ -291,13 +316,14 @@ function NavGroup({ group, activeId, onNavigate, onOpenSearch, onLogout }: { onSelect={onNavigate} onOpenSearch={onOpenSearch} onLogout={onLogout} + collapsed={collapsed} /> ))}
); // 无标题分组(核心导航入口)常驻展示,不做折叠。 - if (!group.heading) { + if (!group.heading || collapsed) { return
{content}
; } @@ -322,7 +348,7 @@ function NavGroup({ group, activeId, onNavigate, onOpenSearch, onLogout }: { ); } -export function WorkspaceSidebarNav({ onNavigate, onOpenSearch }: { onNavigate: () => void; onOpenSearch: () => void }) { +export function WorkspaceSidebarNav({ collapsed, onNavigate, onOpenSearch, onExpand }: { collapsed: boolean; onNavigate: () => void; onOpenSearch: () => void; onExpand: () => void }) { const { pathname } = useLocation(); const [searchParams] = useSearchParams(); const features = useUserStore((state) => state.features); @@ -335,7 +361,7 @@ export function WorkspaceSidebarNav({ onNavigate, onOpenSearch }: { onNavigate: ? "--" : (availableMicrocredits / 1_000_000).toLocaleString("zh-CN", { maximumFractionDigits: 2 }); - const { groups, footer } = useMemo(() => buildNav(features, balance), [features, balance]); + const { groups, footer } = useMemo(() => buildNav(features, balance, user?.role === "admin"), [features, balance, user?.role]); const slug = pathname.split("/").filter(Boolean)[0] || "create"; const section = searchParams.get("section"); @@ -357,18 +383,20 @@ export function WorkspaceSidebarNav({ onNavigate, onOpenSearch }: { onNavigate: }, [groups]); return ( -
- +
+ -
+
@@ -379,6 +407,7 @@ export function WorkspaceSidebarNav({ onNavigate, onOpenSearch }: { onNavigate: onScroll={handleScroll} className={cn( "app-workspace-sidebar-scroll-area flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-3 pb-3 pt-2", + collapsed && "is-collapsed", scrollState.hasTopFade && "has-top-fade", scrollState.hasBottomFade && "has-bottom-fade", )} @@ -391,6 +420,7 @@ export function WorkspaceSidebarNav({ onNavigate, onOpenSearch }: { onNavigate: onNavigate={onNavigate} onOpenSearch={onOpenSearch} onLogout={() => void handleLogout()} + collapsed={collapsed} /> ))}
@@ -405,6 +435,7 @@ export function WorkspaceSidebarNav({ onNavigate, onOpenSearch }: { onNavigate: onSelect={onNavigate} onOpenSearch={onOpenSearch} onLogout={() => void handleLogout()} + collapsed={collapsed} /> ))}
diff --git a/web/src/components/prompt/prompt-code-editor.tsx b/web/src/components/prompt/prompt-code-editor.tsx index 5fce75c13..90969596e 100644 --- a/web/src/components/prompt/prompt-code-editor.tsx +++ b/web/src/components/prompt/prompt-code-editor.tsx @@ -1,5 +1,4 @@ -import { EditorView } from "@codemirror/view"; -import CodeMirror from "@uiw/react-codemirror"; +import CodeMirror, { EditorView } from "@uiw/react-codemirror"; import { forwardRef, useImperativeHandle, useRef } from "react"; import { cn } from "@/lib/utils"; diff --git a/web/src/pages/canvas/index.tsx b/web/src/pages/canvas/index.tsx index 399a4ca2e..ab17d38c2 100644 --- a/web/src/pages/canvas/index.tsx +++ b/web/src/pages/canvas/index.tsx @@ -10,7 +10,8 @@ import { WorkspaceLoadingState, WorkspaceState } from "@/components/layout/works import { readZip } from "@/lib/zip"; import { setMediaBlob } from "@/services/file-storage"; import { setImageBlob } from "@/services/image-storage"; -import { CanvasCreateCard, CanvasProjectCard } from "@/components/canvas/canvas-project-card"; +import { CanvasCreateCard } from "@/components/canvas/canvas-project-card"; +import { CanvasFolderCard } from "@/components/canvas/canvas-folder-card"; import type { CanvasExportFile } from "@/types/canvas-export"; import { useCanvasStore } from "@/stores/canvas/use-canvas-store"; import { useCanvasUiStore } from "@/stores/canvas/use-canvas-ui-store"; @@ -326,7 +327,12 @@ export default function CanvasPage() { {showCreateCard ? : null} {visibleProjects.map((project) => ( - + enterProject(project.id)} + /> ))} ) : ( diff --git a/web/src/styles/globals.css b/web/src/styles/globals.css index 73d7ad095..5bc0893cc 100644 --- a/web/src/styles/globals.css +++ b/web/src/styles/globals.css @@ -403,8 +403,9 @@ --workspace-sidebar: var(--workspace-navigation); --workspace-accent: #171717; --workspace-accent-soft: rgba(17, 17, 17, 0.07); - --workspace-sidebar-width: 260px; /* 侧栏展开宽度(组件 token)*/ - --workspace-sidebar-nav-width: 260px; /* 侧栏导航内容宽度,不受折叠状态影响 */ + --workspace-sidebar-width: 208px; /* 侧栏展开宽度:较原 260px 收窄 20% */ + --workspace-sidebar-nav-width: 208px; /* 展开态导航内容宽度 */ + --workspace-sidebar-collapsed-width: 56px; /* 折叠态保留可发现的图标菜单轨 */ --workspace-shell-gutter: 6px; /* 工作区外壳画布留白与卡片间隙(组件 token)*/ @@ -817,6 +818,7 @@ position: fixed; inset: 0; isolation: isolate; + overflow: hidden; } textarea { resize: none; @@ -986,6 +988,11 @@ box-shadow: none; } + .workspace-account-popover .ant-popover-inner { + border-color: color-mix(in srgb, var(--foreground) 7%, transparent) !important; + box-shadow: var(--elevation-overlay) !important; + } + body.app-spatial-overlays { --workspace-overlay-bg: rgba(244, 244, 245, 1); --workspace-overlay-bg-strong: rgba(250, 250, 250, 1); @@ -1239,46 +1246,85 @@ box-shadow: none; } - /* 工作区卡片(侧栏 / 主区)共享外观:边框、圆角、阴影(深浅主题对称) */ + /* 工作区外壳通过表面明度与间距分层,避免边框和内高光叠加造成线条感。 */ .app-workspace-sidebar, .app-workspace-stage { - border: 1px solid color-mix(in srgb, var(--foreground) 7%, transparent); - border-radius: var(--r-xl); - box-shadow: - 0 14px 44px color-mix(in srgb, var(--foreground) 12%, transparent), - inset 0 1px 0 color-mix(in srgb, white 6%, transparent); - } - - .dark .app-workspace-sidebar, - .dark .app-workspace-stage { - box-shadow: - 0 14px 44px rgba(0, 0, 0, 0.45), - inset 0 1px 0 color-mix(in srgb, white 6%, transparent); - } - - .app-workspace-sidebar { - width: var(--workspace-sidebar-width, 260px); - background: var(--workspace-sidebar); - transition: width var(--motion-state) var(--ease-aceternity), border-color var(--motion-instant) ease-out, box-shadow var(--motion-instant) ease-out; - } - - .app-workspace-shell .app-workspace-sidebar.is-collapsed { - width: 0 !important; border: 0; + border-radius: var(--r-xl); box-shadow: none; } - /* 侧栏折叠后主区四周贴齐视口:取消外壳留白、行间距与卡片圆角,四周无间隙 */ - .app-workspace-shell:has(.app-workspace-sidebar.is-collapsed) { - padding: 0; + .app-workspace-sidebar { + width: var(--workspace-sidebar-width, 208px); + background: color-mix(in srgb, var(--workspace-sidebar) 96%, var(--foreground) 4%); + transition: width var(--motion-state) var(--ease-aceternity); } - .app-workspace-shell:has(.app-workspace-sidebar.is-collapsed) .app-workspace-main-row { + .app-workspace-shell .app-workspace-sidebar.is-collapsed { + width: var(--workspace-sidebar-collapsed-width) !important; + } + + .app-workspace-sidebar-nav { + width: var(--workspace-sidebar-nav-width); + transition: width var(--motion-state) var(--ease-aceternity); + } + + .app-workspace-sidebar-nav.is-collapsed { + width: var(--workspace-sidebar-collapsed-width); + } + + .app-workspace-sidebar-rail-header { + display: grid; + min-height: 56px; + padding: 10px; + place-items: center; + } + + .app-workspace-sidebar-rail-button { + display: grid; + width: 36px; + height: 36px; + place-items: center; + border-radius: var(--r-sm); + color: color-mix(in srgb, var(--foreground) 66%, transparent); + transition: color var(--motion-instant) ease-out, background-color var(--motion-instant) ease-out; + } + + .app-workspace-sidebar-rail-button:hover, + .app-workspace-sidebar-rail-button:focus-visible { + background: var(--surface-hover); + color: var(--foreground); + } + + .app-workspace-sidebar-nav.is-collapsed .app-workspace-sidebar-search { + padding: 0 10px 4px; + } + + .app-workspace-sidebar-nav.is-collapsed .app-workspace-sidebar-search > button, + .app-workspace-nav-link.is-collapsed { + justify-content: center; + gap: 0; + padding-inline: 0; + } + + .app-workspace-sidebar-nav.is-collapsed .app-workspace-sidebar-search-label, + .app-workspace-sidebar-nav.is-collapsed .app-workspace-sidebar-search-shortcut, + .app-workspace-nav-link.is-collapsed .app-workspace-nav-title, + .app-workspace-nav-link.is-collapsed .app-workspace-nav-meta { + display: none; + } + + .app-workspace-nav-link.is-collapsed .app-workspace-nav-main { gap: 0; } - .app-workspace-shell:has(.app-workspace-sidebar.is-collapsed) .app-workspace-stage { - border-radius: 0; + .app-workspace-sidebar-scroll-area.is-collapsed { + gap: 6px; + padding: 4px 10px 8px; + } + + .app-workspace-sidebar-nav.is-collapsed .app-workspace-sidebar-footer { + padding: 8px 10px 10px; } @@ -3537,8 +3583,8 @@ .canvas-library-count { display: flex; align-items: baseline; gap: 6px; justify-self: end; color: color-mix(in srgb, var(--foreground) 38%, transparent); font-size: var(--fs-micro); white-space: nowrap; } .canvas-library-count strong { color: var(--foreground); font-size: var(--fs-heading-lg); font-variant-numeric: tabular-nums; font-weight: 500; letter-spacing: .02em; } - /* 画布库保持固定列节奏,行列间距一致。 */ - .canvas-library-grid { grid-template-columns: repeat(6, minmax(0, 1fr)); align-items: stretch; gap: var(--space-5) var(--space-4); } + /* 画布库使用稳定的最小列宽;卡片不靠负边距或溢出制造层叠。 */ + .canvas-library-grid { grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); align-items: stretch; gap: var(--space-6) var(--space-5); } .canvas-library-page .app-pagination-bar { position: static; margin-top: var(--space-4); border: 0; background: transparent; padding: var(--space-1-half) var(--space-2); backdrop-filter: none; } /* === 画布卡片重构(对应 docs/ui-design-system.md)=== */ @@ -3613,6 +3659,166 @@ .app-canvas-selection-toolbar { border-color: color-mix(in srgb, var(--workspace-accent) 25%, var(--workspace-border)); background: color-mix(in srgb, var(--workspace-accent) 7%, transparent); color: color-mix(in srgb, var(--workspace-accent) 48%, var(--foreground)); } + /* 画布文件夹:去掉旧的三层叠压,改为更干净的文件夹封面 + 预览图 + 信息带。 */ + .canvas-folder-card { + position: relative; + min-width: 0; + overflow: hidden; + border-radius: var(--r-xl); + background: var(--card-surface); + color: var(--foreground); + isolation: isolate; + content-visibility: auto; + contain-intrinsic-size: 0 320px; + box-shadow: none; + transition: transform var(--motion-state) var(--motion-ease-out), background-color var(--motion-state) ease-out, box-shadow var(--motion-state) ease-out; + } + + .canvas-folder-card:hover, .canvas-folder-card:focus-within { + background: var(--card-surface-hover); + transform: translateY(-2px); + } + + .canvas-folder-card:active { transform: translateY(-1px) scale(.995); transition-duration: var(--motion-instant); } + .canvas-folder-card.is-selected { box-shadow: 0 0 0 1px color-mix(in srgb, var(--workspace-accent) 54%, transparent); } + + .canvas-folder-open { + position: relative; + z-index: 1; + display: flex; + width: 100%; + height: 100%; + cursor: pointer; + flex-direction: column; + gap: 0; + border: 0; + background: transparent; + padding: 0; + color: inherit; + text-align: left; + transition: transform var(--motion-state) var(--motion-ease-out); + } + + .canvas-folder-open:focus:not(:focus-visible) { outline: none; } + .canvas-folder-open:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; } + .canvas-folder-card:hover .canvas-folder-open, .canvas-folder-card:focus-within .canvas-folder-open { transform: translateY(-1px); } + .canvas-folder-card:active .canvas-folder-open { transform: none; } + + .canvas-folder-preview { + position: relative; + display: block; + flex: 0 0 auto; + aspect-ratio: 16 / 10; + min-height: 0; + overflow: hidden; + border-radius: 0; + background: var(--canvas-cover-surface); + box-shadow: none; + } + + .canvas-folder-preview > :where(.canvas-project-media, .canvas-project-video, .canvas-project-empty, .canvas-project-preview-canvas), + .canvas-folder-preview .cached-resource-image-shell { width: 100%; height: 100%; } + .canvas-folder-preview .canvas-project-media img, .canvas-folder-preview .cached-resource-image-shell > img { display: block; width: 100%; height: 100%; object-fit: cover; } + .canvas-folder-preview .canvas-project-media img { filter: saturate(.94) contrast(1.02); transition: transform 420ms var(--motion-ease-out), filter 240ms ease-out; } + .canvas-folder-card:hover .canvas-folder-preview .canvas-project-media img { filter: saturate(1) contrast(1.04); transform: scale(1.025); } + .canvas-folder-preview .canvas-project-preview-canvas { background: var(--canvas-cover-surface); } + + .canvas-folder-body { + display: flex; + min-width: 0; + flex-direction: column; + gap: 6px; + padding: var(--space-2); + } + + .canvas-folder-heading-row { display: flex; min-width: 0; align-items: center; } + .canvas-folder-title { overflow: hidden; color: var(--foreground); font-size: var(--fs-heading-lg); font-weight: 640; line-height: 1.25; text-overflow: ellipsis; white-space: nowrap; } + .canvas-folder-title-input { width: 100%; min-width: 0; } + .canvas-folder-meta { + display: flex; + min-width: 0; + flex-wrap: wrap; + align-items: center; + gap: 4px 10px; + color: color-mix(in srgb, var(--foreground) 42%, transparent); + font-size: var(--fs-caption); + line-height: 1.45; + } + .canvas-folder-meta-item { min-width: 0; } + .canvas-folder-meta-separator { color: color-mix(in srgb, var(--foreground) 24%, transparent); } + + .canvas-folder-select { + position: absolute; + z-index: 4; + top: 12px; + left: 12px; + display: grid; + width: 30px; + height: 30px; + place-items: center; + border-radius: var(--r-full); + border: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent); + background: color-mix(in srgb, var(--background) 72%, transparent); + opacity: 0; + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + transform: translateY(-2px); + transition: opacity var(--motion-instant) ease-out, transform var(--motion-instant) ease-out, background-color var(--motion-instant) ease-out; + } + + .canvas-folder-select.is-visible, .canvas-folder-card:hover .canvas-folder-select, .canvas-folder-card:focus-within .canvas-folder-select { opacity: 1; transform: translateY(0); } + .canvas-folder-select input { width: 14px; height: 14px; margin: 0; accent-color: var(--control-check-bg); } + + .canvas-folder-actions { + position: absolute; + z-index: 4; + top: 12px; + right: 12px; + display: flex; + align-items: center; + gap: 4px; + padding: 4px; + border: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent); + border-radius: var(--r-full); + background: color-mix(in srgb, var(--background) 72%, transparent); + opacity: 0; + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + transform: translateY(-2px); + transition: opacity var(--motion-instant) ease-out, transform var(--motion-instant) ease-out, background-color var(--motion-instant) ease-out; + } + + .canvas-folder-card:hover .canvas-folder-actions, .canvas-folder-card:focus-within .canvas-folder-actions { opacity: 1; transform: translateY(0); } + + .canvas-folder-rename, .canvas-folder-action { + display: grid; + width: 28px; + height: 28px; + cursor: pointer; + place-items: center; + border: 0; + border-radius: var(--r-full); + background: transparent; + color: color-mix(in srgb, var(--foreground) 42%, transparent); + transition: background-color var(--motion-instant) ease-out, color var(--motion-instant) ease-out; + } + + :where(.canvas-folder-rename, .canvas-folder-action):hover { background: color-mix(in srgb, var(--foreground) 8%, transparent); color: var(--foreground); } + :where(.canvas-folder-rename, .canvas-folder-action) svg { width: 16px; height: 16px; } + + @media (max-width: 639px) { + .canvas-folder-open { gap: 0; padding: 0; } + .canvas-folder-title { font-size: var(--fs-heading); } + .canvas-folder-select, .canvas-folder-actions { opacity: 1; transform: none; } + } + + @media (prefers-reduced-motion: reduce) { + .canvas-folder-card, .canvas-folder-open, .canvas-folder-preview .canvas-project-media img, .canvas-folder-select, .canvas-folder-actions { transition: none; } + .canvas-folder-card:hover, .canvas-folder-card:focus-within, .canvas-folder-card:active { transform: none; } + .canvas-folder-card:hover .canvas-folder-open, .canvas-folder-card:focus-within .canvas-folder-open { transform: none; } + .canvas-folder-card:hover .canvas-folder-preview .canvas-project-media img { transform: none; } + } + /* 作品库式页面:与画布列表共享浏览节奏,内容优先于边框和工具栏。 */ .library-page .app-page-header { border-bottom: 0; } .library-page .app-page-header h1 { font-size: var(--fs-title); font-weight: 620; } @@ -3839,6 +4045,7 @@ .canvas-library-page .canvas-library-grid { flex: 1; + gap: var(--space-5); } .canvas-library-page .canvas-library-search { @@ -4505,20 +4712,8 @@ .task-record-actions { opacity: 1; transform: none; } } - @media (max-width: 1600px) and (min-width: 1101px) { - .canvas-library-grid { grid-template-columns: repeat(5, minmax(0, 1fr)); gap: var(--space-5) var(--space-4); } - } - - @media (max-width: 1100px) and (min-width: 900px) { - .canvas-library-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--space-5) var(--space-4); } - } - - @media (max-width: 899px) and (min-width: 640px) { - .canvas-library-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--space-5) var(--space-4); } - } - @media (max-width: 639px) { - .canvas-library-grid { grid-template-columns: 1fr; gap: var(--space-4); } + .canvas-library-grid { grid-template-columns: 1fr; gap: var(--space-5); } .canvas-library-discovery { padding-inline: 0; } .canvas-library-filters { min-width: 0; } .canvas-library-filter { max-width: 150px; padding-inline: 6px; }