mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-21 21:22:11 -03:00
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:
449
docs/plan/model-modal-redesign.md
Normal file
449
docs/plan/model-modal-redesign.md
Normal 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 个 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*
|
||||
354
static/css/components/model-modal/metadata.css
Normal file
354
static/css/components/model-modal/metadata.css
Normal 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);
|
||||
}
|
||||
}
|
||||
161
static/css/components/model-modal/overlay.css
Normal file
161
static/css/components/model-modal/overlay.css
Normal 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;
|
||||
}
|
||||
298
static/css/components/model-modal/showcase.css
Normal file
298
static/css/components/model-modal/showcase.css
Normal 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);
|
||||
}
|
||||
}
|
||||
153
static/css/components/model-modal/tabs.css
Normal file
153
static/css/components/model-modal/tabs.css
Normal 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 */
|
||||
}
|
||||
}
|
||||
138
static/css/components/model-modal/thumbnail-rail.css
Normal file
138
static/css/components/model-modal/thumbnail-rail.css
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
505
static/js/components/model-modal/MetadataPanel.js
Normal file
505
static/js/components/model-modal/MetadataPanel.js
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
354
static/js/components/model-modal/ModelModal.js
Normal file
354
static/js/components/model-modal/ModelModal.js
Normal 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);
|
||||
});
|
||||
389
static/js/components/model-modal/Showcase.js
Normal file
389
static/js/components/model-modal/Showcase.js
Normal 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');
|
||||
}
|
||||
}
|
||||
16
static/js/components/model-modal/index.js
Normal file
16
static/js/components/model-modal/index.js
Normal 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;
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user