From 7bc63d7631c57dc6eddc1915fd17cf6adc01faa5 Mon Sep 17 00:00:00 2001 From: Will Miao Date: Fri, 6 Feb 2026 19:24:49 +0800 Subject: [PATCH] Phase 1: Model Modal Split-View Redesign MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Implement new split-view overlay layout (left showcase, right metadata) - Add keyboard navigation (↑↓ for model, ←→ for examples, ESC to close) - Create Thumbnail Rail for quick example navigation - Add image controls (view params, set preview, delete) - Implement parameter panel with prompt display - Add metadata panel with model info, tags, licenses - Create tabs (Description/Versions/Recipes) with accordion content - Integrate with existing ModelCard click handlers - Add first-use keyboard hint overlay New files: - static/js/components/model-modal/*.js - static/css/components/model-modal/*.css - docs/plan/model-modal-redesign.md --- docs/plan/model-modal-redesign.md | 449 ++++++++++++++++ .../css/components/model-modal/metadata.css | 354 ++++++++++++ static/css/components/model-modal/overlay.css | 161 ++++++ .../css/components/model-modal/showcase.css | 298 +++++++++++ static/css/components/model-modal/tabs.css | 153 ++++++ .../components/model-modal/thumbnail-rail.css | 138 +++++ static/css/style.css | 7 + .../components/model-modal/MetadataPanel.js | 505 ++++++++++++++++++ .../js/components/model-modal/ModelModal.js | 354 ++++++++++++ static/js/components/model-modal/Showcase.js | 389 ++++++++++++++ static/js/components/model-modal/index.js | 16 + static/js/components/shared/ModelModal.js | 22 +- 12 files changed, 2838 insertions(+), 8 deletions(-) create mode 100644 docs/plan/model-modal-redesign.md create mode 100644 static/css/components/model-modal/metadata.css create mode 100644 static/css/components/model-modal/overlay.css create mode 100644 static/css/components/model-modal/showcase.css create mode 100644 static/css/components/model-modal/tabs.css create mode 100644 static/css/components/model-modal/thumbnail-rail.css create mode 100644 static/js/components/model-modal/MetadataPanel.js create mode 100644 static/js/components/model-modal/ModelModal.js create mode 100644 static/js/components/model-modal/Showcase.js create mode 100644 static/js/components/model-modal/index.js 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.renderTags(m.tags)} +
+ +
+ +
+ + ${this.modelType === 'loras' ? this.renderLoraSpecific() : ''} + + ${this.renderNotes(m.notes)} + +
+ ${this.renderTabs()} + ${this.renderTabPanels()} +
+ `; + } + + /** + * 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 ` +
+ ${icons.map(icon => ` + + `).join('')} +
+ `; + } + + /** + * 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 ` +
+ ${visibleTags.map(tag => ` + + `).join('')} + ${remaining > 0 ? `` : ''} +
+ `; + } + + /** + * 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 ` +
+ ${tabs.map(tab => ` + + `).join('')} +
+ `; + } + + /** + * Render tab panels + */ + renderTabPanels() { + const civitai = this.model.civitai || {}; + + return ` +
+
+
+
+ ${translate('modals.model.accordion.aboutVersion', {}, 'About this version')} + +
+
+
+ ${civitai.description ? ` +
${civitai.description}
+ ` : ` +

${translate('modals.model.description.empty', {}, 'No description available')}

+ `} +
+
+
+ +
+
+ ${translate('modals.model.accordion.modelDescription', {}, 'Model Description')} + +
+
+
+ ${civitai.model?.description ? ` +
${civitai.model.description}
+ ` : ` +

${translate('modals.model.description.empty', {}, 'No description available')}

+ `} +
+
+
+
+ +
+
+ + ${translate('modals.model.loading.versions', {}, 'Loading versions...')} +
+
+ + ${this.modelType === 'loras' ? ` +
+
+ + ${translate('modals.model.loading.recipes', {}, 'Loading recipes...')} +
+
+ ` : ''} +
+ `; + } + + /** + * Bind event listeners + */ + bindEvents() { + this.element.addEventListener('click', (e) => { + const target = e.target.closest('[data-action]'); + if (!target) return; + + const action = target.dataset.action; + + switch (action) { + case 'switch-tab': + const tabId = target.dataset.tab; + this.switchTab(tabId); + break; + case 'toggle-accordion': + target.closest('.accordion')?.classList.toggle('expanded'); + break; + case 'open-location': + this.openFileLocation(); + break; + case 'view-creator': + const username = target.dataset.username; + if (username) { + window.open(`https://civitai.com/user/${username}`, '_blank'); + } + break; + case 'edit-name': + case 'edit-usage-tips': + case 'edit-trigger-words': + case 'edit-notes': + // TODO: Implement edit modes + console.log('Edit:', action); + break; + } + }); + + // Notes textarea auto-save + const notesTextarea = this.element.querySelector('.metadata__notes'); + if (notesTextarea) { + notesTextarea.addEventListener('blur', () => { + this.saveNotes(notesTextarea.value); + }); + } + } + + /** + * Switch active tab + */ + switchTab(tabId) { + this.activeTab = tabId; + + // Update tab buttons + this.element.querySelectorAll('.tab').forEach(tab => { + tab.classList.toggle('active', tab.dataset.tab === tabId); + }); + + // Update panels + this.element.querySelectorAll('.tab-panel').forEach(panel => { + panel.classList.toggle('active', panel.dataset.panel === tabId); + }); + + // Load tab-specific data + if (tabId === 'versions') { + this.loadVersions(); + } else if (tabId === 'recipes') { + this.loadRecipes(); + } + } + + /** + * Load versions data + */ + async loadVersions() { + // TODO: Implement versions loading + console.log('Load versions'); + } + + /** + * Load recipes data + */ + async loadRecipes() { + // TODO: Implement recipes loading + console.log('Load recipes'); + } + + /** + * Save notes + */ + async saveNotes(notes) { + if (!this.model?.file_path) return; + + try { + const { getModelApiClient } = await import('../../api/modelApiFactory.js'); + await getModelApiClient().saveModelMetadata(this.model.file_path, { notes }); + + const { showToast } = await import('../../utils/uiHelpers.js'); + showToast('modals.model.notes.saved', {}, 'success'); + } catch (err) { + console.error('Failed to save notes:', err); + const { showToast } = await import('../../utils/i18nHelpers.js'); + showToast('modals.model.notes.saveFailed', {}, 'error'); + } + } + + /** + * Open file location + */ + async openFileLocation() { + if (!this.model?.file_path) return; + + try { + const response = await fetch('/api/lm/open-file-location', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ file_path: this.model.file_path }) + }); + + if (!response.ok) throw new Error('Failed to open file location'); + + const { showToast } = await import('../../utils/uiHelpers.js'); + showToast('modals.model.openFileLocation.success', {}, 'success'); + } catch (err) { + console.error('Failed to open file location:', err); + const { showToast } = await import('../../utils/uiHelpers.js'); + showToast('modals.model.openFileLocation.failed', {}, 'error'); + } + } +} diff --git a/static/js/components/model-modal/ModelModal.js b/static/js/components/model-modal/ModelModal.js new file mode 100644 index 00000000..d6107b0c --- /dev/null +++ b/static/js/components/model-modal/ModelModal.js @@ -0,0 +1,354 @@ +/** + * ModelModal - Main Controller for Split-View Overlay + * + * Architecture: + * - Overlay container (split-view grid) + * - Left: Showcase (ExampleShowcase component) + * - Right: Metadata + Tabs (MetadataPanel component) + * - Global keyboard navigation (↑↓ for model, ←→ for examples) + */ + +import { Showcase } from './Showcase.js'; +import { MetadataPanel } from './MetadataPanel.js'; +import { getModelApiClient } from '../../api/modelApiFactory.js'; +import { state } from '../../state/index.js'; +import { translate } from '../../utils/i18nHelpers.js'; + +export class ModelModal { + static instance = null; + static overlayElement = null; + static currentModel = null; + static currentModelType = null; + static showcase = null; + static metadataPanel = null; + static isNavigating = false; + static keyboardHandler = null; + static hasShownHint = false; + + /** + * Show the model modal with split-view overlay + * @param {Object} model - Model data object + * @param {string} modelType - Type of model ('loras', 'checkpoints', 'embeddings') + */ + static async show(model, modelType) { + // Prevent navigation spam + if (this.isNavigating) return; + + // If already open, animate transition to new model + if (this.isOpen()) { + await this.transitionToModel(model, modelType); + return; + } + + this.currentModel = model; + this.currentModelType = modelType; + this.isNavigating = false; + + // Fetch complete metadata + let completeCivitaiData = model.civitai || {}; + if (model.file_path) { + try { + const fullMetadata = await getModelApiClient().fetchModelMetadata(model.file_path); + completeCivitaiData = fullMetadata || model.civitai || {}; + } catch (error) { + console.warn('Failed to fetch complete metadata:', error); + } + } + + this.currentModel = { + ...model, + civitai: completeCivitaiData + }; + + // Create overlay + this.createOverlay(); + + // Initialize components + this.showcase = new Showcase(this.overlayElement.querySelector('.showcase')); + this.metadataPanel = new MetadataPanel(this.overlayElement.querySelector('.metadata')); + + // Render content + await this.render(); + + // Setup keyboard navigation + this.setupKeyboardNavigation(); + + // Lock body scroll + document.body.classList.add('modal-open'); + + // Show hint on first use + if (!this.hasShownHint) { + this.showKeyboardHint(); + this.hasShownHint = true; + } + } + + /** + * Create the overlay DOM structure + */ + static createOverlay() { + // Check sidebar state for layout adjustment + const sidebar = document.querySelector('.folder-sidebar'); + const isSidebarCollapsed = sidebar?.classList.contains('collapsed'); + + this.overlayElement = document.createElement('div'); + this.overlayElement.className = `model-overlay ${isSidebarCollapsed ? 'sidebar-collapsed' : ''}`; + this.overlayElement.id = 'modelModal'; + this.overlayElement.innerHTML = ` + +
+ ↑↓ ${translate('modals.model.navigation.switchModel', {}, 'Switch model')} | + ←→ ${translate('modals.model.navigation.browseExamples', {}, 'Browse examples')} | + ESC ${translate('common.close', {}, 'Close')} +
+
+
+ `; + + // Close button handler + this.overlayElement.querySelector('.model-overlay__close').addEventListener('click', () => { + this.close(); + }); + + // Click outside to close + this.overlayElement.addEventListener('click', (e) => { + if (e.target === this.overlayElement) { + this.close(); + } + }); + + document.body.appendChild(this.overlayElement); + } + + /** + * Render content into components + */ + static async render() { + if (!this.currentModel) return; + + // Prepare images data + const regularImages = this.currentModel.civitai?.images || []; + const customImages = this.currentModel.civitai?.customImages || []; + const allImages = [...regularImages, ...customImages]; + + // Render showcase + this.showcase.render({ + images: allImages, + modelHash: this.currentModel.sha256, + filePath: this.currentModel.file_path + }); + + // Render metadata panel + this.metadataPanel.render({ + model: this.currentModel, + modelType: this.currentModelType + }); + } + + /** + * Transition to a different model with animation + */ + static async transitionToModel(model, modelType) { + if (this.isNavigating) return; + this.isNavigating = true; + + // Fade out current content + this.showcase?.element?.classList.add('transitioning'); + this.metadataPanel?.element?.classList.add('transitioning'); + + await new Promise(resolve => setTimeout(resolve, 150)); + + // Close and reopen with new model + this.close(false); // Don't remove overlay immediately + await this.show(model, modelType); + + this.isNavigating = false; + } + + /** + * Close the modal + */ + static close(animate = true) { + if (!this.overlayElement) return; + + // Cleanup keyboard handler + this.cleanupKeyboardNavigation(); + + // Animate out + if (animate) { + this.overlayElement.classList.add('closing'); + setTimeout(() => { + this.removeOverlay(); + }, 200); + } else { + this.removeOverlay(); + } + + // Unlock body scroll + document.body.classList.remove('modal-open'); + } + + /** + * Remove overlay from DOM + */ + static removeOverlay() { + if (this.overlayElement) { + this.overlayElement.remove(); + this.overlayElement = null; + } + this.showcase = null; + this.metadataPanel = null; + this.currentModel = null; + this.currentModelType = null; + } + + /** + * Check if modal is currently open + */ + static isOpen() { + return !!this.overlayElement; + } + + /** + * Setup global keyboard navigation + */ + static setupKeyboardNavigation() { + this.keyboardHandler = (e) => { + // Ignore if user is typing in an input + if (this.isUserTyping()) return; + + switch (e.key) { + case 'ArrowUp': + e.preventDefault(); + this.navigateModel('prev'); + break; + case 'ArrowDown': + e.preventDefault(); + this.navigateModel('next'); + break; + case 'ArrowLeft': + e.preventDefault(); + this.showcase?.prevImage(); + break; + case 'ArrowRight': + e.preventDefault(); + this.showcase?.nextImage(); + break; + case 'Escape': + e.preventDefault(); + this.close(); + break; + case 'i': + case 'I': + if (!this.isUserTyping()) { + e.preventDefault(); + this.showcase?.toggleParams(); + } + break; + case 'c': + case 'C': + if (!this.isUserTyping()) { + e.preventDefault(); + this.showcase?.copyPrompt(); + } + break; + } + }; + + document.addEventListener('keydown', this.keyboardHandler); + } + + /** + * Cleanup keyboard navigation + */ + static cleanupKeyboardNavigation() { + if (this.keyboardHandler) { + document.removeEventListener('keydown', this.keyboardHandler); + this.keyboardHandler = null; + } + } + + /** + * Check if user is currently typing in an input/editable field + */ + static isUserTyping() { + const activeElement = document.activeElement; + if (!activeElement) return false; + + const tagName = activeElement.tagName?.toLowerCase(); + const isEditable = activeElement.isContentEditable; + const isInput = ['input', 'textarea', 'select'].includes(tagName); + + return isEditable || isInput; + } + + /** + * Navigate to previous/next model using virtual scroller + */ + static async navigateModel(direction) { + if (this.isNavigating || !this.currentModel?.file_path) return; + + const scroller = state.virtualScroller; + if (!scroller || typeof scroller.getAdjacentItemByFilePath !== 'function') { + return; + } + + this.isNavigating = true; + + try { + const adjacent = await scroller.getAdjacentItemByFilePath( + this.currentModel.file_path, + direction + ); + + if (!adjacent?.item) { + const toastKey = direction === 'prev' + ? 'modals.model.navigation.noPrevious' + : 'modals.model.navigation.noNext'; + const fallback = direction === 'prev' + ? 'No previous model available' + : 'No next model available'; + // Show toast notification (imported from utils) + import('../../utils/uiHelpers.js').then(({ showToast }) => { + showToast(toastKey, {}, 'info', fallback); + }); + return; + } + + await this.transitionToModel(adjacent.item, this.currentModelType); + } finally { + this.isNavigating = false; + } + } + + /** + * Show keyboard shortcut hint + */ + static showKeyboardHint() { + const hint = this.overlayElement?.querySelector('.model-overlay__hint'); + if (hint) { + // Animation is handled by CSS, just ensure it's visible + hint.classList.remove('hidden'); + } + } + + /** + * Update sidebar state when sidebar is toggled + */ + static updateSidebarState(collapsed) { + if (!this.overlayElement) return; + + if (collapsed) { + this.overlayElement.classList.add('sidebar-collapsed'); + } else { + this.overlayElement.classList.remove('sidebar-collapsed'); + } + } +} + +// Listen for sidebar toggle events +document.addEventListener('sidebar-toggle', (e) => { + ModelModal.updateSidebarState(e.detail.collapsed); +}); diff --git a/static/js/components/model-modal/Showcase.js b/static/js/components/model-modal/Showcase.js new file mode 100644 index 00000000..fb1e92cb --- /dev/null +++ b/static/js/components/model-modal/Showcase.js @@ -0,0 +1,389 @@ +/** + * Showcase - Left panel for displaying example images + * Features: + * - Main image display with navigation + * - Thumbnail rail for quick switching + * - Params panel for image metadata + * - Keyboard navigation support (← →) + */ + +import { escapeHtml } from '../shared/utils.js'; +import { translate } from '../../utils/i18nHelpers.js'; + +export class Showcase { + constructor(container) { + this.element = container; + this.images = []; + this.currentIndex = 0; + this.modelHash = ''; + this.filePath = ''; + this.paramsVisible = false; + } + + /** + * Render the showcase + */ + render({ images, modelHash, filePath }) { + this.images = images || []; + this.modelHash = modelHash || ''; + this.filePath = filePath || ''; + this.currentIndex = 0; + this.paramsVisible = false; + + this.element.innerHTML = this.getTemplate(); + this.bindEvents(); + + if (this.images.length > 0) { + this.loadImage(0); + } + } + + /** + * Get the HTML template + */ + getTemplate() { + const hasImages = this.images.length > 0; + + return ` +
+ ${hasImages ? ` +
+ ${translate('modals.model.examples.title', {}, 'Example')} + +
+ + + +
+ + + + +
+
+ ${translate('modals.model.params.title', {}, 'Generation Parameters')} + +
+
+ +
+
+
+ ` : ` +
+ +

${translate('modals.model.examples.empty', {}, 'No example images available')}

+
+ `} +
+ + ${this.renderThumbnailRail()} + `; + } + + /** + * Render the thumbnail rail + */ + renderThumbnailRail() { + if (this.images.length === 0) { + return ` +
+ +
+ `; + } + + const thumbnails = this.images.map((img, index) => { + const url = img.url || img; + const isNsfw = img.nsfw || false; + return ` +
+ + ${isNsfw ? 'NSFW' : ''} +
+ `; + }).join(''); + + return ` +
+ ${thumbnails} + +
+
+ +
+ `; + } + + /** + * Bind event listeners + */ + bindEvents() { + this.element.addEventListener('click', (e) => { + const target = e.target.closest('[data-action]'); + if (!target) return; + + const action = target.dataset.action; + + switch (action) { + case 'prev-image': + this.prevImage(); + break; + case 'next-image': + this.nextImage(); + break; + case 'select-image': + const index = parseInt(target.dataset.index, 10); + if (!isNaN(index)) { + this.loadImage(index); + } + break; + case 'toggle-params': + this.toggleParams(); + break; + case 'close-params': + this.hideParams(); + break; + case 'set-preview': + this.setAsPreview(); + break; + case 'delete-example': + this.deleteExample(); + break; + case 'add-example': + this.showUploadArea(); + break; + case 'copy-prompt': + this.copyPrompt(); + break; + } + }); + } + + /** + * Load and display an image by index + */ + loadImage(index) { + if (index < 0 || index >= this.images.length) return; + + this.currentIndex = index; + const image = this.images[index]; + const url = image.url || image; + + // Update main image + const imgElement = this.element.querySelector('.showcase__image'); + if (imgElement) { + imgElement.classList.add('loading'); + imgElement.src = url; + imgElement.onload = () => { + imgElement.classList.remove('loading'); + }; + } + + // Update thumbnail rail active state + this.element.querySelectorAll('.thumbnail-rail__item').forEach((item, i) => { + item.classList.toggle('active', i === index); + }); + + // Update params + this.updateParams(image); + } + + /** + * Navigate to previous image + */ + prevImage() { + if (this.images.length === 0) return; + const newIndex = this.currentIndex > 0 ? this.currentIndex - 1 : this.images.length - 1; + this.loadImage(newIndex); + } + + /** + * Navigate to next image + */ + nextImage() { + if (this.images.length === 0) return; + const newIndex = this.currentIndex < this.images.length - 1 ? this.currentIndex + 1 : 0; + this.loadImage(newIndex); + } + + /** + * Toggle params panel visibility + */ + toggleParams() { + this.paramsVisible = !this.paramsVisible; + const panel = this.element.querySelector('.showcase__params'); + if (panel) { + panel.classList.toggle('visible', this.paramsVisible); + } + } + + /** + * Hide params panel + */ + hideParams() { + this.paramsVisible = false; + const panel = this.element.querySelector('.showcase__params'); + if (panel) { + panel.classList.remove('visible'); + } + } + + /** + * Update params panel content + */ + updateParams(image) { + const content = this.element.querySelector('.showcase__params-content'); + if (!content) return; + + const meta = image.meta || {}; + const prompt = meta.prompt || ''; + const negativePrompt = meta.negativePrompt || ''; + + // Build params display + let html = ''; + + if (prompt) { + html += this.renderPromptSection( + translate('modals.model.params.prompt', {}, 'Prompt'), + prompt, + 'prompt' + ); + } + + if (negativePrompt) { + html += this.renderPromptSection( + translate('modals.model.params.negativePrompt', {}, 'Negative Prompt'), + negativePrompt, + 'negative' + ); + } + + // Add parameter tags + const params = []; + if (meta.sampler) params.push({ name: 'Sampler', value: meta.sampler }); + if (meta.steps) params.push({ name: 'Steps', value: meta.steps }); + if (meta.cfgScale) params.push({ name: 'CFG', value: meta.cfgScale }); + if (meta.seed) params.push({ name: 'Seed', value: meta.seed }); + if (meta.size) params.push({ name: 'Size', value: meta.size }); + + if (params.length > 0) { + html += '
'; + params.forEach(param => { + html += ` + + ${escapeHtml(param.name)}: + ${escapeHtml(String(param.value))} + + `; + }); + html += '
'; + } + + if (!prompt && !negativePrompt && params.length === 0) { + html = `
+ + ${translate('modals.model.params.noData', {}, 'No generation data available')} +
`; + } + + content.innerHTML = html; + } + + /** + * Render a prompt section + */ + renderPromptSection(label, text, type) { + return ` +
+
${escapeHtml(label)}
+
${escapeHtml(text)}
+ +
+ `; + } + + /** + * Copy current prompt to clipboard + */ + async copyPrompt() { + const image = this.images[this.currentIndex]; + if (!image) return; + + const meta = image.meta || {}; + const prompt = meta.prompt || ''; + + if (!prompt) return; + + try { + await navigator.clipboard.writeText(prompt); + const { showToast } = await import('../../utils/uiHelpers.js'); + showToast('modals.model.params.promptCopied', {}, 'success'); + } catch (err) { + console.error('Failed to copy prompt:', err); + } + } + + /** + * Set current image as model preview + */ + async setAsPreview() { + const image = this.images[this.currentIndex]; + if (!image || !this.filePath) return; + + const url = image.url || image; + + try { + const { getModelApiClient } = await import('../../api/modelApiFactory.js'); + await getModelApiClient().setModelPreview(this.filePath, url); + + const { showToast } = await import('../../utils/uiHelpers.js'); + showToast('modals.model.actions.previewSet', {}, 'success'); + } catch (err) { + console.error('Failed to set preview:', err); + const { showToast } = await import('../../utils/uiHelpers.js'); + showToast('modals.model.actions.previewFailed', {}, 'error'); + } + } + + /** + * Delete current example + */ + async deleteExample() { + const image = this.images[this.currentIndex]; + if (!image || !this.filePath) return; + + // TODO: Implement delete confirmation and API call + console.log('Delete example:', image); + } + + /** + * Show upload area for adding new examples + */ + showUploadArea() { + // TODO: Implement upload area expansion + console.log('Show upload area'); + } +} diff --git a/static/js/components/model-modal/index.js b/static/js/components/model-modal/index.js new file mode 100644 index 00000000..57836dfa --- /dev/null +++ b/static/js/components/model-modal/index.js @@ -0,0 +1,16 @@ +/** + * Model Modal - New Split-View Overlay Design + * Phase 1 Implementation + */ + +import { ModelModal } from './ModelModal.js'; + +// Export the public API +export const modelModal = { + show: ModelModal.show.bind(ModelModal), + close: ModelModal.close.bind(ModelModal), + isOpen: ModelModal.isOpen.bind(ModelModal), +}; + +// Default export for convenience +export default modelModal; diff --git a/static/js/components/shared/ModelModal.js b/static/js/components/shared/ModelModal.js index 67b59276..582016a6 100644 --- a/static/js/components/shared/ModelModal.js +++ b/static/js/components/shared/ModelModal.js @@ -22,6 +22,12 @@ import { loadRecipesForLora } from './RecipeTab.js'; import { translate } from '../../utils/i18nHelpers.js'; import { state } from '../../state/index.js'; +// Import new ModelModal for split-view overlay (Phase 1) +import { modelModal as newModelModal } from '../model-modal/index.js'; + +// Feature flag: Use new split-view design +const USE_NEW_MODAL = true; + function getModalFilePath(fallback = '') { const modalElement = document.getElementById('modelModal'); if (modalElement && modalElement.dataset && modalElement.dataset.filePath) { @@ -238,6 +244,12 @@ function renderLicenseIcons(modelData) { * @param {string} modelType - Type of model ('lora' or 'checkpoint') */ export async function showModelModal(model, modelType) { + // Use new split-view overlay design when feature flag is enabled + if (USE_NEW_MODAL) { + return newModelModal.show(model, modelType); + } + + // Legacy implementation below (deprecated, kept for fallback) const modalId = 'modelModal'; const modalTitle = model.model_name; cleanupNavigationShortcuts(); @@ -1020,11 +1032,5 @@ async function openFileLocation(filePath) { } } -// Export the model modal API -const modelModal = { - show: showModelModal, - toggleShowcase, - scrollToTop -}; - -export { modelModal }; +// Re-export for compatibility +export { toggleShowcase, scrollToTop };