Phase 1: Model Modal Split-View Redesign

- 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
This commit is contained in:
Will Miao
2026-02-06 19:24:49 +08:00
parent 1606a3ff46
commit 7bc63d7631
12 changed files with 2838 additions and 8 deletions

View File

@@ -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 个 TabExamples/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*

View File

@@ -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);
}
}

View File

@@ -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;
}

View File

@@ -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);
}
}

View File

@@ -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 */
}
}

View File

@@ -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;
}
}

View File

@@ -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';

View File

@@ -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 `
<div class="metadata__header">
<div class="metadata__title-row">
<h2 class="metadata__name">${escapeHtml(m.model_name || 'Unknown')}</h2>
<button class="metadata__edit-btn" data-action="edit-name" title="${translate('modals.model.actions.editName', {}, 'Edit name')}">
<i class="fas fa-pencil-alt"></i>
</button>
</div>
<div class="metadata__actions">
${creator.username ? `
<div class="metadata__creator" data-action="view-creator" data-username="${escapeHtml(creator.username)}">
${creator.image ? `
<div class="metadata__creator-avatar">
<img src="${creator.image}" alt="${escapeHtml(creator.username)}" onerror="this.style.display='none'; this.nextElementSibling.style.display='flex';">
<i class="fas fa-user" style="display: none;"></i>
</div>
` : `
<div class="metadata__creator-avatar">
<i class="fas fa-user"></i>
</div>
`}
<span class="metadata__creator-name">${escapeHtml(creator.username)}</span>
</div>
` : ''}
${m.from_civitai ? `
<a class="metadata__civitai-link" href="https://civitai.com/models/${civitai.modelId}" target="_blank" rel="noopener">
<i class="fas fa-globe"></i>
<span>${translate('modals.model.actions.viewOnCivitai', {}, 'Civitai')}</span>
</a>
` : ''}
${this.renderLicenseIcons()}
</div>
${this.renderTags(m.tags)}
</div>
<div class="metadata__info">
<div class="metadata__info-grid">
<div class="metadata__info-item">
<span class="metadata__info-label">${translate('modals.model.metadata.version', {}, 'Version')}</span>
<span class="metadata__info-value">${escapeHtml(civitai.name || 'N/A')}</span>
</div>
<div class="metadata__info-item">
<span class="metadata__info-label">${translate('modals.model.metadata.size', {}, 'Size')}</span>
<span class="metadata__info-value metadata__info-value--mono">${formatFileSize(m.file_size)}</span>
</div>
<div class="metadata__info-item">
<span class="metadata__info-label">${translate('modals.model.metadata.baseModel', {}, 'Base Model')}</span>
<span class="metadata__info-value">${escapeHtml(m.base_model || translate('modals.model.metadata.unknown', {}, 'Unknown'))}</span>
</div>
<div class="metadata__info-item">
<span class="metadata__info-label">${translate('modals.model.metadata.fileName', {}, 'File Name')}</span>
<span class="metadata__info-value metadata__info-value--mono">${escapeHtml(m.file_name || 'N/A')}</span>
</div>
<div class="metadata__info-item metadata__info-item--full">
<span class="metadata__info-label">${translate('modals.model.metadata.location', {}, 'Location')}</span>
<span class="metadata__info-value metadata__info-value--path" data-action="open-location" title="${translate('modals.model.actions.openLocation', {}, 'Open file location')}">
${escapeHtml((m.file_path || '').replace(/[^/]+$/, '') || 'N/A')}
</span>
</div>
</div>
</div>
${this.modelType === 'loras' ? this.renderLoraSpecific() : ''}
${this.renderNotes(m.notes)}
<div class="metadata__content">
${this.renderTabs()}
${this.renderTabPanels()}
</div>
`;
}
/**
* 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 `
<div class="metadata__licenses">
${icons.map(icon => `
<span class="metadata__license-icon"
style="--license-icon-image: url('/loras_static/images/tabler/${icon.icon}.svg')"
title="${escapeHtml(icon.title)}"
role="img"
aria-label="${escapeHtml(icon.title)}">
</span>
`).join('')}
</div>
`;
}
/**
* 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 `
<div class="metadata__tags">
${visibleTags.map(tag => `
<span class="metadata__tag">${escapeHtml(tag)}</span>
`).join('')}
${remaining > 0 ? `<span class="metadata__tag">+${remaining}</span>` : ''}
</div>
`;
}
/**
* 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 `
<div class="metadata__section">
<div class="metadata__section-header">
<span class="metadata__section-title">${translate('modals.model.metadata.usageTips', {}, 'Usage Tips')}</span>
<button class="metadata__section-edit" data-action="edit-usage-tips">
<i class="fas fa-pencil-alt"></i>
</button>
</div>
<div class="metadata__tags--editable">
${Object.entries(usageTips).map(([key, value]) => `
<span class="metadata__tag metadata__tag--editable" data-key="${escapeHtml(key)}" data-value="${escapeHtml(String(value))}">
${escapeHtml(key)}: ${escapeHtml(String(value))}
</span>
`).join('')}
<span class="metadata__tag metadata__tag--add" data-action="add-usage-tip">
<i class="fas fa-plus"></i>
</span>
</div>
</div>
<div class="metadata__section">
<div class="metadata__section-header">
<span class="metadata__section-title">${translate('modals.model.metadata.triggerWords', {}, 'Trigger Words')}</span>
<button class="metadata__section-edit" data-action="edit-trigger-words">
<i class="fas fa-pencil-alt"></i>
</button>
</div>
<div class="metadata__tags--editable">
${triggerWords.map(word => `
<span class="metadata__tag metadata__tag--editable" data-word="${escapeHtml(word)}">
${escapeHtml(word)}
</span>
`).join('')}
<span class="metadata__tag metadata__tag--add" data-action="add-trigger-word">
<i class="fas fa-plus"></i>
</span>
</div>
</div>
`;
}
/**
* Render notes section
*/
renderNotes(notes) {
return `
<div class="metadata__section">
<div class="metadata__section-header">
<span class="metadata__section-title">${translate('modals.model.metadata.additionalNotes', {}, 'Notes')}</span>
<button class="metadata__section-edit" data-action="edit-notes">
<i class="fas fa-pencil-alt"></i>
</button>
</div>
<textarea class="metadata__notes"
placeholder="${translate('modals.model.metadata.addNotesPlaceholder', {}, 'Add your notes here...')}"
data-action="save-notes">${escapeHtml(notes || '')}</textarea>
</div>
`;
}
/**
* 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 `
<div class="tabs">
${tabs.map(tab => `
<button class="tab ${tab.id === this.activeTab ? 'active' : ''}"
data-tab="${tab.id}"
data-action="switch-tab">
<span class="tab__label">${tab.label}</span>
${tab.id === 'versions' && this.model.update_available ? `
<span class="tab__badge tab__badge--pulse">${translate('modals.model.tabs.update', {}, 'Update')}</span>
` : ''}
</button>
`).join('')}
</div>
`;
}
/**
* Render tab panels
*/
renderTabPanels() {
const civitai = this.model.civitai || {};
return `
<div class="tab-panels">
<div class="tab-panel ${this.activeTab === 'description' ? 'active' : ''}" data-panel="description">
<div class="accordion expanded">
<div class="accordion__header" data-action="toggle-accordion">
<span class="accordion__title">${translate('modals.model.accordion.aboutVersion', {}, 'About this version')}</span>
<i class="accordion__icon fas fa-chevron-down"></i>
</div>
<div class="accordion__content">
<div class="accordion__body">
${civitai.description ? `
<div class="markdown-content">${civitai.description}</div>
` : `
<p class="text-muted">${translate('modals.model.description.empty', {}, 'No description available')}</p>
`}
</div>
</div>
</div>
<div class="accordion">
<div class="accordion__header" data-action="toggle-accordion">
<span class="accordion__title">${translate('modals.model.accordion.modelDescription', {}, 'Model Description')}</span>
<i class="accordion__icon fas fa-chevron-down"></i>
</div>
<div class="accordion__content">
<div class="accordion__body">
${civitai.model?.description ? `
<div class="markdown-content">${civitai.model.description}</div>
` : `
<p class="text-muted">${translate('modals.model.description.empty', {}, 'No description available')}</p>
`}
</div>
</div>
</div>
</div>
<div class="tab-panel ${this.activeTab === 'versions' ? 'active' : ''}" data-panel="versions">
<div class="versions-loading">
<i class="fas fa-spinner fa-spin"></i>
<span>${translate('modals.model.loading.versions', {}, 'Loading versions...')}</span>
</div>
</div>
${this.modelType === 'loras' ? `
<div class="tab-panel ${this.activeTab === 'recipes' ? 'active' : ''}" data-panel="recipes">
<div class="recipes-loading">
<i class="fas fa-spinner fa-spin"></i>
<span>${translate('modals.model.loading.recipes', {}, 'Loading recipes...')}</span>
</div>
</div>
` : ''}
</div>
`;
}
/**
* 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');
}
}
}

View File

@@ -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 = `
<button class="model-overlay__close" title="${translate('common.close', {}, 'Close')}">
<i class="fas fa-times"></i>
</button>
<div class="model-overlay__hint">
↑↓ ${translate('modals.model.navigation.switchModel', {}, 'Switch model')} |
←→ ${translate('modals.model.navigation.browseExamples', {}, 'Browse examples')} |
ESC ${translate('common.close', {}, 'Close')}
</div>
<div class="showcase"></div>
<div class="metadata"></div>
`;
// 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);
});

View File

@@ -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 `
<div class="showcase__main">
${hasImages ? `
<div class="showcase__image-wrapper">
<img class="showcase__image" alt="${translate('modals.model.examples.title', {}, 'Example')}">
<div class="showcase__controls">
<button class="showcase__control-btn" data-action="toggle-params" title="${translate('modals.model.actions.viewParams', {}, 'View parameters (I)')}">
<i class="fas fa-info-circle"></i>
</button>
<button class="showcase__control-btn showcase__control-btn--primary" data-action="set-preview" title="${translate('modals.model.actions.setPreview', {}, 'Set as preview')}">
<i class="fas fa-image"></i>
</button>
<button class="showcase__control-btn showcase__control-btn--danger" data-action="delete-example" title="${translate('modals.model.actions.delete', {}, 'Delete')}">
<i class="fas fa-trash-alt"></i>
</button>
</div>
<button class="showcase__nav showcase__nav--prev" data-action="prev-image" title="${translate('modals.model.navigation.previous', {}, 'Previous')} (←)">
<i class="fas fa-chevron-left"></i>
</button>
<button class="showcase__nav showcase__nav--next" data-action="next-image" title="${translate('modals.model.navigation.next', {}, 'Next')} (→)">
<i class="fas fa-chevron-right"></i>
</button>
<div class="showcase__params">
<div class="showcase__params-header">
<span class="showcase__params-title">${translate('modals.model.params.title', {}, 'Generation Parameters')}</span>
<button class="showcase__params-close" data-action="close-params">
<i class="fas fa-times"></i>
</button>
</div>
<div class="showcase__params-content">
<!-- Params will be populated here -->
</div>
</div>
</div>
` : `
<div class="showcase__empty">
<i class="fas fa-images"></i>
<p>${translate('modals.model.examples.empty', {}, 'No example images available')}</p>
</div>
`}
</div>
${this.renderThumbnailRail()}
`;
}
/**
* Render the thumbnail rail
*/
renderThumbnailRail() {
if (this.images.length === 0) {
return `
<div class="thumbnail-rail">
<button class="thumbnail-rail__add" data-action="add-example">
<i class="fas fa-plus"></i>
<span>${translate('modals.model.examples.add', {}, 'Add')}</span>
</button>
</div>
`;
}
const thumbnails = this.images.map((img, index) => {
const url = img.url || img;
const isNsfw = img.nsfw || false;
return `
<div class="thumbnail-rail__item ${index === 0 ? 'active' : ''} ${isNsfw ? 'nsfw' : ''}"
data-index="${index}"
data-action="select-image">
<img src="${url}" loading="lazy" alt="">
${isNsfw ? '<span class="thumbnail-rail__nsfw-badge">NSFW</span>' : ''}
</div>
`;
}).join('');
return `
<div class="thumbnail-rail">
${thumbnails}
<button class="thumbnail-rail__add" data-action="add-example">
<i class="fas fa-plus"></i>
<span>${translate('modals.model.examples.add', {}, 'Add')}</span>
</button>
</div>
<div class="thumbnail-rail__upload">
<!-- Upload area will be expanded here -->
</div>
`;
}
/**
* 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 += '<div class="params-tags">';
params.forEach(param => {
html += `
<span class="param-tag">
<span class="param-name">${escapeHtml(param.name)}:</span>
<span class="param-value">${escapeHtml(String(param.value))}</span>
</span>
`;
});
html += '</div>';
}
if (!prompt && !negativePrompt && params.length === 0) {
html = `<div class="no-metadata-message">
<i class="fas fa-info-circle"></i>
${translate('modals.model.params.noData', {}, 'No generation data available')}
</div>`;
}
content.innerHTML = html;
}
/**
* Render a prompt section
*/
renderPromptSection(label, text, type) {
return `
<div class="showcase__prompt">
<div class="showcase__prompt-label">${escapeHtml(label)}</div>
<div class="showcase__prompt-text">${escapeHtml(text)}</div>
<button class="showcase__prompt-copy" data-action="copy-prompt" data-type="${type}" title="${translate('common.copy', {}, 'Copy')}">
<i class="fas fa-copy"></i>
</button>
</div>
`;
}
/**
* 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');
}
}

View File

@@ -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;

View File

@@ -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 };