diff --git a/docs/plan/model-modal-redesign.md b/docs/plan/model-modal-redesign.md new file mode 100644 index 00000000..7d740df7 --- /dev/null +++ b/docs/plan/model-modal-redesign.md @@ -0,0 +1,449 @@ +# Model Modal UI/UX 重构计划 + +> **Status**: Phase 1 Complete ✓ +> **Created**: 2026-02-06 +> **Target**: v2.x Release + +--- + +## 1. 项目概述 + +### 1.1 背景与问题 + +当前 Model Modal 存在以下 UX 问题: + +1. **空间利用率低** - 固定 800px 宽度,大屏环境下大量留白 +2. **Tab 切换繁琐** - 4 个 Tab(Examples/Description/Versions/Recipes)隐藏了重要信息 +3. **Examples 浏览不便** - 需持续向下滚动,无快速导航 +4. **添加自定义示例困难** - 需滚动到底部,操作路径长 + +### 1.2 设计目标 + +- **空间效率**: 利用 header 以下、sidebar 右侧的全部可用空间 +- **浏览体验**: 类似 Midjourney 的沉浸式图片浏览 +- **信息架构**: 关键元数据固定可见,次要信息可折叠 +- **操作效率**: 直觉化的键盘导航,减少点击次数 + +--- + +## 2. 设计方案 + +### 2.1 布局架构: Split-View Overlay + +``` +┌──────────────────────────────────────────────────────────────────────┐ +│ HEADER (保持现有) │ +├──────────┬───────────────────────────────────────────────────────────┤ +│ │ ┌───────────────────────────┬────────────────────────┐ │ +│ FOLDER │ │ │ MODEL HEADER │ │ +│ SIDEBAR │ │ EXAMPLES SHOWCASE │ ├─ Name │ │ +│ (可折叠) │ │ │ ├─ Creator + Actions │ │ +│ │ │ ┌─────────────────┐ │ ├─ Tags │ │ +│ │ │ │ │ ├────────────────────────┤ │ +│ │ │ │ MAIN IMAGE │ │ COMPACT METADATA │ │ +│ │ │ │ (自适应高度) │ │ ├─ Ver | Base | Size │ │ +│ │ │ │ │ │ ├─ Location │ │ +│ │ │ └─────────────────┘ │ ├─ Usage Tips │ │ +│ │ │ │ ├─ Trigger Words │ │ +│ │ │ [PARAMS PREVIEW] │ ├─ Notes │ │ +│ │ │ (Prompt + Copy) ├────────────────────────┤ │ +│ │ │ │ CONTENT TABS │ │ +│ │ │ ┌─────────────────┐ │ [Desc][Versions][Rec] │ │ +│ │ │ │ THUMBNAIL RAIL │ │ │ │ +│ │ │ │ [1][2][3][4][+]│ │ TAB CONTENT AREA │ │ +│ │ │ └─────────────────┘ │ (Accordion / List) │ │ +│ │ └───────────────────────────┴────────────────────────┘ │ +└──────────┴───────────────────────────────────────────────────────────┘ +``` + +**尺寸规格**: +- Sidebar 展开: Left 60% | Right 40% +- Sidebar 折叠: Left 65% | Right 35% +- 最小宽度: 1200px (低于此值触发移动端适配) + +### 2.2 左侧: Examples Showcase + +#### 2.2.1 组件结构 + +| 组件 | 描述 | 优先级 | +|------|------|--------| +| Main Image | 自适应容器,保持原始比例,最大高度 70vh | P0 | +| Params Panel | 底部滑出面板,显示 Prompt/Negative/Params | P0 | +| Thumbnail Rail | 底部横向滚动条,支持点击跳转 | P0 | +| Add Button | Rail 最右侧 "+" 按钮,打开上传区 | P0 | +| Nav Arrows | 图片左右两侧悬停显示 | P1 | + +#### 2.2.2 图片悬停操作 + +``` +┌─────────────────┐ +│ [👁] [📌] [🗑] │ ← 查看参数 | 设为预览 | 删除 +│ │ +│ IMAGE │ +│ │ +└─────────────────┘ +``` + +#### 2.2.3 键盘导航 + +| 按键 | 功能 | 说明 | +|------|------|------| +| ← | 上一个 Example | 循环(首张时到最后一张) | +| → | 下一个 Example | 循环(末张时到第一张) | +| I | Toggle Params Panel | 显示/隐藏图片参数 | +| C | Copy Prompt | 复制当前 Prompt 到剪贴板 | + +### 2.3 右侧: Metadata + Content + +#### 2.3.1 固定头部 (不可折叠) + +``` +┌────────────────────────┐ +│ MODEL NAME [×] │ +│ [👤 Creator] [🌐 Civ] │ +│ [tag1] [tag2] [tag3] │ +├────────────────────────┤ +│ Ver: v1.0 Size: 96MB │ +│ Base: SDXL │ +│ 📁 /path/to/file │ +├────────────────────────┤ +│ USAGE TIPS [✏️] │ +│ [strength: 0.8] [+] │ +├────────────────────────┤ +│ TRIGGER WORDS [✏️] │ +│ [word1] [word2] [📋] │ +├────────────────────────┤ +│ NOTES [✏️] │ +│ "Add your notes..." │ +└────────────────────────┘ +``` + +#### 2.3.2 Tabs 设计 + +保留横向 Tab 切换,但优化内容展示: + +| Tab | 内容 | 交互方式 | +|-----|------|----------| +| Description | About this version + Model Description | Accordion 折叠 | +| Versions | 版本列表卡片 | 完整列表视图 | +| Recipes | Recipe 卡片网格 | 网格布局 | + +**Accordion 行为**: +- 手风琴模式:同时只能展开一个 section +- 默认:About this version 展开,Description 折叠 +- 动画:300ms ease-out + +### 2.4 全局导航 + +#### 2.4.1 Model 切换 + +| 按键 | 功能 | +|------|------| +| ↑ | 上一个 Model | +| ↓ | 下一个 Model | + +**切换动画**: +1. 当前 Modal 淡出 (150ms) +2. 加载新 Model 数据 +3. 新 Modal 淡入 (150ms) +4. 保持当前 Tab 状态(不重置到默认) + +#### 2.4.2 首次使用提示 + +Modal 首次打开时,顶部显示提示条: +``` +┌─────────────────────────────────────────────────────────────┐ +│ 💡 Tip: ↑↓ 切换模型 | ←→ 浏览示例 | I 查看参数 | ESC 关闭 │ +└─────────────────────────────────────────────────────────────┘ +``` +- 3 秒后自动淡出 +- 提供 "不再显示" 选项 + +--- + +## 3. 技术实现 + +### 3.1 文件结构变更 + +``` +static/ +├── js/ +│ └── components/ +│ └── model-modal/ # 新目录 +│ ├── index.js # 主入口 +│ ├── ModelModal.js # Modal 容器 +│ ├── ExampleShowcase.js # 左侧展示 +│ ├── ThumbnailRail.js # 缩略图导航 +│ ├── MetadataPanel.js # 右侧元数据 +│ ├── ContentTabs.js # Tabs 容器 +│ └── accordions/ # Accordion 组件 +│ ├── DescriptionAccordion.js +│ └── VersionsList.js +├── css/ +│ └── components/ +│ └── model-modal/ # 新目录 +│ ├── modal-overlay.css +│ ├── showcase.css +│ ├── thumbnail-rail.css +│ ├── metadata.css +│ └── tabs.css +``` + +### 3.2 核心 CSS 架构 + +```css +/* modal-overlay.css */ +.model-overlay { + position: fixed; + top: var(--header-height); + left: var(--sidebar-width, 250px); + right: 0; + bottom: 0; + z-index: var(--z-modal); + + display: grid; + grid-template-columns: 1.2fr 0.8fr; + gap: 0; + + background: var(--bg-color); + animation: modalSlideIn 0.2s ease-out; +} + +.model-overlay.sidebar-collapsed { + left: var(--sidebar-collapsed-width, 60px); + grid-template-columns: 1.3fr 0.7fr; +} + +/* 移动端适配 */ +@media (max-width: 768px) { + .model-overlay { + left: 0; + grid-template-columns: 1fr; + grid-template-rows: auto 1fr; + } +} +``` + +### 3.3 响应式断点 + +| 断点 | 布局 | 说明 | +|------|------|------| +| > 1400px | Split 60/40 | 大屏优化 | +| 1200-1400px | Split 50/50 | 标准桌面 | +| 768-1200px | Split 50/50 | 小屏桌面/平板 | +| < 768px | Stack | 移动端:Examples 在上,Metadata 在下 | + +--- + +## 4. 实施阶段 + +### Phase 1: 核心重构 (预计 2-3 周) + +**目标**: MVP 可用,基础功能完整 + +**任务清单**: + +- [ ] 创建新的文件结构和基础组件 +- [ ] 实现 Split-View Overlay 布局 + - [ ] CSS Grid 布局系统 + - [ ] Sidebar 状态联动 + - [ ] 响应式断点处理 +- [ ] 迁移左侧 Examples 区域 + - [ ] Main Image 自适应容器 + - [ ] Thumbnail Rail 组件 + - [ ] Params Panel 滑出动画 +- [ ] 实现新的快捷键系统 + - [ ] ↑↓ 切换 Model + - [ ] ←→ 切换 Example + - [ ] I/C/ESC 功能键 +- [ ] 移除旧 Modal 的 max-width 限制 +- [ ] 基础动画过渡 + +**验收标准**: +- [ ] 新布局在各种屏幕尺寸下正常显示 +- [ ] 键盘导航正常工作 +- [ ] 无阻塞性 Bug + +--- + +### Phase 2: 体验优化 (预计 1-2 周) + +**目标**: 信息架构优化,交互细节完善 + +**任务清单**: + +- [ ] Accordion 组件实现 + - [ ] Description Tab 的折叠面板 + - [ ] 手风琴交互逻辑 + - [ ] 动画优化 +- [ ] 右侧 Metadata 区域固定化 + - [ ] 滚动行为优化 + - [ ] 编辑功能迁移 +- [ ] Example 添加流程优化 + - [ ] Rail 上的 "+" 按钮 + - [ ] Inline Upload Area + - [ ] 拖拽上传支持 +- [ ] Model 切换动画优化 + - [ ] 淡入淡出效果 + - [ ] 加载状态指示 +- [ ] 首次使用提示 + +**验收标准**: +- [ ] Accordion 交互流畅 +- [ ] 添加 Example 操作路径 < 2 步 +- [ ] Model 切换视觉反馈清晰 + +--- + +### Phase 3: 功能完整化 (预计 1-2 周) + +**目标**: 所有现有功能迁移完成 + +**任务清单**: + +- [ ] Versions Tab 完整实现 + - [ ] 版本列表卡片 + - [ ] 下载/忽略/删除操作 + - [ ] 更新状态 Badge +- [ ] Recipes Tab 完整实现 + - [ ] Recipe 卡片网格 + - [ ] 复制/应用操作 +- [ ] Tab 状态保持 + - [ ] 切换 Model 时保持当前 Tab + - [ ] Tab 内容滚动位置记忆 +- [ ] 所有编辑功能迁移 + - [ ] Model Name 编辑 + - [ ] Base Model 编辑 + - [ ] File Name 编辑 + - [ ] Tags 编辑 + - [ ] Usage Tips 编辑 + - [ ] Notes 编辑 + +**验收标准**: +- [ ] 所有现有功能可用 +- [ ] 单元测试覆盖率 > 80% + +--- + +### Phase 4: 打磨与优化 (预计 1 周) + +**目标**: 性能优化,边缘 case 处理 + +**任务清单**: + +- [ ] 移动端适配完善 + - [ ] Stack 布局优化 + - [ ] 触摸手势支持(滑动切换) +- [ ] 性能优化 + - [ ] 图片懒加载优化 + - [ ] 虚拟滚动(大量 Examples 时) + - [ ] 减少重渲染 +- [ ] 无障碍支持 + - [ ] ARIA 标签 + - [ ] 键盘导航焦点管理 + - [ ] 屏幕阅读器测试 +- [ ] 动画性能优化 + - [ ] will-change 优化 + - [ ] 减少 layout thrashing + +**验收标准**: +- [ ] Lighthouse Performance > 90 +- [ ] 无障碍检查无严重问题 + +--- + +### Phase 5: 发布准备 (预计 3-5 天) + +**目标**: 稳定版本,文档完整 + +**任务清单**: + +- [ ] Bug 修复 +- [ ] 用户测试 +- [ ] 更新文档 + - [ ] README 更新 + - [ ] 快捷键说明 + - [ ] 截图/GIF 演示 +- [ ] 发布说明 + +--- + +## 5. 风险与应对 + +| 风险 | 影响 | 应对策略 | +|------|------|----------| +| 用户不适应新布局 | 中 | 提供设置选项,允许切换回旧版(临时) | +| 性能问题(大量 Examples) | 高 | Phase 4 重点优化,必要时虚拟滚动 | +| 移动端体验不佳 | 中 | 单独设计移动端布局,非简单缩放 | +| 与现有扩展冲突 | 低 | 充分的回归测试 | + +--- + +## 6. 关联文件 + +### 6.1 需修改的现有文件 + +``` +static/js/components/shared/ModelModal.js # 完全重构 +static/js/components/shared/showcase/ # 迁移至新目录 +static/css/components/lora-modal/ # 样式重写 +static/css/components/modal/_base.css # Overlay 样式调整 +``` + +### 6.2 参考资源 + +- [Midjourney Explore](https://www.midjourney.com/explore) - 交互参考 +- [Pinterest Pin View](https://www.pinterest.com) - 布局参考 +- [AGENTS.md](/AGENTS.md) - 项目代码规范 + +--- + +## 7. Checklist + +### 7.1 启动前 + +- [ ] 创建 feature branch: `feature/model-modal-redesign` +- [ ] 设置开发环境 +- [ ] 准备测试数据集(多种 Model 类型) + +### 7.2 每个 Phase 完成时 + +- [ ] 代码审查 +- [ ] 功能测试 +- [ ] 更新本文档状态 + +### 7.3 发布前 + +- [ ] 完整回归测试 +- [ ] 更新 CHANGELOG +- [ ] 更新版本号 + +--- + +## 8. 附录 + +### 8.1 命名规范 + +| 类型 | 规范 | 示例 | +|------|------|------| +| 文件 | kebab-case | `thumbnail-rail.js` | +| 组件 | PascalCase | `ThumbnailRail` | +| CSS 类 | BEM | `.thumbnail-rail__item--active` | +| 变量 | camelCase | `currentExampleIndex` | + +### 8.2 颜色规范 + +使用现有 CSS 变量,不引入新颜色: + +```css +--lora-accent: #4299e1; +--lora-accent-l: 60%; +--lora-accent-c: 0.2; +--lora-accent-h: 250; +--lora-surface: var(--card-bg); +--lora-border: var(--border-color); +``` + +--- + +*Last Updated: 2026-02-06* diff --git a/static/css/components/model-modal/metadata.css b/static/css/components/model-modal/metadata.css new file mode 100644 index 00000000..bb5b734b --- /dev/null +++ b/static/css/components/model-modal/metadata.css @@ -0,0 +1,354 @@ +/* Metadata Panel - Right Panel */ + +.metadata { + display: flex; + flex-direction: column; + height: 100%; + background: var(--card-bg); + border-left: 1px solid var(--lora-border); + overflow: hidden; +} + +/* Header section */ +.metadata__header { + padding: var(--space-3); + border-bottom: 1px solid var(--lora-border); + background: var(--lora-surface); +} + +.metadata__title-row { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--space-2); + margin-bottom: var(--space-2); +} + +.metadata__name { + font-size: 1.4em; + font-weight: 600; + line-height: 1.3; + color: var(--text-color); + margin: 0; + word-break: break-word; +} + +.metadata__edit-btn { + flex-shrink: 0; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + background: transparent; + border: none; + border-radius: 50%; + color: var(--text-color); + opacity: 0; + cursor: pointer; + transition: opacity 0.2s, background-color 0.2s; +} + +.metadata__header:hover .metadata__edit-btn { + opacity: 0.5; +} + +.metadata__edit-btn:hover { + opacity: 1 !important; + background: var(--lora-border); +} + +/* Creator and actions */ +.metadata__actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-2); + margin-bottom: var(--space-2); +} + +.metadata__creator { + display: flex; + align-items: center; + gap: var(--space-1); + padding: var(--space-1) var(--space-2); + background: rgba(0, 0, 0, 0.03); + border: 1px solid var(--lora-border); + border-radius: var(--border-radius-sm); + cursor: pointer; + transition: all 0.2s; +} + +[data-theme="dark"] .metadata__creator { + background: rgba(255, 255, 255, 0.03); +} + +.metadata__creator:hover { + border-color: var(--lora-accent); + background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1); +} + +.metadata__creator-avatar { + width: 24px; + height: 24px; + border-radius: 50%; + overflow: hidden; + background: var(--lora-accent); + display: flex; + align-items: center; + justify-content: center; +} + +.metadata__creator-avatar img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.metadata__creator-avatar i { + color: white; + font-size: 0.8rem; +} + +.metadata__creator-name { + font-size: 0.9em; + font-weight: 500; +} + +.metadata__civitai-link { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + background: rgba(0, 0, 0, 0.03); + border: 1px solid var(--lora-border); + border-radius: var(--border-radius-sm); + color: var(--text-color); + font-size: 0.85em; + text-decoration: none; + transition: all 0.2s; +} + +.metadata__civitai-link:hover { + border-color: var(--lora-accent); + background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1); +} + +/* License icons */ +.metadata__licenses { + display: flex; + gap: var(--space-1); + margin-left: auto; +} + +.metadata__license-icon { + width: 22px; + height: 22px; + display: inline-block; + background-color: var(--text-muted); + -webkit-mask: var(--license-icon-image) center/contain no-repeat; + mask: var(--license-icon-image) center/contain no-repeat; + transition: background-color 0.2s ease, transform 0.2s ease; +} + +.metadata__license-icon:hover { + background-color: var(--text-color); + transform: translateY(-1px); +} + +/* Tags */ +.metadata__tags { + display: flex; + flex-wrap: wrap; + gap: var(--space-1); +} + +.metadata__tag { + display: inline-flex; + align-items: center; + padding: 4px 10px; + background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1); + border: 1px solid oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.2); + border-radius: 999px; + font-size: 0.8em; + color: var(--lora-accent); + transition: all 0.2s; +} + +.metadata__tag:hover { + background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.2); + border-color: var(--lora-accent); +} + +/* Info grid */ +.metadata__info { + padding: var(--space-2) var(--space-3); + border-bottom: 1px solid var(--lora-border); +} + +.metadata__info-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: var(--space-2); +} + +.metadata__info-item { + display: flex; + flex-direction: column; + gap: 2px; +} + +.metadata__info-item--full { + grid-column: 1 / -1; +} + +.metadata__info-label { + font-size: 0.75em; + color: var(--text-color); + opacity: 0.7; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.metadata__info-value { + font-size: 0.9em; + color: var(--text-color); + word-break: break-word; +} + +.metadata__info-value--mono { + font-family: monospace; + font-size: 0.85em; +} + +.metadata__info-value--path { + cursor: pointer; + text-decoration: underline; + text-decoration-style: dotted; +} + +.metadata__info-value--path:hover { + opacity: 0.8; +} + +/* Editable sections */ +.metadata__section { + padding: var(--space-2) var(--space-3); + border-bottom: 1px solid var(--lora-border); +} + +.metadata__section-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: var(--space-1); +} + +.metadata__section-title { + font-size: 0.75em; + color: var(--text-color); + opacity: 0.7; + text-transform: uppercase; + letter-spacing: 0.05em; + font-weight: 600; +} + +.metadata__section-edit { + background: transparent; + border: none; + color: var(--text-color); + opacity: 0; + cursor: pointer; + padding: 4px; + border-radius: var(--border-radius-xs); + transition: opacity 0.2s, background-color 0.2s; +} + +.metadata__section:hover .metadata__section-edit { + opacity: 0.5; +} + +.metadata__section-edit:hover { + opacity: 1 !important; + background: var(--lora-border); +} + +/* Usage tips / Trigger words */ +.metadata__tags--editable { + display: flex; + flex-wrap: wrap; + gap: var(--space-1); +} + +.metadata__tag--editable { + cursor: pointer; +} + +.metadata__tag--editable:hover { + background: var(--lora-error); + border-color: var(--lora-error); + color: white; +} + +.metadata__tag--add { + background: transparent; + border-style: dashed; + cursor: pointer; +} + +.metadata__tag--add:hover { + background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1); + border-style: solid; +} + +/* Notes textarea */ +.metadata__notes { + min-height: 60px; + max-height: 120px; + padding: var(--space-2); + background: var(--bg-color); + border: 1px solid var(--lora-border); + border-radius: var(--border-radius-xs); + font-size: 0.9em; + line-height: 1.5; + color: var(--text-color); + resize: vertical; + width: 100%; +} + +.metadata__notes:focus { + outline: none; + border-color: var(--lora-accent); +} + +.metadata__notes::placeholder { + color: var(--text-color); + opacity: 0.5; +} + +/* Content area (tabs + scrollable content) */ +.metadata__content { + flex: 1; + overflow-y: auto; + display: flex; + flex-direction: column; +} + +/* Mobile adjustments */ +@media (max-width: 768px) { + .metadata__header { + padding: var(--space-2); + } + + .metadata__name { + font-size: 1.2em; + } + + .metadata__info { + padding: var(--space-2); + } + + .metadata__section { + padding: var(--space-2); + } +} diff --git a/static/css/components/model-modal/overlay.css b/static/css/components/model-modal/overlay.css new file mode 100644 index 00000000..efbaaf90 --- /dev/null +++ b/static/css/components/model-modal/overlay.css @@ -0,0 +1,161 @@ +/* Model Modal Overlay - Split View Layout */ + +.model-overlay { + position: fixed; + top: var(--header-height, 48px); + left: var(--sidebar-width, 250px); + right: 0; + bottom: 0; + z-index: var(--z-modal, 1000); + + display: grid; + grid-template-columns: 1.2fr 0.8fr; + gap: 0; + + background: var(--bg-color); + opacity: 0; + animation: modalOverlayFadeIn 0.25s ease-out forwards; +} + +.model-overlay.sidebar-collapsed { + left: var(--sidebar-collapsed-width, 60px); + grid-template-columns: 1.3fr 0.7fr; +} + +@keyframes modalOverlayFadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +.model-overlay.closing { + animation: modalOverlayFadeOut 0.2s ease-out forwards; +} + +@keyframes modalOverlayFadeOut { + from { + opacity: 1; + } + to { + opacity: 0; + } +} + +/* Close button */ +.model-overlay__close { + position: absolute; + top: var(--space-2); + right: var(--space-2); + width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.3); + border: none; + border-radius: 50%; + color: white; + font-size: 1.5rem; + cursor: pointer; + z-index: 10; + transition: background-color 0.2s ease, transform 0.2s ease; +} + +.model-overlay__close:hover { + background: rgba(0, 0, 0, 0.5); + transform: scale(1.05); +} + +/* Keyboard shortcut hint */ +.model-overlay__hint { + position: absolute; + top: var(--space-2); + left: 50%; + transform: translateX(-50%); + padding: var(--space-1) var(--space-3); + background: rgba(0, 0, 0, 0.7); + color: white; + font-size: 0.85em; + border-radius: var(--border-radius-sm); + opacity: 0; + animation: hintFadeIn 0.3s ease-out 0.5s forwards, hintFadeOut 0.3s ease-out 3.5s forwards; + z-index: 10; + pointer-events: none; + white-space: nowrap; +} + +@keyframes hintFadeIn { + from { + opacity: 0; + transform: translateX(-50%) translateY(-10px); + } + to { + opacity: 1; + transform: translateX(-50%) translateY(0); + } +} + +@keyframes hintFadeOut { + from { + opacity: 1; + } + to { + opacity: 0; + } +} + +.model-overlay__hint.hidden { + display: none; +} + +/* Responsive breakpoints */ +@media (max-width: 1400px) { + .model-overlay { + grid-template-columns: 1fr 1fr; + } + + .model-overlay.sidebar-collapsed { + grid-template-columns: 1.1fr 0.9fr; + } +} + +@media (max-width: 1200px) { + .model-overlay { + grid-template-columns: 1fr 1fr; + } + + .model-overlay.sidebar-collapsed { + grid-template-columns: 1fr 1fr; + } +} + +/* Mobile: stack layout */ +@media (max-width: 768px) { + .model-overlay { + left: 0; + grid-template-columns: 1fr; + grid-template-rows: auto 1fr; + overflow-y: auto; + } + + .model-overlay.sidebar-collapsed { + left: 0; + grid-template-columns: 1fr; + grid-template-rows: auto 1fr; + } +} + +/* Body scroll lock when modal is open */ +body.modal-open { + overflow: hidden !important; +} + +/* Transition effect for content when switching models */ +.showcase.transitioning, +.metadata.transitioning { + opacity: 0; + transition: opacity 0.15s ease-out; +} diff --git a/static/css/components/model-modal/showcase.css b/static/css/components/model-modal/showcase.css new file mode 100644 index 00000000..b2254ce2 --- /dev/null +++ b/static/css/components/model-modal/showcase.css @@ -0,0 +1,298 @@ +/* Examples Showcase - Left Panel */ + +.showcase { + display: flex; + flex-direction: column; + height: 100%; + background: var(--lora-surface); + position: relative; + overflow: hidden; +} + +/* Main image container */ +.showcase__main { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: var(--space-3); + position: relative; + overflow: hidden; +} + +.showcase__image-wrapper { + position: relative; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--border-radius-sm); + background: var(--bg-color); +} + +.showcase__image { + max-width: 100%; + max-height: 70vh; + object-fit: contain; + border-radius: var(--border-radius-sm); + transition: opacity 0.2s ease; +} + +.showcase__image.loading { + opacity: 0.5; +} + +/* Navigation arrows */ +.showcase__nav { + position: absolute; + top: 50%; + transform: translateY(-50%); + width: 48px; + height: 48px; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.4); + border: none; + border-radius: 50%; + color: white; + font-size: 1.2rem; + cursor: pointer; + opacity: 0; + transition: opacity 0.2s ease, background-color 0.2s ease, transform 0.2s ease; + z-index: 5; +} + +.showcase:hover .showcase__nav { + opacity: 1; +} + +.showcase__nav:hover { + background: rgba(0, 0, 0, 0.6); + transform: translateY(-50%) scale(1.05); +} + +.showcase__nav--prev { + left: var(--space-3); +} + +.showcase__nav--next { + right: var(--space-3); +} + +.showcase__nav:disabled { + opacity: 0.3 !important; + cursor: not-allowed; +} + +/* Image controls overlay */ +.showcase__controls { + position: absolute; + top: var(--space-2); + right: var(--space-2); + display: flex; + gap: var(--space-1); + opacity: 0; + transform: translateY(-5px); + transition: opacity 0.2s ease, transform 0.2s ease; + z-index: 5; +} + +.showcase__image-wrapper:hover .showcase__controls { + opacity: 1; + transform: translateY(0); +} + +.showcase__control-btn { + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.5); + border: none; + border-radius: 50%; + color: white; + font-size: 0.9rem; + cursor: pointer; + transition: background-color 0.2s ease, transform 0.2s ease; +} + +.showcase__control-btn:hover { + background: rgba(0, 0, 0, 0.7); + transform: scale(1.1); +} + +.showcase__control-btn--primary:hover { + background: var(--lora-accent); +} + +.showcase__control-btn--danger:hover { + background: var(--lora-error); +} + +/* Params panel (slide up) */ +.showcase__params { + position: absolute; + bottom: 0; + left: 0; + right: 0; + background: var(--bg-color); + border-top: 1px solid var(--lora-border); + padding: var(--space-3); + transform: translateY(100%); + transition: transform 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275); + z-index: 6; + max-height: 50%; + overflow-y: auto; + box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.15); +} + +.showcase__params.visible { + transform: translateY(0); +} + +.showcase__params-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: var(--space-2); + padding-bottom: var(--space-2); + border-bottom: 1px solid var(--lora-border); +} + +.showcase__params-title { + font-weight: 600; + font-size: 0.95em; +} + +.showcase__params-close { + background: transparent; + border: none; + color: var(--text-color); + font-size: 1.2rem; + cursor: pointer; + padding: 0; + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + transition: background-color 0.2s; +} + +.showcase__params-close:hover { + background: var(--lora-border); +} + +/* Prompt display */ +.showcase__prompt { + background: var(--lora-surface); + border: 1px solid var(--lora-border); + border-radius: var(--border-radius-xs); + padding: var(--space-2); + margin-bottom: var(--space-2); + position: relative; +} + +.showcase__prompt-label { + font-size: 0.8em; + color: var(--text-color); + opacity: 0.7; + margin-bottom: var(--space-1); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.showcase__prompt-text { + font-family: monospace; + font-size: 0.85em; + line-height: 1.5; + max-height: 100px; + overflow-y: auto; + word-break: break-word; + white-space: pre-wrap; +} + +.showcase__prompt-copy { + position: absolute; + top: var(--space-1); + right: var(--space-1); + background: transparent; + border: none; + color: var(--text-color); + opacity: 0.5; + cursor: pointer; + padding: var(--space-1); + border-radius: var(--border-radius-xs); + transition: opacity 0.2s, background-color 0.2s; +} + +.showcase__prompt-copy:hover { + opacity: 1; + background: var(--lora-border); +} + +/* Loading state */ +.showcase__loading { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + color: var(--text-color); + opacity: 0.7; + gap: var(--space-2); +} + +.showcase__loading i { + font-size: 2rem; +} + +/* Empty state */ +.showcase__empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + color: var(--text-color); + opacity: 0.6; + text-align: center; + padding: var(--space-4); +} + +.showcase__empty i { + font-size: 3rem; + margin-bottom: var(--space-2); + opacity: 0.5; +} + +/* Mobile adjustments */ +@media (max-width: 768px) { + .showcase__main { + padding: var(--space-2); + min-height: 50vh; + } + + .showcase__image { + max-height: 50vh; + } + + .showcase__nav { + width: 40px; + height: 40px; + opacity: 0.7; + } + + .showcase__nav--prev { + left: var(--space-1); + } + + .showcase__nav--next { + right: var(--space-1); + } +} diff --git a/static/css/components/model-modal/tabs.css b/static/css/components/model-modal/tabs.css new file mode 100644 index 00000000..4c58c7e4 --- /dev/null +++ b/static/css/components/model-modal/tabs.css @@ -0,0 +1,153 @@ +/* Tabs - Content Area */ + +.tabs { + display: flex; + border-bottom: 1px solid var(--lora-border); + background: var(--lora-surface); +} + +.tab { + flex: 1; + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--space-1); + padding: var(--space-2) var(--space-1); + background: transparent; + border: none; + border-bottom: 2px solid transparent; + color: var(--text-color); + cursor: pointer; + font-size: 0.85em; + font-weight: 500; + transition: all 0.2s; + opacity: 0.7; +} + +.tab:hover { + opacity: 1; + background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.05); +} + +.tab.active { + border-bottom-color: var(--lora-accent); + opacity: 1; + font-weight: 600; +} + +.tab__badge { + display: inline-flex; + align-items: center; + padding: 2px 6px; + border-radius: var(--border-radius-xs); + background: var(--badge-update-bg); + color: var(--badge-update-text); + font-size: 0.65em; + font-weight: 600; + letter-spacing: 0.05em; + text-transform: uppercase; +} + +.tab__badge--pulse { + animation: tabBadgePulse 2s ease-in-out infinite; +} + +@keyframes tabBadgePulse { + 0%, 100% { + box-shadow: 0 0 0 0 color-mix(in oklch, var(--badge-update-bg) 50%, transparent); + } + 50% { + box-shadow: 0 0 0 4px color-mix(in oklch, var(--badge-update-bg) 0%, transparent); + } +} + +/* Tab content */ +.tab-panels { + flex: 1; + overflow-y: auto; + padding: var(--space-2); +} + +.tab-panel { + display: none; +} + +.tab-panel.active { + display: block; + animation: tabPanelFadeIn 0.2s ease-out; +} + +@keyframes tabPanelFadeIn { + from { + opacity: 0; + transform: translateY(5px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Accordion within tab panels */ +.accordion { + border: 1px solid var(--lora-border); + border-radius: var(--border-radius-sm); + overflow: hidden; + margin-bottom: var(--space-2); +} + +.accordion__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--space-2) var(--space-3); + background: var(--lora-surface); + cursor: pointer; + transition: background-color 0.2s; +} + +.accordion__header:hover { + background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.05); +} + +.accordion__title { + font-weight: 600; + font-size: 0.9em; +} + +.accordion__icon { + transition: transform 0.3s ease; +} + +.accordion.expanded .accordion__icon { + transform: rotate(180deg); +} + +.accordion__content { + max-height: 0; + overflow: hidden; + transition: max-height 0.3s ease-out; +} + +.accordion.expanded .accordion__content { + max-height: 500px; /* Adjust based on content */ +} + +.accordion__body { + padding: var(--space-3); + border-top: 1px solid var(--lora-border); + font-size: 0.9em; + line-height: 1.6; +} + +/* Mobile adjustments */ +@media (max-width: 768px) { + .tab { + font-size: 0.8em; + padding: var(--space-2) var(--space-1); + } + + .tab__badge { + display: none; /* Hide badges on small screens */ + } +} diff --git a/static/css/components/model-modal/thumbnail-rail.css b/static/css/components/model-modal/thumbnail-rail.css new file mode 100644 index 00000000..7fd407b9 --- /dev/null +++ b/static/css/components/model-modal/thumbnail-rail.css @@ -0,0 +1,138 @@ +/* Thumbnail Rail - Bottom of Showcase */ + +.thumbnail-rail { + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-2) var(--space-3); + background: var(--lora-surface); + border-top: 1px solid var(--lora-border); + overflow-x: auto; + scrollbar-width: thin; + scrollbar-color: var(--lora-border) transparent; +} + +.thumbnail-rail::-webkit-scrollbar { + height: 6px; +} + +.thumbnail-rail::-webkit-scrollbar-track { + background: transparent; +} + +.thumbnail-rail::-webkit-scrollbar-thumb { + background-color: var(--lora-border); + border-radius: 3px; +} + +/* Thumbnail item */ +.thumbnail-rail__item { + flex-shrink: 0; + width: 64px; + height: 64px; + border-radius: var(--border-radius-xs); + overflow: hidden; + cursor: pointer; + position: relative; + border: 2px solid transparent; + transition: border-color 0.2s ease, transform 0.2s ease; + background: var(--bg-color); +} + +.thumbnail-rail__item:hover { + border-color: var(--lora-border); + transform: translateY(-2px); +} + +.thumbnail-rail__item.active { + border-color: var(--lora-accent); + box-shadow: 0 0 0 2px oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.3); +} + +.thumbnail-rail__item img { + width: 100%; + height: 100%; + object-fit: cover; +} + +/* NSFW blur */ +.thumbnail-rail__item.nsfw img { + filter: blur(8px); +} + +.thumbnail-rail__nsfw-badge { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background: rgba(0, 0, 0, 0.7); + color: white; + font-size: 0.65em; + padding: 2px 6px; + border-radius: var(--border-radius-xs); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +/* Add button */ +.thumbnail-rail__add { + flex-shrink: 0; + width: 64px; + height: 64px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; + background: var(--bg-color); + border: 2px dashed var(--lora-border); + border-radius: var(--border-radius-xs); + color: var(--text-color); + opacity: 0.7; + cursor: pointer; + transition: all 0.2s ease; + font-size: 0.75em; +} + +.thumbnail-rail__add:hover { + border-color: var(--lora-accent); + color: var(--lora-accent); + opacity: 1; + background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.05); +} + +.thumbnail-rail__add i { + font-size: 1.2rem; +} + +/* Upload area (inline expansion) */ +.thumbnail-rail__upload { + display: none; + position: absolute; + bottom: 100%; + left: 0; + right: 0; + padding: var(--space-3); + background: var(--lora-surface); + border-top: 1px solid var(--lora-border); + box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.1); + z-index: 7; +} + +.thumbnail-rail__upload.visible { + display: block; +} + +/* Mobile adjustments */ +@media (max-width: 768px) { + .thumbnail-rail { + padding: var(--space-2); + gap: var(--space-1); + } + + .thumbnail-rail__item, + .thumbnail-rail__add { + width: 56px; + height: 56px; + } +} diff --git a/static/css/style.css b/static/css/style.css index 0f3c18da..058dd0a6 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -27,6 +27,13 @@ @import 'components/lora-modal/showcase.css'; @import 'components/lora-modal/triggerwords.css'; @import 'components/lora-modal/versions.css'; + +/* New Model Modal Split-View Design (Phase 1) */ +@import 'components/model-modal/overlay.css'; +@import 'components/model-modal/showcase.css'; +@import 'components/model-modal/thumbnail-rail.css'; +@import 'components/model-modal/metadata.css'; +@import 'components/model-modal/tabs.css'; @import 'components/shared/edit-metadata.css'; @import 'components/search-filter.css'; @import 'components/bulk.css'; diff --git a/static/js/components/model-modal/MetadataPanel.js b/static/js/components/model-modal/MetadataPanel.js new file mode 100644 index 00000000..f28c00a1 --- /dev/null +++ b/static/js/components/model-modal/MetadataPanel.js @@ -0,0 +1,505 @@ +/** + * MetadataPanel - Right panel for model metadata and tabs + * Features: + * - Fixed header with model info + * - Compact metadata grid + * - Editable fields (usage tips, trigger words, notes) + * - Tabs with accordion content + */ + +import { escapeHtml, formatFileSize } from '../shared/utils.js'; +import { translate } from '../../utils/i18nHelpers.js'; + +export class MetadataPanel { + constructor(container) { + this.element = container; + this.model = null; + this.modelType = null; + this.activeTab = 'description'; + } + + /** + * Render the metadata panel + */ + render({ model, modelType }) { + this.model = model; + this.modelType = modelType; + + this.element.innerHTML = this.getTemplate(); + this.bindEvents(); + } + + /** + * Get the HTML template + */ + getTemplate() { + const m = this.model; + const civitai = m.civitai || {}; + const creator = civitai.creator || {}; + + return ` +
+ + + + ${this.modelType === 'loras' ? this.renderLoraSpecific() : ''} + + ${this.renderNotes(m.notes)} + + + `; + } + + /** + * Render license icons + */ + renderLicenseIcons() { + const license = this.model.civitai?.model; + if (!license) return ''; + + const icons = []; + + if (license.allowNoCredit === false) { + icons.push({ icon: 'user-check', title: translate('modals.model.license.creditRequired', {}, 'Creator credit required') }); + } + + if (license.allowCommercialUse) { + const restrictions = this.resolveCommercialRestrictions(license.allowCommercialUse); + restrictions.forEach(r => { + icons.push({ icon: r.icon, title: r.title }); + }); + } + + if (license.allowDerivatives === false) { + icons.push({ icon: 'exchange-off', title: translate('modals.model.license.noDerivatives', {}, 'No sharing merges') }); + } + + if (license.allowDifferentLicense === false) { + icons.push({ icon: 'rotate-2', title: translate('modals.model.license.noReLicense', {}, 'Same permissions required') }); + } + + if (icons.length === 0) return ''; + + return ` + + `; + } + + /** + * Resolve commercial restrictions + */ + resolveCommercialRestrictions(value) { + const COMMERCIAL_CONFIG = [ + { key: 'image', icon: 'photo-off', title: translate('modals.model.license.noImageSell', {}, 'No selling generated content') }, + { key: 'rentcivit', icon: 'brush-off', title: translate('modals.model.license.noRentCivit', {}, 'No Civitai generation') }, + { key: 'rent', icon: 'world-off', title: translate('modals.model.license.noRent', {}, 'No generation services') }, + { key: 'sell', icon: 'shopping-cart-off', title: translate('modals.model.license.noSell', {}, 'No selling models') }, + ]; + + // Parse and normalize values + let allowed = new Set(); + const values = Array.isArray(value) ? value : [value]; + + values.forEach(v => { + if (!v && v !== '') return; + const cleaned = String(v).trim().toLowerCase().replace(/[\s_-]+/g, '').replace(/[^a-z]/g, ''); + if (cleaned) allowed.add(cleaned); + }); + + // Apply hierarchy + if (allowed.has('sell')) { + allowed.add('rent'); + allowed.add('rentcivit'); + allowed.add('image'); + } + if (allowed.has('rent')) { + allowed.add('rentcivit'); + } + + // Return disallowed items + return COMMERCIAL_CONFIG.filter(config => !allowed.has(config.key)); + } + + /** + * Render tags + */ + renderTags(tags) { + if (!tags || tags.length === 0) return ''; + + const visibleTags = tags.slice(0, 8); + const remaining = tags.length - visibleTags.length; + + return ` + + `; + } + + /** + * Render LoRA specific sections + */ + renderLoraSpecific() { + const m = this.model; + const usageTips = m.usage_tips ? JSON.parse(m.usage_tips) : {}; + const triggerWords = m.civitai?.trainedWords || []; + + return ` + + + + `; + } + + /** + * Render notes section + */ + renderNotes(notes) { + return ` + + `; + } + + /** + * Render tabs + */ + renderTabs() { + const tabs = [ + { id: 'description', label: translate('modals.model.tabs.description', {}, 'Description') }, + { id: 'versions', label: translate('modals.model.tabs.versions', {}, 'Versions') }, + ]; + + if (this.modelType === 'loras') { + tabs.push({ id: 'recipes', label: translate('modals.model.tabs.recipes', {}, 'Recipes') }); + } + + return ` +${translate('modals.model.description.empty', {}, 'No description available')}
+ `} +${translate('modals.model.examples.empty', {}, 'No example images available')}
+