mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-21 21:22:11 -03:00
Compare commits
12 Commits
v0.9.15
...
modal-rewo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26884630d3 | ||
|
|
66e9d77c67 | ||
|
|
5ffca15172 | ||
|
|
4d9115339b | ||
|
|
469f7a1829 | ||
|
|
d27e3c8126 | ||
|
|
7bc63d7631 | ||
|
|
1606a3ff46 | ||
|
|
b313f36be9 | ||
|
|
fa3625ff72 | ||
|
|
895d13dc96 | ||
|
|
b7e0821f66 |
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*
|
||||
@@ -223,7 +223,11 @@
|
||||
"noCreditRequired": "Kein Credit erforderlich",
|
||||
"allowSellingGeneratedContent": "Verkauf erlaubt",
|
||||
"noTags": "Keine Tags",
|
||||
"clearAll": "Alle Filter löschen"
|
||||
"clearAll": "Alle Filter löschen",
|
||||
"any": "Beliebig",
|
||||
"all": "Alle",
|
||||
"tagLogicAny": "Jedes Tag abgleichen (ODER)",
|
||||
"tagLogicAll": "Alle Tags abgleichen (UND)"
|
||||
},
|
||||
"theme": {
|
||||
"toggle": "Theme wechseln",
|
||||
|
||||
@@ -223,7 +223,11 @@
|
||||
"noCreditRequired": "No Credit Required",
|
||||
"allowSellingGeneratedContent": "Allow Selling",
|
||||
"noTags": "No tags",
|
||||
"clearAll": "Clear All Filters"
|
||||
"clearAll": "Clear All Filters",
|
||||
"any": "Any",
|
||||
"all": "All",
|
||||
"tagLogicAny": "Match any tag (OR)",
|
||||
"tagLogicAll": "Match all tags (AND)"
|
||||
},
|
||||
"theme": {
|
||||
"toggle": "Toggle theme",
|
||||
@@ -907,7 +911,12 @@
|
||||
"viewOnCivitai": "View on Civitai",
|
||||
"viewOnCivitaiText": "View on Civitai",
|
||||
"viewCreatorProfile": "View Creator Profile",
|
||||
"openFileLocation": "Open File Location"
|
||||
"openFileLocation": "Open File Location",
|
||||
"viewParams": "View parameters",
|
||||
"setPreview": "Set as preview",
|
||||
"previewSet": "Preview updated successfully",
|
||||
"previewFailed": "Failed to update preview",
|
||||
"delete": "Delete"
|
||||
},
|
||||
"openFileLocation": {
|
||||
"success": "File location opened successfully",
|
||||
@@ -926,13 +935,15 @@
|
||||
"additionalNotes": "Additional Notes",
|
||||
"notesHint": "Press Enter to save, Shift+Enter for new line",
|
||||
"addNotesPlaceholder": "Add your notes here...",
|
||||
"aboutThisVersion": "About this version"
|
||||
"aboutThisVersion": "About this version",
|
||||
"triggerWords": "Trigger Words"
|
||||
},
|
||||
"notes": {
|
||||
"saved": "Notes saved successfully",
|
||||
"saveFailed": "Failed to save notes"
|
||||
},
|
||||
"usageTips": {
|
||||
"add": "Add",
|
||||
"addPresetParameter": "Add preset parameter...",
|
||||
"strengthMin": "Strength Min",
|
||||
"strengthMax": "Strength Max",
|
||||
@@ -941,17 +952,24 @@
|
||||
"clipStrength": "Clip Strength",
|
||||
"clipSkip": "Clip Skip",
|
||||
"valuePlaceholder": "Value",
|
||||
"add": "Add",
|
||||
"invalidRange": "Invalid range format. Use x.x-y.y"
|
||||
},
|
||||
"params": {
|
||||
"title": "Generation Parameters",
|
||||
"prompt": "Prompt",
|
||||
"negativePrompt": "Negative Prompt",
|
||||
"noData": "No generation data available",
|
||||
"promptCopied": "Prompt copied to clipboard"
|
||||
},
|
||||
"triggerWords": {
|
||||
"label": "Trigger Words",
|
||||
"noTriggerWordsNeeded": "No trigger word needed",
|
||||
"noTriggerWordsNeeded": "No trigger words needed",
|
||||
"edit": "Edit trigger words",
|
||||
"cancel": "Cancel editing",
|
||||
"save": "Save changes",
|
||||
"addPlaceholder": "Type to add or click suggestions below",
|
||||
"addPlaceholder": "Type to add trigger word...",
|
||||
"copyWord": "Copy trigger word",
|
||||
"copyAll": "Copy all trigger words",
|
||||
"deleteWord": "Delete trigger word",
|
||||
"suggestions": {
|
||||
"noSuggestions": "No suggestions available",
|
||||
@@ -961,6 +979,9 @@
|
||||
"wordSuggestions": "Word Suggestions",
|
||||
"wordsFound": "{count} words found",
|
||||
"loading": "Loading suggestions..."
|
||||
},
|
||||
"validation": {
|
||||
"duplicate": "This trigger word already exists"
|
||||
}
|
||||
},
|
||||
"description": {
|
||||
@@ -986,7 +1007,11 @@
|
||||
"previousWithShortcut": "Previous model (←)",
|
||||
"nextWithShortcut": "Next model (→)",
|
||||
"noPrevious": "No previous model available",
|
||||
"noNext": "No next model available"
|
||||
"noNext": "No next model available",
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"switchModel": "Switch model",
|
||||
"browseExamples": "Browse examples"
|
||||
},
|
||||
"license": {
|
||||
"noImageSell": "No selling generated content",
|
||||
@@ -998,6 +1023,23 @@
|
||||
"noReLicense": "Same permissions required",
|
||||
"restrictionsLabel": "License restrictions"
|
||||
},
|
||||
"examples": {
|
||||
"add": "Add",
|
||||
"addFirst": "Add your first example",
|
||||
"dropFiles": "Drop files here or click to browse",
|
||||
"supportedFormats": "Supports: JPG, PNG, WEBP, MP4, WEBM",
|
||||
"uploading": "Uploading...",
|
||||
"uploadSuccess": "Example uploaded successfully",
|
||||
"uploadFailed": "Failed to upload example",
|
||||
"confirmDelete": "Delete this example image?",
|
||||
"deleted": "Example deleted successfully",
|
||||
"deleteFailed": "Failed to delete example",
|
||||
"title": "Example",
|
||||
"empty": "No example images available"
|
||||
},
|
||||
"accordion": {
|
||||
"modelDescription": "Model Description"
|
||||
},
|
||||
"loading": {
|
||||
"exampleImages": "Loading example images...",
|
||||
"description": "Loading model description...",
|
||||
|
||||
@@ -223,7 +223,11 @@
|
||||
"noCreditRequired": "Sin crédito requerido",
|
||||
"allowSellingGeneratedContent": "Venta permitida",
|
||||
"noTags": "Sin etiquetas",
|
||||
"clearAll": "Limpiar todos los filtros"
|
||||
"clearAll": "Limpiar todos los filtros",
|
||||
"any": "Cualquiera",
|
||||
"all": "Todos",
|
||||
"tagLogicAny": "Coincidir con cualquier etiqueta (O)",
|
||||
"tagLogicAll": "Coincidir con todas las etiquetas (Y)"
|
||||
},
|
||||
"theme": {
|
||||
"toggle": "Cambiar tema",
|
||||
|
||||
@@ -223,7 +223,11 @@
|
||||
"noCreditRequired": "Crédit non requis",
|
||||
"allowSellingGeneratedContent": "Vente autorisée",
|
||||
"noTags": "Aucun tag",
|
||||
"clearAll": "Effacer tous les filtres"
|
||||
"clearAll": "Effacer tous les filtres",
|
||||
"any": "N'importe quel",
|
||||
"all": "Tous",
|
||||
"tagLogicAny": "Correspondre à n'importe quel tag (OU)",
|
||||
"tagLogicAll": "Correspondre à tous les tags (ET)"
|
||||
},
|
||||
"theme": {
|
||||
"toggle": "Basculer le thème",
|
||||
|
||||
@@ -223,7 +223,11 @@
|
||||
"noCreditRequired": "ללא קרדיט נדרש",
|
||||
"allowSellingGeneratedContent": "אפשר מכירה",
|
||||
"noTags": "ללא תגיות",
|
||||
"clearAll": "נקה את כל המסננים"
|
||||
"clearAll": "נקה את כל המסננים",
|
||||
"any": "כלשהו",
|
||||
"all": "כל התגים",
|
||||
"tagLogicAny": "התאם כל תג (או)",
|
||||
"tagLogicAll": "התאם את כל התגים (וגם)"
|
||||
},
|
||||
"theme": {
|
||||
"toggle": "החלף ערכת נושא",
|
||||
|
||||
@@ -223,7 +223,11 @@
|
||||
"noCreditRequired": "クレジット不要",
|
||||
"allowSellingGeneratedContent": "販売許可",
|
||||
"noTags": "タグなし",
|
||||
"clearAll": "すべてのフィルタをクリア"
|
||||
"clearAll": "すべてのフィルタをクリア",
|
||||
"any": "いずれか",
|
||||
"all": "すべて",
|
||||
"tagLogicAny": "いずれかのタグに一致 (OR)",
|
||||
"tagLogicAll": "すべてのタグに一致 (AND)"
|
||||
},
|
||||
"theme": {
|
||||
"toggle": "テーマの切り替え",
|
||||
|
||||
@@ -223,7 +223,11 @@
|
||||
"noCreditRequired": "크레딧 표기 없음",
|
||||
"allowSellingGeneratedContent": "판매 허용",
|
||||
"noTags": "태그 없음",
|
||||
"clearAll": "모든 필터 지우기"
|
||||
"clearAll": "모든 필터 지우기",
|
||||
"any": "아무",
|
||||
"all": "모두",
|
||||
"tagLogicAny": "모든 태그 일치 (OR)",
|
||||
"tagLogicAll": "모든 태그 일치 (AND)"
|
||||
},
|
||||
"theme": {
|
||||
"toggle": "테마 토글",
|
||||
|
||||
@@ -223,7 +223,11 @@
|
||||
"noCreditRequired": "Без указания авторства",
|
||||
"allowSellingGeneratedContent": "Продажа разрешена",
|
||||
"noTags": "Без тегов",
|
||||
"clearAll": "Очистить все фильтры"
|
||||
"clearAll": "Очистить все фильтры",
|
||||
"any": "Любой",
|
||||
"all": "Все",
|
||||
"tagLogicAny": "Совпадение с любым тегом (ИЛИ)",
|
||||
"tagLogicAll": "Совпадение со всеми тегами (И)"
|
||||
},
|
||||
"theme": {
|
||||
"toggle": "Переключить тему",
|
||||
|
||||
@@ -223,7 +223,11 @@
|
||||
"noCreditRequired": "无需署名",
|
||||
"allowSellingGeneratedContent": "允许销售",
|
||||
"noTags": "无标签",
|
||||
"clearAll": "清除所有筛选"
|
||||
"clearAll": "清除所有筛选",
|
||||
"any": "任一",
|
||||
"all": "全部",
|
||||
"tagLogicAny": "匹配任一标签 (或)",
|
||||
"tagLogicAll": "匹配所有标签 (与)"
|
||||
},
|
||||
"theme": {
|
||||
"toggle": "切换主题",
|
||||
|
||||
@@ -223,7 +223,11 @@
|
||||
"noCreditRequired": "無需署名",
|
||||
"allowSellingGeneratedContent": "允許銷售",
|
||||
"noTags": "無標籤",
|
||||
"clearAll": "清除所有篩選"
|
||||
"clearAll": "清除所有篩選",
|
||||
"any": "任一",
|
||||
"all": "全部",
|
||||
"tagLogicAny": "符合任一票籤 (或)",
|
||||
"tagLogicAll": "符合所有標籤 (與)"
|
||||
},
|
||||
"theme": {
|
||||
"toggle": "切換主題",
|
||||
|
||||
@@ -6,6 +6,7 @@ import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Awaitable, Callable, Dict, Iterable, List, Mapping, Optional
|
||||
@@ -269,6 +270,11 @@ class ModelListingHandler:
|
||||
request.query.get("update_available_only", "false").lower() == "true"
|
||||
)
|
||||
|
||||
# Tag logic: "any" (OR) or "all" (AND) for include tags
|
||||
tag_logic = request.query.get("tag_logic", "any").lower()
|
||||
if tag_logic not in ("any", "all"):
|
||||
tag_logic = "any"
|
||||
|
||||
# New license-based query filters
|
||||
credit_required = request.query.get("credit_required")
|
||||
if credit_required is not None:
|
||||
@@ -297,6 +303,7 @@ class ModelListingHandler:
|
||||
"fuzzy_search": fuzzy_search,
|
||||
"base_models": base_models,
|
||||
"tags": tag_filters,
|
||||
"tag_logic": tag_logic,
|
||||
"search_options": search_options,
|
||||
"hash_filters": hash_filters,
|
||||
"favorites_only": favorites_only,
|
||||
@@ -755,19 +762,22 @@ class ModelQueryHandler:
|
||||
|
||||
async def find_duplicate_models(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
filters = self._parse_duplicate_filters(request)
|
||||
duplicates = self._service.find_duplicate_hashes()
|
||||
result = []
|
||||
cache = await self._service.scanner.get_cached_data()
|
||||
|
||||
for sha256, paths in duplicates.items():
|
||||
group = {"hash": sha256, "models": []}
|
||||
# Collect all models in this group
|
||||
all_models = []
|
||||
for path in paths:
|
||||
model = next(
|
||||
(m for m in cache.raw_data if m["file_path"] == path), None
|
||||
)
|
||||
if model:
|
||||
group["models"].append(
|
||||
await self._service.format_response(model)
|
||||
)
|
||||
all_models.append(model)
|
||||
|
||||
# Include primary if not already in paths
|
||||
primary_path = self._service.get_path_by_hash(sha256)
|
||||
if primary_path and primary_path not in paths:
|
||||
primary_model = next(
|
||||
@@ -775,11 +785,25 @@ class ModelQueryHandler:
|
||||
None,
|
||||
)
|
||||
if primary_model:
|
||||
group["models"].insert(
|
||||
0, await self._service.format_response(primary_model)
|
||||
)
|
||||
all_models.insert(0, primary_model)
|
||||
|
||||
# Apply filters
|
||||
filtered = self._apply_duplicate_filters(all_models, filters)
|
||||
|
||||
# Sort: originals first, copies last
|
||||
sorted_models = self._sort_duplicate_group(filtered)
|
||||
|
||||
# Format response
|
||||
group = {"hash": sha256, "models": []}
|
||||
for model in sorted_models:
|
||||
group["models"].append(
|
||||
await self._service.format_response(model)
|
||||
)
|
||||
|
||||
# Only include groups with 2+ models after filtering
|
||||
if len(group["models"]) > 1:
|
||||
result.append(group)
|
||||
|
||||
return web.json_response(
|
||||
{"success": True, "duplicates": result, "count": len(result)}
|
||||
)
|
||||
@@ -792,6 +816,83 @@ class ModelQueryHandler:
|
||||
)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
def _parse_duplicate_filters(self, request: web.Request) -> Dict[str, Any]:
|
||||
"""Parse filter parameters from the request for duplicate finding."""
|
||||
return {
|
||||
"base_models": request.query.getall("base_model", []),
|
||||
"tag_include": request.query.getall("tag_include", []),
|
||||
"tag_exclude": request.query.getall("tag_exclude", []),
|
||||
"model_types": request.query.getall("model_type", []),
|
||||
"folder": request.query.get("folder"),
|
||||
"favorites_only": request.query.get("favorites_only", "").lower() == "true",
|
||||
}
|
||||
|
||||
def _apply_duplicate_filters(self, models: List[Dict[str, Any]], filters: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
"""Apply filters to a list of models within a duplicate group."""
|
||||
result = models
|
||||
|
||||
# Apply base model filter
|
||||
if filters.get("base_models"):
|
||||
base_set = set(filters["base_models"])
|
||||
result = [m for m in result if m.get("base_model") in base_set]
|
||||
|
||||
# Apply tag filters (include)
|
||||
for tag in filters.get("tag_include", []):
|
||||
if tag == "__no_tags__":
|
||||
result = [m for m in result if not m.get("tags")]
|
||||
else:
|
||||
result = [m for m in result if tag in (m.get("tags") or [])]
|
||||
|
||||
# Apply tag filters (exclude)
|
||||
for tag in filters.get("tag_exclude", []):
|
||||
if tag == "__no_tags__":
|
||||
result = [m for m in result if m.get("tags")]
|
||||
else:
|
||||
result = [m for m in result if tag not in (m.get("tags") or [])]
|
||||
|
||||
# Apply model type filter
|
||||
if filters.get("model_types"):
|
||||
type_set = {t.lower() for t in filters["model_types"]}
|
||||
result = [
|
||||
m for m in result if (m.get("model_type") or "").lower() in type_set
|
||||
]
|
||||
|
||||
# Apply folder filter
|
||||
if filters.get("folder"):
|
||||
folder = filters["folder"]
|
||||
result = [m for m in result if m.get("folder", "").startswith(folder)]
|
||||
|
||||
# Apply favorites filter
|
||||
if filters.get("favorites_only"):
|
||||
result = [m for m in result if m.get("favorite", False)]
|
||||
|
||||
return result
|
||||
|
||||
def _sort_duplicate_group(self, models: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""Sort models: originals first (left), copies (with -????. pattern) last (right)."""
|
||||
if len(models) <= 1:
|
||||
return models
|
||||
|
||||
min_len = min(len(m.get("file_name", "")) for m in models)
|
||||
|
||||
def copy_score(m):
|
||||
fn = m.get("file_name", "")
|
||||
score = 0
|
||||
# Match -0001.safetensors, -1234.safetensors etc.
|
||||
if re.search(r"-\d{4}\.", fn):
|
||||
score += 100
|
||||
# Match (1), (2) etc.
|
||||
if re.search(r"\(\d+\)", fn):
|
||||
score += 50
|
||||
# Match 'copy' in filename
|
||||
if "copy" in fn.lower():
|
||||
score += 50
|
||||
# Longer filenames are more likely copies
|
||||
score += len(fn) - min_len
|
||||
return (score, fn.lower())
|
||||
|
||||
return sorted(models, key=copy_score)
|
||||
|
||||
async def find_filename_conflicts(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
duplicates = self._service.find_duplicate_filenames()
|
||||
|
||||
@@ -81,6 +81,7 @@ class BaseModelService(ABC):
|
||||
update_available_only: bool = False,
|
||||
credit_required: Optional[bool] = None,
|
||||
allow_selling_generated_content: Optional[bool] = None,
|
||||
tag_logic: str = "any",
|
||||
**kwargs,
|
||||
) -> Dict:
|
||||
"""Get paginated and filtered model data"""
|
||||
@@ -109,6 +110,7 @@ class BaseModelService(ABC):
|
||||
tags=tags,
|
||||
favorites_only=favorites_only,
|
||||
search_options=search_options,
|
||||
tag_logic=tag_logic,
|
||||
)
|
||||
|
||||
if search:
|
||||
@@ -241,6 +243,7 @@ class BaseModelService(ABC):
|
||||
tags: Optional[Dict[str, str]] = None,
|
||||
favorites_only: bool = False,
|
||||
search_options: dict = None,
|
||||
tag_logic: str = "any",
|
||||
) -> List[Dict]:
|
||||
"""Apply common filters that work across all model types"""
|
||||
normalized_options = self.search_strategy.normalize_options(search_options)
|
||||
@@ -253,6 +256,7 @@ class BaseModelService(ABC):
|
||||
tags=tags,
|
||||
favorites_only=favorites_only,
|
||||
search_options=normalized_options,
|
||||
tag_logic=tag_logic,
|
||||
)
|
||||
return self.filter_set.apply(data, criteria)
|
||||
|
||||
|
||||
@@ -99,6 +99,7 @@ class FilterCriteria:
|
||||
favorites_only: bool = False
|
||||
search_options: Optional[Dict[str, Any]] = None
|
||||
model_types: Optional[Sequence[str]] = None
|
||||
tag_logic: str = "any" # "any" (OR) or "all" (AND)
|
||||
|
||||
|
||||
class ModelCacheRepository:
|
||||
@@ -300,11 +301,29 @@ class ModelFilterSet:
|
||||
include_tags = {tag for tag in tag_filters if tag}
|
||||
|
||||
if include_tags:
|
||||
tag_logic = criteria.tag_logic.lower() if criteria.tag_logic else "any"
|
||||
|
||||
def matches_include(item_tags):
|
||||
if not item_tags and "__no_tags__" in include_tags:
|
||||
return True
|
||||
return any(tag in include_tags for tag in (item_tags or []))
|
||||
if tag_logic == "all":
|
||||
# AND logic: item must have ALL include tags
|
||||
# Special case: __no_tags__ is handled separately
|
||||
non_special_tags = include_tags - {"__no_tags__"}
|
||||
if "__no_tags__" in include_tags:
|
||||
# If __no_tags__ is selected along with other tags,
|
||||
# treat it as "no tags OR (all other tags)"
|
||||
if not item_tags:
|
||||
return True
|
||||
# Otherwise, check if all non-special tags match
|
||||
if non_special_tags:
|
||||
return all(tag in (item_tags or []) for tag in non_special_tags)
|
||||
return True
|
||||
# Normal case: all tags must match
|
||||
return all(tag in (item_tags or []) for tag in non_special_tags)
|
||||
else:
|
||||
# OR logic (default): item must have ANY include tag
|
||||
return any(tag in include_tags for tag in (item_tags or []))
|
||||
|
||||
items = [item for item in items if matches_include(item.get("tags"))]
|
||||
|
||||
|
||||
@@ -28,6 +28,9 @@ CORE_USER_SETTING_KEYS: Tuple[str, ...] = (
|
||||
"folder_paths",
|
||||
)
|
||||
|
||||
# Threshold for aggressive cleanup: if file contains this many default keys, clean it up
|
||||
DEFAULT_KEYS_CLEANUP_THRESHOLD = 10
|
||||
|
||||
|
||||
DEFAULT_SETTINGS: Dict[str, Any] = {
|
||||
"civitai_api_key": "",
|
||||
@@ -95,6 +98,9 @@ class SettingsManager:
|
||||
if self._needs_initial_save:
|
||||
self._save_settings()
|
||||
self._needs_initial_save = False
|
||||
else:
|
||||
# Clean up existing settings file by removing default values
|
||||
self._cleanup_default_values_from_disk()
|
||||
|
||||
def _detect_standalone_mode(self) -> bool:
|
||||
"""Return ``True`` when running in standalone mode."""
|
||||
@@ -226,7 +232,7 @@ class SettingsManager:
|
||||
return merged
|
||||
|
||||
def _ensure_default_settings(self) -> None:
|
||||
"""Ensure all default settings keys exist"""
|
||||
"""Ensure all default settings keys exist in memory (but don't save defaults to disk)"""
|
||||
defaults = self._get_default_settings()
|
||||
updated_existing = False
|
||||
inserted_defaults = False
|
||||
@@ -265,10 +271,10 @@ class SettingsManager:
|
||||
self.settings[key] = value
|
||||
inserted_defaults = True
|
||||
|
||||
if updated_existing or (
|
||||
inserted_defaults and self._bootstrap_reason in {"invalid", "unreadable"}
|
||||
):
|
||||
# Save only if existing values were normalized/updated
|
||||
if updated_existing:
|
||||
self._save_settings()
|
||||
# Note: inserted_defaults no longer triggers save - defaults stay in memory only
|
||||
|
||||
def _migrate_to_library_registry(self) -> None:
|
||||
"""Ensure settings include the multi-library registry structure."""
|
||||
@@ -711,6 +717,42 @@ class SettingsManager:
|
||||
|
||||
self._startup_messages.append(payload)
|
||||
|
||||
def _cleanup_default_values_from_disk(self) -> None:
|
||||
"""Remove default values from existing settings.json to keep it clean.
|
||||
|
||||
Only performs cleanup if the file contains a significant number of default
|
||||
values (indicating it's "bloated"). Small files (like template-based configs)
|
||||
are preserved as-is to avoid unexpected changes.
|
||||
"""
|
||||
# Only cleanup existing files (not new ones)
|
||||
if self._bootstrap_reason == "missing" or self._original_disk_payload is None:
|
||||
return
|
||||
|
||||
defaults = self._get_default_settings()
|
||||
disk_keys = set(self._original_disk_payload.keys())
|
||||
|
||||
# Count how many keys on disk are set to their default values
|
||||
default_value_keys = set()
|
||||
for key in disk_keys:
|
||||
if key in CORE_USER_SETTING_KEYS:
|
||||
continue # Core keys don't count as "cleanup candidates"
|
||||
disk_value = self._original_disk_payload.get(key)
|
||||
default_value = defaults.get(key)
|
||||
# Compare using JSON serialization for complex objects
|
||||
if json.dumps(disk_value, sort_keys=True, default=str) == json.dumps(default_value, sort_keys=True, default=str):
|
||||
default_value_keys.add(key)
|
||||
|
||||
# Only cleanup if there are "many" default keys (indicating a bloated file)
|
||||
# This preserves small/template-based configs while cleaning up legacy bloated files
|
||||
if len(default_value_keys) >= DEFAULT_KEYS_CLEANUP_THRESHOLD:
|
||||
logger.info(
|
||||
"Cleaning up %d default value(s) from settings.json to keep it minimal",
|
||||
len(default_value_keys)
|
||||
)
|
||||
self._save_settings()
|
||||
# Update original payload to match what we just saved
|
||||
self._original_disk_payload = self._serialize_settings_for_disk()
|
||||
|
||||
def _collect_configuration_warnings(self) -> None:
|
||||
if not self._standalone_mode:
|
||||
return
|
||||
@@ -1101,7 +1143,12 @@ class SettingsManager:
|
||||
self._seed_template = None
|
||||
|
||||
def _serialize_settings_for_disk(self) -> Dict[str, Any]:
|
||||
"""Return the settings payload that should be persisted to disk."""
|
||||
"""Return the settings payload that should be persisted to disk.
|
||||
|
||||
Only saves settings that differ from defaults, keeping the config file
|
||||
clean and focused on user customizations. Default values are still
|
||||
available at runtime via _get_default_settings().
|
||||
"""
|
||||
|
||||
if self._bootstrap_reason == "missing":
|
||||
minimal: Dict[str, Any] = {}
|
||||
@@ -1115,7 +1162,25 @@ class SettingsManager:
|
||||
|
||||
return minimal
|
||||
|
||||
return copy.deepcopy(self.settings)
|
||||
# Only save settings that differ from defaults
|
||||
defaults = self._get_default_settings()
|
||||
minimal = {}
|
||||
|
||||
for key, value in self.settings.items():
|
||||
default_value = defaults.get(key)
|
||||
|
||||
# Core settings are always saved (even if equal to default)
|
||||
if key in CORE_USER_SETTING_KEYS:
|
||||
minimal[key] = copy.deepcopy(value)
|
||||
# Complex objects need deep comparison
|
||||
elif isinstance(value, (dict, list)) and default_value is not None:
|
||||
if json.dumps(value, sort_keys=True, default=str) != json.dumps(default_value, sort_keys=True, default=str):
|
||||
minimal[key] = copy.deepcopy(value)
|
||||
# Simple values use direct comparison
|
||||
elif value != default_value:
|
||||
minimal[key] = copy.deepcopy(value)
|
||||
|
||||
return minimal
|
||||
|
||||
def get_libraries(self) -> Dict[str, Dict[str, Any]]:
|
||||
"""Return a copy of the registered libraries."""
|
||||
|
||||
@@ -65,6 +65,8 @@ body {
|
||||
--space-1: calc(8px * 1);
|
||||
--space-2: calc(8px * 2);
|
||||
--space-3: calc(8px * 3);
|
||||
--space-4: calc(8px * 4);
|
||||
--space-5: calc(8px * 5);
|
||||
|
||||
/* Z-index Scale */
|
||||
--z-base: 10;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
167
static/css/components/model-modal/overlay.css
Normal file
167
static/css/components/model-modal/overlay.css
Normal file
@@ -0,0 +1,167 @@
|
||||
/* 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) !important;
|
||||
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 {
|
||||
opacity: 1 !important;
|
||||
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,
|
||||
.metadata {
|
||||
opacity: 1;
|
||||
transition: opacity 0.15s ease-out;
|
||||
}
|
||||
|
||||
.showcase.transitioning,
|
||||
.metadata.transitioning {
|
||||
opacity: 0;
|
||||
}
|
||||
272
static/css/components/model-modal/recipes.css
Normal file
272
static/css/components/model-modal/recipes.css
Normal file
@@ -0,0 +1,272 @@
|
||||
/* Recipes Tab Styles */
|
||||
|
||||
.recipes-loading,
|
||||
.recipes-error,
|
||||
.recipes-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-8) var(--space-4);
|
||||
text-align: center;
|
||||
color: var(--text-color);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.recipes-loading i,
|
||||
.recipes-error i,
|
||||
.recipes-empty i {
|
||||
font-size: 2rem;
|
||||
margin-bottom: var(--space-3);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.recipes-error i {
|
||||
color: var(--lora-error);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.recipes-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
padding: var(--space-3);
|
||||
background: var(--lora-surface);
|
||||
border-bottom: 1px solid var(--lora-border);
|
||||
margin: calc(-1 * var(--space-2)) calc(-1 * var(--space-2)) var(--space-2);
|
||||
}
|
||||
|
||||
.recipes-header__text {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.recipes-header__eyebrow {
|
||||
display: block;
|
||||
font-size: 0.75em;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
opacity: 0.6;
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.recipes-header h3 {
|
||||
margin: 0 0 var(--space-1);
|
||||
font-size: 1.1em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.recipes-header__description {
|
||||
margin: 0;
|
||||
font-size: 0.85em;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.recipes-header__view-all {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
background: transparent;
|
||||
border: 1px solid var(--lora-border);
|
||||
border-radius: var(--border-radius-sm);
|
||||
color: var(--text-color);
|
||||
font-size: 0.8em;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.recipes-header__view-all:hover {
|
||||
border-color: var(--lora-accent);
|
||||
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1);
|
||||
}
|
||||
|
||||
/* Recipe Cards Grid */
|
||||
.recipes-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-1);
|
||||
}
|
||||
|
||||
/* Recipe Card */
|
||||
.recipe-card {
|
||||
background: var(--lora-surface);
|
||||
border: 1px solid var(--lora-border);
|
||||
border-radius: var(--border-radius-sm);
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.recipe-card:hover {
|
||||
border-color: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.3);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.recipe-card:focus {
|
||||
outline: none;
|
||||
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.2);
|
||||
}
|
||||
|
||||
/* Recipe Card Media */
|
||||
.recipe-card__media {
|
||||
position: relative;
|
||||
aspect-ratio: 16 / 10;
|
||||
overflow: hidden;
|
||||
background: var(--bg-color);
|
||||
}
|
||||
|
||||
.recipe-card__media img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
|
||||
.recipe-card:hover .recipe-card__media img {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.recipe-card__media-top {
|
||||
position: absolute;
|
||||
top: var(--space-2);
|
||||
right: var(--space-2);
|
||||
display: flex;
|
||||
gap: var(--space-1);
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.recipe-card:hover .recipe-card__media-top {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.recipe-card__copy {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.recipe-card__copy:hover {
|
||||
background: var(--lora-accent);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
/* Recipe Card Body */
|
||||
.recipe-card__body {
|
||||
padding: var(--space-2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.recipe-card__title {
|
||||
margin: 0 0 var(--space-1);
|
||||
font-size: 0.9em;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.recipe-card__meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-1);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.recipe-card__badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
font-size: 0.7em;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.recipe-card__badge--base {
|
||||
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1);
|
||||
color: var(--lora-accent);
|
||||
}
|
||||
|
||||
.recipe-card__badge--empty {
|
||||
background: var(--lora-border);
|
||||
color: var(--text-color);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.recipe-card__badge--ready {
|
||||
background: oklch(60% 0.15 145);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.recipe-card__badge--missing {
|
||||
background: oklch(60% 0.15 30);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.recipe-card__cta {
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-top: var(--space-2);
|
||||
border-top: 1px solid var(--lora-border);
|
||||
font-size: 0.8em;
|
||||
font-weight: 500;
|
||||
color: var(--lora-accent);
|
||||
opacity: 0.8;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.recipe-card:hover .recipe-card__cta {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.recipe-card__cta i {
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.recipe-card:hover .recipe-card__cta i {
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
/* Mobile Adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.recipes-header {
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.recipes-header__view-all {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.recipes-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.recipe-card__media-top {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
566
static/css/components/model-modal/showcase.css
Normal file
566
static/css/components/model-modal/showcase.css
Normal file
@@ -0,0 +1,566 @@
|
||||
/* 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;
|
||||
}
|
||||
|
||||
/* Media container for images and videos */
|
||||
.showcase__media-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.showcase-media-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.showcase__media-inner {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.showcase__media {
|
||||
max-width: 100%;
|
||||
max-height: 70vh;
|
||||
object-fit: contain;
|
||||
border-radius: var(--border-radius-sm);
|
||||
transition: filter 0.2s ease, opacity 0.3s ease;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.showcase__media.loaded {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.showcase__media.blurred {
|
||||
filter: blur(25px);
|
||||
}
|
||||
|
||||
/* NSFW notice for main media - redesigned to avoid conflicts with card.css */
|
||||
.showcase__nsfw-notice {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
text-align: center;
|
||||
color: white;
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
padding: var(--space-4) var(--space-5);
|
||||
border-radius: var(--border-radius-base);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
z-index: 5;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.showcase__nsfw-notice-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.showcase__nsfw-notice-text {
|
||||
margin: 0;
|
||||
font-size: 1.1em;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
/* Show content button in NSFW notice - styled like card.css show-content-btn */
|
||||
.showcase__nsfw-show-btn {
|
||||
background: var(--lora-accent);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: var(--border-radius-xs);
|
||||
padding: 6px var(--space-3);
|
||||
cursor: pointer;
|
||||
font-size: 0.9em;
|
||||
font-weight: 500;
|
||||
transition: background-color 0.2s ease, transform 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.showcase__nsfw-show-btn:hover {
|
||||
background: oklch(58% 0.28 256);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.showcase__nsfw-show-btn i {
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
/* Control button active state for blur toggle */
|
||||
.showcase__control-btn.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Video indicator for thumbnails */
|
||||
.thumbnail-rail__video-indicator {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
color: white;
|
||||
font-size: 1.5rem;
|
||||
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.5);
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
/* NSFW blur for thumbnails */
|
||||
.thumbnail-rail__item.nsfw-blur img {
|
||||
filter: blur(8px);
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
|
||||
/* Image counter */
|
||||
.showcase__counter {
|
||||
position: absolute;
|
||||
top: var(--space-2);
|
||||
left: var(--space-2);
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
color: white;
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border-radius: var(--border-radius-xs);
|
||||
font-size: 0.85em;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
opacity: 0.8;
|
||||
transition: opacity 0.2s ease;
|
||||
pointer-events: none;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.showcase__image-wrapper:hover .showcase__counter {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.showcase__counter-current {
|
||||
font-weight: 600;
|
||||
min-width: 2ch;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.showcase__counter-separator {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.showcase__counter-total {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
/* Active state for toggle buttons */
|
||||
.showcase__control-btn.active {
|
||||
background: var(--lora-accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.showcase__control-btn.active:hover {
|
||||
background: var(--lora-accent-hover, #3182ce);
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
|
||||
/* Skeleton loading state */
|
||||
.showcase__skeleton {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--lora-surface);
|
||||
}
|
||||
|
||||
.skeleton-animation {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-3);
|
||||
color: var(--text-color);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.skeleton-spinner {
|
||||
font-size: 2.5rem;
|
||||
color: var(--lora-accent);
|
||||
}
|
||||
|
||||
/* Error state */
|
||||
.showcase__error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: var(--lora-error);
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.showcase__error i {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.showcase__error p {
|
||||
margin: 0;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
/* 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);
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Lazy Loading Styles
|
||||
============================================ */
|
||||
|
||||
/* Thumbnail lazy loading placeholder */
|
||||
.thumbnail-rail__item img {
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
background: var(--lora-surface);
|
||||
}
|
||||
|
||||
/* Loaded state */
|
||||
.thumbnail-rail__item img.loaded {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Loading state with skeleton animation */
|
||||
.thumbnail-rail__item img.lazy-load {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--lora-surface) 25%,
|
||||
var(--lora-border) 50%,
|
||||
var(--lora-surface) 75%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: lazy-loading-shimmer 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes lazy-loading-shimmer {
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Error state for failed loads */
|
||||
.thumbnail-rail__item img.load-error {
|
||||
opacity: 0.3;
|
||||
background: var(--lora-error);
|
||||
}
|
||||
|
||||
/* Cached image - subtle highlight */
|
||||
.thumbnail-rail__item img[data-cached="true"] {
|
||||
border: 1px solid var(--lora-accent);
|
||||
}
|
||||
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 */
|
||||
}
|
||||
}
|
||||
151
static/css/components/model-modal/thumbnail-rail.css
Normal file
151
static/css/components/model-modal/thumbnail-rail.css
Normal file
@@ -0,0 +1,151 @@
|
||||
/* 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(--lora-surface);
|
||||
}
|
||||
|
||||
.thumbnail-rail__item img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.thumbnail-rail__item img.loaded {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
/* NSFW blur for thumbnails - BEM naming to avoid conflicts with global .nsfw-blur */
|
||||
.thumbnail-rail__item--nsfw-blurred img {
|
||||
filter: blur(8px);
|
||||
}
|
||||
|
||||
/* Legacy support for old class names (deprecated) */
|
||||
.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;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
}
|
||||
163
static/css/components/model-modal/upload.css
Normal file
163
static/css/components/model-modal/upload.css
Normal file
@@ -0,0 +1,163 @@
|
||||
/* Upload Area Styles */
|
||||
|
||||
.upload-area {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--card-bg);
|
||||
border-top: 1px solid var(--lora-border);
|
||||
transform: translateY(100%);
|
||||
transition: transform 0.3s ease;
|
||||
z-index: 10;
|
||||
max-height: 50%;
|
||||
}
|
||||
|
||||
.upload-area.visible {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.upload-area__content {
|
||||
padding: var(--space-4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
/* Dropzone */
|
||||
.upload-area__dropzone {
|
||||
border: 2px dashed var(--lora-border);
|
||||
border-radius: var(--border-radius-md);
|
||||
padding: var(--space-6);
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.upload-area__dropzone:hover {
|
||||
border-color: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.5);
|
||||
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.02);
|
||||
}
|
||||
|
||||
.upload-area__dropzone.dragover {
|
||||
border-color: var(--lora-accent);
|
||||
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.08);
|
||||
}
|
||||
|
||||
.upload-area__input {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Placeholder */
|
||||
.upload-area__placeholder {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.upload-area__placeholder i {
|
||||
font-size: 2.5rem;
|
||||
color: var(--lora-accent);
|
||||
opacity: 0.6;
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.upload-area__title {
|
||||
margin: 0 0 var(--space-1);
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.upload-area__hint {
|
||||
margin: 0;
|
||||
font-size: 0.8em;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* Uploading State */
|
||||
.upload-area__uploading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.upload-area__uploading i {
|
||||
font-size: 2rem;
|
||||
color: var(--lora-accent);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.upload-area__uploading p {
|
||||
margin: 0;
|
||||
color: var(--text-color);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Actions */
|
||||
.upload-area__actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.upload-area__cancel {
|
||||
padding: var(--space-2) var(--space-4);
|
||||
background: transparent;
|
||||
border: 1px solid var(--lora-border);
|
||||
border-radius: var(--border-radius-sm);
|
||||
color: var(--text-color);
|
||||
font-size: 0.9em;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.upload-area__cancel:hover {
|
||||
border-color: var(--lora-error);
|
||||
color: var(--lora-error);
|
||||
}
|
||||
|
||||
/* Add Button in Empty State */
|
||||
.showcase__add-btn {
|
||||
margin-top: var(--space-4);
|
||||
padding: var(--space-2) var(--space-4);
|
||||
background: var(--lora-accent);
|
||||
border: none;
|
||||
border-radius: var(--border-radius-sm);
|
||||
color: white;
|
||||
font-size: 0.9em;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.showcase__add-btn:hover {
|
||||
opacity: 0.9;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* Mobile Adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.upload-area {
|
||||
max-height: 60%;
|
||||
}
|
||||
|
||||
.upload-area__content {
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
.upload-area__dropzone {
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.upload-area__placeholder i {
|
||||
font-size: 2rem;
|
||||
}
|
||||
}
|
||||
378
static/css/components/model-modal/versions.css
Normal file
378
static/css/components/model-modal/versions.css
Normal file
@@ -0,0 +1,378 @@
|
||||
/* Versions Tab Styles */
|
||||
|
||||
.versions-loading,
|
||||
.versions-error,
|
||||
.versions-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-8) var(--space-4);
|
||||
text-align: center;
|
||||
color: var(--text-color);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.versions-loading i,
|
||||
.versions-error i,
|
||||
.versions-empty i {
|
||||
font-size: 2rem;
|
||||
margin-bottom: var(--space-3);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.versions-error i {
|
||||
color: var(--lora-error);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.versions-empty-filter {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* Toolbar */
|
||||
.versions-toolbar {
|
||||
padding: var(--space-3);
|
||||
background: var(--lora-surface);
|
||||
border-bottom: 1px solid var(--lora-border);
|
||||
margin: calc(-1 * var(--space-2)) calc(-1 * var(--space-2)) var(--space-2);
|
||||
}
|
||||
|
||||
.versions-toolbar-info-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.versions-toolbar-info-heading h3 {
|
||||
margin: 0;
|
||||
font-size: 1em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.versions-toolbar-info p {
|
||||
margin: 0;
|
||||
font-size: 0.85em;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.versions-toolbar-actions {
|
||||
margin-top: var(--space-2);
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.versions-filter-toggle {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: 1px solid var(--lora-border);
|
||||
border-radius: var(--border-radius-sm);
|
||||
color: var(--text-color);
|
||||
cursor: pointer;
|
||||
opacity: 0.6;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.versions-filter-toggle:hover {
|
||||
opacity: 1;
|
||||
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1);
|
||||
}
|
||||
|
||||
.versions-filter-toggle.active {
|
||||
opacity: 1;
|
||||
background: var(--lora-accent);
|
||||
border-color: var(--lora-accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.versions-toolbar-btn {
|
||||
padding: var(--space-1) var(--space-3);
|
||||
border-radius: var(--border-radius-sm);
|
||||
font-size: 0.85em;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
border: 1px solid var(--lora-border);
|
||||
}
|
||||
|
||||
.versions-toolbar-btn-primary {
|
||||
background: var(--lora-accent);
|
||||
border-color: var(--lora-accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.versions-toolbar-btn-primary:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* Version Cards List */
|
||||
.versions-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
/* Version Card */
|
||||
.version-card {
|
||||
display: grid;
|
||||
grid-template-columns: 80px 1fr auto;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3);
|
||||
background: var(--lora-surface);
|
||||
border: 1px solid var(--lora-border);
|
||||
border-radius: var(--border-radius-sm);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.version-card:hover {
|
||||
border-color: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.3);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.version-card.is-current {
|
||||
border-color: var(--lora-accent);
|
||||
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.05);
|
||||
}
|
||||
|
||||
.version-card.is-clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.version-card.is-clickable:hover {
|
||||
border-color: var(--lora-accent);
|
||||
}
|
||||
|
||||
/* Version Media */
|
||||
.version-media {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
overflow: hidden;
|
||||
background: var(--bg-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.version-media img,
|
||||
.version-media video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.version-media-placeholder {
|
||||
font-size: 0.75em;
|
||||
color: var(--text-color);
|
||||
opacity: 0.5;
|
||||
text-align: center;
|
||||
padding: var(--space-1);
|
||||
}
|
||||
|
||||
/* Version Details */
|
||||
.version-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.version-name {
|
||||
font-weight: 600;
|
||||
font-size: 0.95em;
|
||||
margin-bottom: var(--space-1);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.version-badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-1);
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.version-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
font-size: 0.7em;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.version-badge-current {
|
||||
background: var(--lora-accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.version-badge-success {
|
||||
background: var(--lora-success);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.version-badge-info {
|
||||
background: var(--badge-update-bg);
|
||||
color: var(--badge-update-text);
|
||||
}
|
||||
|
||||
.version-badge-muted {
|
||||
background: var(--lora-border);
|
||||
color: var(--text-color);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.version-meta {
|
||||
font-size: 0.8em;
|
||||
opacity: 0.7;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.version-meta-separator {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.version-meta-primary {
|
||||
color: var(--lora-accent);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Version Actions */
|
||||
.version-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.version-action {
|
||||
padding: var(--space-1) var(--space-3);
|
||||
border-radius: var(--border-radius-xs);
|
||||
font-size: 0.8em;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
border: 1px solid transparent;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.version-action-primary {
|
||||
background: var(--lora-accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.version-action-primary:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.version-action-danger {
|
||||
background: transparent;
|
||||
border-color: var(--lora-error);
|
||||
color: var(--lora-error);
|
||||
}
|
||||
|
||||
.version-action-danger:hover {
|
||||
background: var(--lora-error);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.version-action-ghost {
|
||||
background: transparent;
|
||||
border-color: var(--lora-border);
|
||||
color: var(--text-color);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.version-action-ghost:hover {
|
||||
opacity: 1;
|
||||
border-color: var(--text-color);
|
||||
}
|
||||
|
||||
.version-action:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Delete Modal for Version */
|
||||
.version-delete-modal .delete-model-info {
|
||||
display: grid;
|
||||
grid-template-columns: 100px 1fr;
|
||||
gap: var(--space-3);
|
||||
margin: var(--space-3) 0;
|
||||
padding: var(--space-3);
|
||||
background: var(--lora-surface);
|
||||
border-radius: var(--border-radius-sm);
|
||||
}
|
||||
|
||||
.version-delete-modal .delete-preview {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
overflow: hidden;
|
||||
background: var(--bg-color);
|
||||
}
|
||||
|
||||
.version-delete-modal .delete-preview img,
|
||||
.version-delete-modal .delete-preview video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.version-delete-modal .delete-info h3 {
|
||||
margin: 0 0 var(--space-1);
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.version-delete-modal .version-base-model {
|
||||
margin: 0;
|
||||
opacity: 0.7;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
/* Mobile Adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.version-card {
|
||||
grid-template-columns: 60px 1fr auto;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2);
|
||||
}
|
||||
|
||||
.version-media {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
}
|
||||
|
||||
.version-name {
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.version-actions {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.version-action {
|
||||
padding: 4px 8px;
|
||||
font-size: 0.75em;
|
||||
}
|
||||
|
||||
.versions-toolbar-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.versions-toolbar-btn {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
@@ -673,6 +673,57 @@
|
||||
|
||||
|
||||
|
||||
/* Tag Logic Toggle Styles */
|
||||
.filter-section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.filter-section-header h4 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.tag-logic-toggle {
|
||||
display: flex;
|
||||
background-color: var(--lora-surface);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tag-logic-option {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 2px 8px;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
color: var(--text-color);
|
||||
opacity: 0.7;
|
||||
transition: all 0.2s ease;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.tag-logic-option:hover {
|
||||
opacity: 1;
|
||||
background-color: var(--lora-surface-hover);
|
||||
}
|
||||
|
||||
.tag-logic-option.active {
|
||||
background-color: var(--lora-accent);
|
||||
color: white;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.tag-logic-option:first-child {
|
||||
border-right: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.tag-logic-option.active:first-child {
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
/* Mobile adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.search-options-panel,
|
||||
|
||||
@@ -27,6 +27,18 @@
|
||||
@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';
|
||||
|
||||
/* Model Modal Phase 2 - Tabs and Upload */
|
||||
@import 'components/model-modal/versions.css';
|
||||
@import 'components/model-modal/recipes.css';
|
||||
@import 'components/model-modal/upload.css';
|
||||
@import 'components/shared/edit-metadata.css';
|
||||
@import 'components/search-filter.css';
|
||||
@import 'components/bulk.css';
|
||||
|
||||
@@ -924,6 +924,11 @@ export class BaseModelApiClient {
|
||||
params.append('model_type', type);
|
||||
});
|
||||
}
|
||||
|
||||
// Add tag logic parameter (any = OR, all = AND)
|
||||
if (pageState.filters.tagLogic) {
|
||||
params.append('tag_logic', pageState.filters.tagLogic);
|
||||
}
|
||||
}
|
||||
|
||||
this._addModelSpecificParams(params, pageState);
|
||||
|
||||
@@ -48,15 +48,18 @@ export class ModelDuplicatesManager {
|
||||
// Method to check for duplicates count using existing endpoint
|
||||
async checkDuplicatesCount() {
|
||||
try {
|
||||
const params = this._buildFilterQueryParams();
|
||||
const endpoint = `/api/lm/${this.modelType}/find-duplicates`;
|
||||
const response = await fetch(endpoint);
|
||||
|
||||
const url = params.toString() ? `${endpoint}?${params}` : endpoint;
|
||||
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to get duplicates count: ${response.statusText}`);
|
||||
}
|
||||
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
|
||||
if (data.success) {
|
||||
const duplicatesCount = (data.duplicates || []).length;
|
||||
this.updateDuplicatesBadge(duplicatesCount);
|
||||
@@ -103,29 +106,34 @@ export class ModelDuplicatesManager {
|
||||
|
||||
async findDuplicates() {
|
||||
try {
|
||||
// Determine API endpoint based on model type
|
||||
const params = this._buildFilterQueryParams();
|
||||
const endpoint = `/api/lm/${this.modelType}/find-duplicates`;
|
||||
|
||||
const response = await fetch(endpoint);
|
||||
const url = params.toString() ? `${endpoint}?${params}` : endpoint;
|
||||
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to find duplicates: ${response.statusText}`);
|
||||
}
|
||||
|
||||
|
||||
const data = await response.json();
|
||||
if (!data.success) {
|
||||
throw new Error(data.error || 'Unknown error finding duplicates');
|
||||
}
|
||||
|
||||
|
||||
this.duplicateGroups = data.duplicates || [];
|
||||
|
||||
|
||||
// Update the badge with the current count
|
||||
this.updateDuplicatesBadge(this.duplicateGroups.length);
|
||||
|
||||
|
||||
if (this.duplicateGroups.length === 0) {
|
||||
showToast('toast.duplicates.noDuplicatesFound', { type: this.modelType }, 'info');
|
||||
// If already in duplicate mode, exit to clear the display
|
||||
if (this.inDuplicateMode) {
|
||||
this.exitDuplicateMode();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
this.enterDuplicateMode();
|
||||
return true;
|
||||
} catch (error) {
|
||||
@@ -134,6 +142,51 @@ export class ModelDuplicatesManager {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build query parameters from current filter state for duplicate finding.
|
||||
* @returns {URLSearchParams} The query parameters to append to the API endpoint
|
||||
*/
|
||||
_buildFilterQueryParams() {
|
||||
const params = new URLSearchParams();
|
||||
const pageState = getCurrentPageState();
|
||||
const filters = pageState?.filters;
|
||||
|
||||
if (!filters) return params;
|
||||
|
||||
// Base model filters
|
||||
if (filters.baseModel && Array.isArray(filters.baseModel)) {
|
||||
filters.baseModel.forEach(m => params.append('base_model', m));
|
||||
}
|
||||
|
||||
// Tag filters (tri-state: include/exclude)
|
||||
if (filters.tags && typeof filters.tags === 'object') {
|
||||
Object.entries(filters.tags).forEach(([tag, state]) => {
|
||||
if (state === 'include') {
|
||||
params.append('tag_include', tag);
|
||||
} else if (state === 'exclude') {
|
||||
params.append('tag_exclude', tag);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Model type filters
|
||||
if (filters.modelTypes && Array.isArray(filters.modelTypes)) {
|
||||
filters.modelTypes.forEach(t => params.append('model_type', t));
|
||||
}
|
||||
|
||||
// Folder filter (from active folder state)
|
||||
if (pageState.activeFolder) {
|
||||
params.append('folder', pageState.activeFolder);
|
||||
}
|
||||
|
||||
// Favorites filter
|
||||
if (pageState.showFavoritesOnly) {
|
||||
params.append('favorites_only', 'true');
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
enterDuplicateMode() {
|
||||
this.inDuplicateMode = true;
|
||||
|
||||
871
static/js/components/model-modal/MetadataPanel.js
Normal file
871
static/js/components/model-modal/MetadataPanel.js
Normal file
@@ -0,0 +1,871 @@
|
||||
/**
|
||||
* 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 (Description, Versions, Recipes)
|
||||
*/
|
||||
|
||||
import { escapeHtml, formatFileSize } from '../shared/utils.js';
|
||||
import { translate } from '../../utils/i18nHelpers.js';
|
||||
import { showToast } from '../../utils/uiHelpers.js';
|
||||
import { getModelApiClient } from '../../api/modelApiFactory.js';
|
||||
import { VersionsTab } from './VersionsTab.js';
|
||||
import { RecipesTab } from './RecipesTab.js';
|
||||
|
||||
export class MetadataPanel {
|
||||
constructor(container) {
|
||||
this.element = container;
|
||||
this.model = null;
|
||||
this.modelType = null;
|
||||
this.activeTab = 'description';
|
||||
this.versionsTab = null;
|
||||
this.recipesTab = null;
|
||||
this.notesDebounceTimer = null;
|
||||
this.isEditingUsageTips = false;
|
||||
this.isEditingTriggerWords = false;
|
||||
this.editingTriggerWords = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.editModelName', {}, 'Edit model 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.openFileLocation', {}, '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') },
|
||||
];
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
if (allowed.has('sell')) {
|
||||
allowed.add('rent');
|
||||
allowed.add('rentcivit');
|
||||
allowed.add('image');
|
||||
}
|
||||
if (allowed.has('rent')) {
|
||||
allowed.add('rentcivit');
|
||||
}
|
||||
|
||||
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 with editing
|
||||
*/
|
||||
renderLoraSpecific() {
|
||||
const m = this.model;
|
||||
const usageTips = m.usage_tips ? JSON.parse(m.usage_tips) : {};
|
||||
const triggerWords = this.isEditingTriggerWords
|
||||
? this.editingTriggerWords
|
||||
: (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>
|
||||
${!this.isEditingUsageTips ? `
|
||||
<button class="metadata__section-edit" data-action="edit-usage-tips" title="${translate('modals.model.usageTips.add', {}, 'Add usage tip')}">
|
||||
<i class="fas fa-plus"></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-action="remove-usage-tip" title="${translate('common.actions.delete', {}, 'Delete')}">
|
||||
${escapeHtml(key)}: ${escapeHtml(String(value))}
|
||||
</span>
|
||||
`).join('')}
|
||||
${this.isEditingUsageTips ? this.renderUsageTipEditor() : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="metadata__section">
|
||||
<div class="metadata__section-header">
|
||||
<span class="metadata__section-title">${translate('modals.model.triggerWords.label', {}, 'Trigger Words')}</span>
|
||||
<div class="metadata__section-actions">
|
||||
${!this.isEditingTriggerWords ? `
|
||||
<button class="metadata__section-edit" data-action="copy-trigger-words" title="${translate('modals.model.triggerWords.copyWord', {}, 'Copy all trigger words')}">
|
||||
<i class="fas fa-copy"></i>
|
||||
</button>
|
||||
<button class="metadata__section-edit" data-action="edit-trigger-words" title="${translate('modals.model.triggerWords.edit', {}, 'Edit trigger words')}">
|
||||
<i class="fas fa-pencil-alt"></i>
|
||||
</button>
|
||||
` : `
|
||||
<button class="metadata__section-edit" data-action="cancel-trigger-words" title="${translate('common.actions.cancel', {}, 'Cancel')}">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
<button class="metadata__section-edit metadata__section-edit--primary" data-action="save-trigger-words" title="${translate('common.actions.save', {}, 'Save')}">
|
||||
<i class="fas fa-check"></i>
|
||||
</button>
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
<div class="metadata__tags--editable">
|
||||
${triggerWords.map(word => `
|
||||
<span class="metadata__tag ${this.isEditingTriggerWords ? 'metadata__tag--removable' : 'metadata__tag--editable'}"
|
||||
data-word="${escapeHtml(word)}"
|
||||
${this.isEditingTriggerWords ? 'data-action="remove-trigger-word"' : 'data-action="copy-trigger-word"'}
|
||||
title="${this.isEditingTriggerWords ? translate('common.actions.delete', {}, 'Delete') : translate('modals.model.triggerWords.copyWord', {}, 'Copy trigger word')}">
|
||||
${escapeHtml(word)}
|
||||
${this.isEditingTriggerWords ? '<i class="fas fa-times"></i>' : ''}
|
||||
</span>
|
||||
`).join('')}
|
||||
${this.isEditingTriggerWords ? `
|
||||
<input type="text"
|
||||
class="metadata__tag-input"
|
||||
placeholder="${translate('modals.model.triggerWords.addPlaceholder', {}, 'Type to add...')}"
|
||||
data-action="add-trigger-word-input"
|
||||
autofocus>
|
||||
` : triggerWords.length === 0 ? `
|
||||
<span class="metadata__tag metadata__tag--placeholder">${translate('modals.model.triggerWords.noTriggerWordsNeeded', {}, 'No trigger words needed')}</span>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render usage tip editor
|
||||
*/
|
||||
renderUsageTipEditor() {
|
||||
return `
|
||||
<div class="usage-tip-editor">
|
||||
<select class="usage-tip-key" data-action="usage-tip-key-change">
|
||||
<option value="">${translate('modals.model.usageTips.addPresetParameter', {}, 'Select parameter...')}</option>
|
||||
<option value="strength">${translate('modals.model.usageTips.strength', {}, 'Strength')}</option>
|
||||
<option value="strength_min">${translate('modals.model.usageTips.strengthMin', {}, 'Strength Min')}</option>
|
||||
<option value="strength_max">${translate('modals.model.usageTips.strengthMax', {}, 'Strength Max')}</option>
|
||||
<option value="clip_strength">${translate('modals.model.usageTips.clipStrength', {}, 'Clip Strength')}</option>
|
||||
<option value="clip_skip">${translate('modals.model.usageTips.clipSkip', {}, 'Clip Skip')}</option>
|
||||
</select>
|
||||
<input type="text"
|
||||
class="usage-tip-value"
|
||||
placeholder="${translate('modals.model.usageTips.valuePlaceholder', {}, 'Value')}"
|
||||
data-action="usage-tip-value-input">
|
||||
<button class="usage-tip-add" data-action="add-usage-tip">
|
||||
<i class="fas fa-check"></i>
|
||||
</button>
|
||||
<button class="usage-tip-cancel" data-action="cancel-usage-tips">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render notes section
|
||||
*/
|
||||
renderNotes(notes) {
|
||||
return `
|
||||
<div class="metadata__section metadata__section--notes">
|
||||
<div class="metadata__section-header">
|
||||
<span class="metadata__section-title">${translate('modals.model.metadata.additionalNotes', {}, 'Notes')}</span>
|
||||
<span class="metadata__save-indicator" data-save-indicator style="display: none;">
|
||||
<i class="fas fa-check"></i> Saved
|
||||
</span>
|
||||
</div>
|
||||
<textarea class="metadata__notes"
|
||||
placeholder="${translate('modals.model.metadata.addNotesPlaceholder', {}, 'Add your notes here...')}"
|
||||
data-action="notes-input">${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.metadata.aboutThisVersion', {}, '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.noDescription', {}, '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.noDescription', {}, 'No description available')}</p>
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-panel ${this.activeTab === 'versions' ? 'active' : ''}" data-panel="versions">
|
||||
<div class="versions-tab-container"></div>
|
||||
</div>
|
||||
|
||||
${this.modelType === 'loras' ? `
|
||||
<div class="tab-panel ${this.activeTab === 'recipes' ? 'active' : ''}" data-panel="recipes">
|
||||
<div class="recipes-tab-container"></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 || target.closest('[data-username]')?.dataset.username;
|
||||
if (username) {
|
||||
window.open(`https://civitai.com/user/${username}`, '_blank');
|
||||
}
|
||||
break;
|
||||
case 'edit-name':
|
||||
this.editModelName();
|
||||
break;
|
||||
case 'edit-usage-tips':
|
||||
this.startEditingUsageTips();
|
||||
break;
|
||||
case 'cancel-usage-tips':
|
||||
this.cancelEditingUsageTips();
|
||||
break;
|
||||
case 'add-usage-tip':
|
||||
this.addUsageTip();
|
||||
break;
|
||||
case 'remove-usage-tip':
|
||||
const key = target.dataset.key;
|
||||
if (key) this.removeUsageTip(key);
|
||||
break;
|
||||
case 'edit-trigger-words':
|
||||
this.startEditingTriggerWords();
|
||||
break;
|
||||
case 'cancel-trigger-words':
|
||||
this.cancelEditingTriggerWords();
|
||||
break;
|
||||
case 'save-trigger-words':
|
||||
this.saveTriggerWords();
|
||||
break;
|
||||
case 'copy-trigger-words':
|
||||
this.copyAllTriggerWords();
|
||||
break;
|
||||
case 'copy-trigger-word':
|
||||
const word = target.dataset.word;
|
||||
if (word) this.copyTriggerWord(word);
|
||||
break;
|
||||
case 'remove-trigger-word':
|
||||
const wordToRemove = target.dataset.word || target.closest('[data-word]')?.dataset.word;
|
||||
if (wordToRemove) this.removeTriggerWord(wordToRemove);
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
// Handle input events
|
||||
this.element.addEventListener('input', (e) => {
|
||||
if (e.target.dataset.action === 'notes-input') {
|
||||
this.handleNotesInput(e.target.value);
|
||||
}
|
||||
});
|
||||
|
||||
this.element.addEventListener('keydown', (e) => {
|
||||
if (e.target.dataset.action === 'add-trigger-word-input' && e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const value = e.target.value.trim();
|
||||
if (value) {
|
||||
this.addTriggerWord(value);
|
||||
e.target.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
if (e.target.dataset.action === 'usage-tip-value-input' && e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
this.addUsageTip();
|
||||
}
|
||||
});
|
||||
|
||||
// Load initial tab content
|
||||
if (this.activeTab === 'versions') {
|
||||
this.loadVersionsTab();
|
||||
} else if (this.activeTab === 'recipes') {
|
||||
this.loadRecipesTab();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.loadVersionsTab();
|
||||
} else if (tabId === 'recipes') {
|
||||
this.loadRecipesTab();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load versions tab
|
||||
*/
|
||||
loadVersionsTab() {
|
||||
if (!this.versionsTab) {
|
||||
const container = this.element.querySelector('.versions-tab-container');
|
||||
if (container) {
|
||||
this.versionsTab = new VersionsTab(container);
|
||||
this.versionsTab.render({ model: this.model, modelType: this.modelType });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load recipes tab
|
||||
*/
|
||||
loadRecipesTab() {
|
||||
if (!this.recipesTab) {
|
||||
const container = this.element.querySelector('.recipes-tab-container');
|
||||
if (container) {
|
||||
this.recipesTab = new RecipesTab(container);
|
||||
this.recipesTab.render({ model: this.model });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle notes input with auto-save
|
||||
*/
|
||||
handleNotesInput(value) {
|
||||
// Clear existing timer
|
||||
if (this.notesDebounceTimer) {
|
||||
clearTimeout(this.notesDebounceTimer);
|
||||
}
|
||||
|
||||
// Show saving indicator
|
||||
const indicator = this.element.querySelector('[data-save-indicator]');
|
||||
if (indicator) {
|
||||
indicator.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Saving...';
|
||||
indicator.style.display = 'inline-flex';
|
||||
}
|
||||
|
||||
// Debounce save
|
||||
this.notesDebounceTimer = setTimeout(() => {
|
||||
this.saveNotes(value);
|
||||
}, 800);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save notes to server
|
||||
*/
|
||||
async saveNotes(notes) {
|
||||
if (!this.model?.file_path) return;
|
||||
|
||||
try {
|
||||
const client = getModelApiClient(this.modelType);
|
||||
await client.saveModelMetadata(this.model.file_path, { notes });
|
||||
|
||||
const indicator = this.element.querySelector('[data-save-indicator]');
|
||||
if (indicator) {
|
||||
indicator.innerHTML = '<i class="fas fa-check"></i> Saved';
|
||||
setTimeout(() => {
|
||||
indicator.style.display = 'none';
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
showToast('modals.model.notes.saved', {}, 'success');
|
||||
} catch (err) {
|
||||
console.error('Failed to save notes:', err);
|
||||
|
||||
const indicator = this.element.querySelector('[data-save-indicator]');
|
||||
if (indicator) {
|
||||
indicator.innerHTML = '<i class="fas fa-exclamation-triangle"></i> Failed';
|
||||
}
|
||||
|
||||
showToast('modals.model.notes.saveFailed', {}, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start editing usage tips
|
||||
*/
|
||||
startEditingUsageTips() {
|
||||
this.isEditingUsageTips = true;
|
||||
this.refreshLoraSpecificSection();
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel editing usage tips
|
||||
*/
|
||||
cancelEditingUsageTips() {
|
||||
this.isEditingUsageTips = false;
|
||||
this.refreshLoraSpecificSection();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add usage tip
|
||||
*/
|
||||
async addUsageTip() {
|
||||
const keySelect = this.element.querySelector('.usage-tip-key');
|
||||
const valueInput = this.element.querySelector('.usage-tip-value');
|
||||
|
||||
const key = keySelect?.value;
|
||||
const value = valueInput?.value.trim();
|
||||
|
||||
if (!key || !value) return;
|
||||
|
||||
try {
|
||||
const usageTips = this.model.usage_tips ? JSON.parse(this.model.usage_tips) : {};
|
||||
usageTips[key] = value;
|
||||
|
||||
const client = getModelApiClient(this.modelType);
|
||||
await client.saveModelMetadata(this.model.file_path, { usage_tips: JSON.stringify(usageTips) });
|
||||
|
||||
this.model.usage_tips = JSON.stringify(usageTips);
|
||||
this.isEditingUsageTips = false;
|
||||
this.refreshLoraSpecificSection();
|
||||
showToast('common.actions.save', {}, 'success');
|
||||
} catch (err) {
|
||||
console.error('Failed to save usage tip:', err);
|
||||
showToast('modals.model.notes.saveFailed', {}, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove usage tip
|
||||
*/
|
||||
async removeUsageTip(key) {
|
||||
try {
|
||||
const usageTips = this.model.usage_tips ? JSON.parse(this.model.usage_tips) : {};
|
||||
delete usageTips[key];
|
||||
|
||||
const client = getModelApiClient(this.modelType);
|
||||
await client.saveModelMetadata(this.model.file_path, {
|
||||
usage_tips: Object.keys(usageTips).length > 0 ? JSON.stringify(usageTips) : null
|
||||
});
|
||||
|
||||
this.model.usage_tips = Object.keys(usageTips).length > 0 ? JSON.stringify(usageTips) : null;
|
||||
this.refreshLoraSpecificSection();
|
||||
showToast('common.actions.delete', {}, 'success');
|
||||
} catch (err) {
|
||||
console.error('Failed to remove usage tip:', err);
|
||||
showToast('modals.model.notes.saveFailed', {}, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start editing trigger words
|
||||
*/
|
||||
startEditingTriggerWords() {
|
||||
this.isEditingTriggerWords = true;
|
||||
this.editingTriggerWords = [...(this.model.civitai?.trainedWords || [])];
|
||||
this.refreshLoraSpecificSection();
|
||||
|
||||
// Focus input
|
||||
setTimeout(() => {
|
||||
const input = this.element.querySelector('.metadata__tag-input');
|
||||
if (input) input.focus();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel editing trigger words
|
||||
*/
|
||||
cancelEditingTriggerWords() {
|
||||
this.isEditingTriggerWords = false;
|
||||
this.editingTriggerWords = [];
|
||||
this.refreshLoraSpecificSection();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add trigger word during editing
|
||||
*/
|
||||
addTriggerWord(word) {
|
||||
if (!word.trim()) return;
|
||||
if (this.editingTriggerWords.includes(word.trim())) {
|
||||
showToast('modals.model.triggerWords.validation.duplicate', {}, 'warning');
|
||||
return;
|
||||
}
|
||||
this.editingTriggerWords.push(word.trim());
|
||||
this.refreshLoraSpecificSection();
|
||||
|
||||
// Focus input again
|
||||
setTimeout(() => {
|
||||
const input = this.element.querySelector('.metadata__tag-input');
|
||||
if (input) {
|
||||
input.value = '';
|
||||
input.focus();
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove trigger word during editing
|
||||
*/
|
||||
removeTriggerWord(word) {
|
||||
this.editingTriggerWords = this.editingTriggerWords.filter(w => w !== word);
|
||||
this.refreshLoraSpecificSection();
|
||||
}
|
||||
|
||||
/**
|
||||
* Save trigger words
|
||||
*/
|
||||
async saveTriggerWords() {
|
||||
try {
|
||||
const client = getModelApiClient(this.modelType);
|
||||
await client.saveModelMetadata(this.model.file_path, {
|
||||
trained_words: this.editingTriggerWords
|
||||
});
|
||||
|
||||
// Update local model data
|
||||
if (!this.model.civitai) this.model.civitai = {};
|
||||
this.model.civitai.trainedWords = [...this.editingTriggerWords];
|
||||
|
||||
this.isEditingTriggerWords = false;
|
||||
this.editingTriggerWords = [];
|
||||
this.refreshLoraSpecificSection();
|
||||
showToast('common.actions.save', {}, 'success');
|
||||
} catch (err) {
|
||||
console.error('Failed to save trigger words:', err);
|
||||
showToast('modals.model.notes.saveFailed', {}, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy single trigger word
|
||||
*/
|
||||
async copyTriggerWord(word) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(word);
|
||||
showToast('modals.model.triggerWords.copyWord', {}, 'success');
|
||||
} catch (err) {
|
||||
console.error('Failed to copy trigger word:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy all trigger words
|
||||
*/
|
||||
async copyAllTriggerWords() {
|
||||
const words = this.model.civitai?.trainedWords || [];
|
||||
if (words.length === 0) return;
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(words.join(', '));
|
||||
showToast('modals.model.triggerWords.copyWord', {}, 'success');
|
||||
} catch (err) {
|
||||
console.error('Failed to copy trigger words:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh LoRA specific section
|
||||
*/
|
||||
refreshLoraSpecificSection() {
|
||||
if (this.modelType !== 'loras') return;
|
||||
|
||||
const sections = this.element.querySelectorAll('.metadata__section');
|
||||
// First two sections are usage tips and trigger words
|
||||
if (sections.length >= 2) {
|
||||
const newHtml = this.renderLoraSpecific();
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.innerHTML = newHtml;
|
||||
|
||||
const newSections = tempDiv.querySelectorAll('.metadata__section');
|
||||
if (newSections.length >= 2) {
|
||||
sections[0].replaceWith(newSections[0]);
|
||||
sections[1].replaceWith(newSections[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit model name
|
||||
*/
|
||||
async editModelName() {
|
||||
const currentName = this.model.model_name || '';
|
||||
const newName = prompt(
|
||||
translate('modals.model.actions.editModelName', {}, 'Edit model name'),
|
||||
currentName
|
||||
);
|
||||
|
||||
if (newName !== null && newName.trim() !== '' && newName !== currentName) {
|
||||
try {
|
||||
const client = getModelApiClient(this.modelType);
|
||||
await client.saveModelMetadata(this.model.file_path, { model_name: newName.trim() });
|
||||
|
||||
this.model.model_name = newName.trim();
|
||||
this.element.querySelector('.metadata__name').textContent = newName.trim();
|
||||
showToast('common.actions.save', {}, 'success');
|
||||
} catch (err) {
|
||||
console.error('Failed to save model name:', err);
|
||||
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');
|
||||
|
||||
showToast('modals.model.openFileLocation.success', {}, 'success');
|
||||
} catch (err) {
|
||||
console.error('Failed to open file location:', err);
|
||||
showToast('modals.model.openFileLocation.failed', {}, 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
374
static/js/components/model-modal/ModelModal.js
Normal file
374
static/js/components/model-modal/ModelModal.js
Normal file
@@ -0,0 +1,374 @@
|
||||
/**
|
||||
* 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) {
|
||||
// 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) {
|
||||
// Ensure components are initialized
|
||||
if (!this.showcase || !this.metadataPanel) {
|
||||
console.warn('Showcase or MetadataPanel not initialized, falling back to show()');
|
||||
await this.show(model, modelType);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fade out current content
|
||||
this.showcase?.element?.classList.add('transitioning');
|
||||
this.metadataPanel?.element?.classList.add('transitioning');
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 150));
|
||||
|
||||
// Fetch complete metadata for new model
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// Update model data in-place
|
||||
this.currentModel = {
|
||||
...model,
|
||||
civitai: completeCivitaiData
|
||||
};
|
||||
this.currentModelType = modelType;
|
||||
|
||||
// Render new content in-place
|
||||
await this.render();
|
||||
|
||||
// Fade in new content
|
||||
this.showcase?.element?.classList.remove('transitioning');
|
||||
this.metadataPanel?.element?.classList.remove('transitioning');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
321
static/js/components/model-modal/RecipesTab.js
Normal file
321
static/js/components/model-modal/RecipesTab.js
Normal file
@@ -0,0 +1,321 @@
|
||||
/**
|
||||
* RecipesTab - Recipe cards grid component for LoRA models
|
||||
* Features:
|
||||
* - Recipe cards grid layout
|
||||
* - Copy/View actions
|
||||
* - LoRA availability status badges
|
||||
*/
|
||||
|
||||
import { escapeHtml } from '../shared/utils.js';
|
||||
import { translate } from '../../utils/i18nHelpers.js';
|
||||
import { showToast, copyToClipboard } from '../../utils/uiHelpers.js';
|
||||
import { setSessionItem, removeSessionItem } from '../../utils/storageHelpers.js';
|
||||
|
||||
export class RecipesTab {
|
||||
constructor(container) {
|
||||
this.element = container;
|
||||
this.model = null;
|
||||
this.recipes = [];
|
||||
this.isLoading = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the recipes tab
|
||||
*/
|
||||
async render({ model }) {
|
||||
this.model = model;
|
||||
this.element.innerHTML = this.getLoadingTemplate();
|
||||
|
||||
await this.loadRecipes();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get loading template
|
||||
*/
|
||||
getLoadingTemplate() {
|
||||
return `
|
||||
<div class="recipes-loading">
|
||||
<i class="fas fa-spinner fa-spin"></i>
|
||||
<span>${translate('modals.model.loading.recipes', {}, 'Loading recipes...')}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load recipes from API
|
||||
*/
|
||||
async loadRecipes() {
|
||||
const sha256 = this.model?.sha256;
|
||||
|
||||
if (!sha256) {
|
||||
this.renderError('Missing model hash');
|
||||
return;
|
||||
}
|
||||
|
||||
this.isLoading = true;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/lm/recipes/for-lora?hash=${encodeURIComponent(sha256.toLowerCase())}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.error || 'Failed to load recipes');
|
||||
}
|
||||
|
||||
this.recipes = data.recipes || [];
|
||||
this.renderRecipes();
|
||||
} catch (error) {
|
||||
console.error('Failed to load recipes:', error);
|
||||
this.renderError(error.message);
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render error state
|
||||
*/
|
||||
renderError(message) {
|
||||
this.element.innerHTML = `
|
||||
<div class="recipes-error">
|
||||
<i class="fas fa-exclamation-circle"></i>
|
||||
<p>${escapeHtml(message || 'Failed to load recipes. Please try again later.')}</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render empty state
|
||||
*/
|
||||
renderEmpty() {
|
||||
this.element.innerHTML = `
|
||||
<div class="recipes-empty">
|
||||
<i class="fas fa-book-open"></i>
|
||||
<p>${translate('recipes.noRecipesFound', {}, 'No recipes found that use this LoRA.')}</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render recipes grid
|
||||
*/
|
||||
renderRecipes() {
|
||||
if (!this.recipes || this.recipes.length === 0) {
|
||||
this.renderEmpty();
|
||||
return;
|
||||
}
|
||||
|
||||
const loraName = this.model?.model_name || '';
|
||||
|
||||
this.element.innerHTML = `
|
||||
<div class="recipes-header">
|
||||
<div class="recipes-header__text">
|
||||
<span class="recipes-header__eyebrow">Linked recipes</span>
|
||||
<h3>${this.recipes.length} recipe${this.recipes.length > 1 ? 's' : ''} using this LoRA</h3>
|
||||
<p class="recipes-header__description">
|
||||
${loraName ? `Discover workflows crafted for ${escapeHtml(loraName)}.` : 'Discover workflows crafted for this model.'}
|
||||
</p>
|
||||
</div>
|
||||
<button class="recipes-header__view-all" data-action="view-all">
|
||||
<i class="fas fa-external-link-alt"></i>
|
||||
<span>View all recipes</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="recipes-grid">
|
||||
${this.recipes.map(recipe => this.renderRecipeCard(recipe)).join('')}
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.bindEvents();
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a single recipe card
|
||||
*/
|
||||
renderRecipeCard(recipe) {
|
||||
const baseModel = recipe.base_model || '';
|
||||
const loras = recipe.loras || [];
|
||||
const lorasCount = loras.length;
|
||||
const missingLorasCount = loras.filter(lora => !lora.inLibrary && !lora.isDeleted).length;
|
||||
const allLorasAvailable = missingLorasCount === 0 && lorasCount > 0;
|
||||
|
||||
let statusClass = 'empty';
|
||||
let statusLabel = 'No linked LoRAs';
|
||||
let statusTitle = 'No LoRAs in this recipe';
|
||||
|
||||
if (lorasCount > 0) {
|
||||
if (allLorasAvailable) {
|
||||
statusClass = 'ready';
|
||||
statusLabel = `${lorasCount} LoRA${lorasCount > 1 ? 's' : ''} ready`;
|
||||
statusTitle = 'All LoRAs available - Ready to use';
|
||||
} else {
|
||||
statusClass = 'missing';
|
||||
statusLabel = `Missing ${missingLorasCount} of ${lorasCount}`;
|
||||
statusTitle = `${missingLorasCount} of ${lorasCount} LoRAs missing`;
|
||||
}
|
||||
}
|
||||
|
||||
const imageUrl = recipe.file_url ||
|
||||
(recipe.file_path ? `/loras_static/root1/preview/${recipe.file_path.split('/').pop()}` :
|
||||
'/loras_static/images/no-preview.png');
|
||||
|
||||
return `
|
||||
<article class="recipe-card"
|
||||
data-recipe-id="${escapeHtml(recipe.id || '')}"
|
||||
data-file-path="${escapeHtml(recipe.file_path || '')}"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label="${recipe.title ? `View recipe ${escapeHtml(recipe.title)}` : 'View recipe details'}">
|
||||
<div class="recipe-card__media">
|
||||
<img src="${escapeHtml(imageUrl)}"
|
||||
alt="${recipe.title ? escapeHtml(recipe.title) + ' preview' : 'Recipe preview'}"
|
||||
loading="lazy">
|
||||
<div class="recipe-card__media-top">
|
||||
<button class="recipe-card__copy" data-action="copy-recipe" title="Copy recipe syntax">
|
||||
<i class="fas fa-copy"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="recipe-card__body">
|
||||
<h4 class="recipe-card__title" title="${escapeHtml(recipe.title || 'Untitled recipe')}">
|
||||
${escapeHtml(recipe.title || 'Untitled recipe')}
|
||||
</h4>
|
||||
<div class="recipe-card__meta">
|
||||
${baseModel ? `<span class="recipe-card__badge recipe-card__badge--base">${escapeHtml(baseModel)}</span>` : ''}
|
||||
<span class="recipe-card__badge recipe-card__badge--${statusClass}" title="${escapeHtml(statusTitle)}">
|
||||
<i class="fas fa-layer-group"></i>
|
||||
<span>${escapeHtml(statusLabel)}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="recipe-card__cta">
|
||||
<span>View details</span>
|
||||
<i class="fas fa-arrow-right"></i>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind event listeners
|
||||
*/
|
||||
bindEvents() {
|
||||
this.element.addEventListener('click', async (e) => {
|
||||
const target = e.target.closest('[data-action]');
|
||||
|
||||
if (target) {
|
||||
const action = target.dataset.action;
|
||||
|
||||
if (action === 'view-all') {
|
||||
await this.navigateToRecipesPage();
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'copy-recipe') {
|
||||
const card = target.closest('.recipe-card');
|
||||
const recipeId = card?.dataset.recipeId;
|
||||
if (recipeId) {
|
||||
e.stopPropagation();
|
||||
this.copyRecipeSyntax(recipeId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Card click - navigate to recipe
|
||||
const card = e.target.closest('.recipe-card');
|
||||
if (card && !e.target.closest('[data-action]')) {
|
||||
const recipeId = card.dataset.recipeId;
|
||||
if (recipeId) {
|
||||
await this.navigateToRecipeDetails(recipeId);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Keyboard navigation for cards
|
||||
this.element.addEventListener('keydown', async (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
const card = e.target.closest('.recipe-card');
|
||||
if (card) {
|
||||
e.preventDefault();
|
||||
const recipeId = card.dataset.recipeId;
|
||||
if (recipeId) {
|
||||
await this.navigateToRecipeDetails(recipeId);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy recipe syntax to clipboard
|
||||
*/
|
||||
async copyRecipeSyntax(recipeId) {
|
||||
if (!recipeId) {
|
||||
showToast('toast.recipes.noRecipeId', {}, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/lm/recipe/${recipeId}/syntax`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success && data.syntax) {
|
||||
await copyToClipboard(data.syntax, 'Recipe syntax copied to clipboard');
|
||||
} else {
|
||||
throw new Error(data.error || 'No syntax returned');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to copy recipe syntax:', err);
|
||||
showToast('toast.recipes.copyFailed', { message: err.message }, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to recipes page with filter
|
||||
*/
|
||||
async navigateToRecipesPage() {
|
||||
// Close the modal
|
||||
const { ModelModal } = await import('./ModelModal.js');
|
||||
ModelModal.close();
|
||||
|
||||
// Clear any previous filters
|
||||
removeSessionItem('filterLoraName');
|
||||
removeSessionItem('filterLoraHash');
|
||||
removeSessionItem('viewRecipeId');
|
||||
|
||||
// Store the LoRA name and hash filter in sessionStorage
|
||||
setSessionItem('lora_to_recipe_filterLoraName', this.model?.model_name || '');
|
||||
setSessionItem('lora_to_recipe_filterLoraHash', this.model?.sha256 || '');
|
||||
|
||||
// Navigate to recipes page
|
||||
window.location.href = '/loras/recipes';
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to specific recipe details
|
||||
*/
|
||||
async navigateToRecipeDetails(recipeId) {
|
||||
// Close the modal
|
||||
const { ModelModal } = await import('./ModelModal.js');
|
||||
ModelModal.close();
|
||||
|
||||
// Clear any previous filters
|
||||
removeSessionItem('filterLoraName');
|
||||
removeSessionItem('filterLoraHash');
|
||||
removeSessionItem('viewRecipeId');
|
||||
|
||||
// Store the recipe ID in sessionStorage to load on recipes page
|
||||
setSessionItem('viewRecipeId', recipeId);
|
||||
|
||||
// Navigate to recipes page
|
||||
window.location.href = '/loras/recipes';
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh recipes
|
||||
*/
|
||||
async refresh() {
|
||||
await this.loadRecipes();
|
||||
}
|
||||
}
|
||||
1501
static/js/components/model-modal/Showcase.js
Normal file
1501
static/js/components/model-modal/Showcase.js
Normal file
File diff suppressed because it is too large
Load Diff
627
static/js/components/model-modal/VersionsTab.js
Normal file
627
static/js/components/model-modal/VersionsTab.js
Normal file
@@ -0,0 +1,627 @@
|
||||
/**
|
||||
* VersionsTab - Model versions list component
|
||||
* Features:
|
||||
* - Version cards with preview, badges, and actions
|
||||
* - Download/Delete/Ignore actions
|
||||
* - Base model filter toggle
|
||||
* - Reference: static/js/components/shared/ModelVersionsTab.js
|
||||
*/
|
||||
|
||||
import { escapeHtml, formatFileSize } from '../shared/utils.js';
|
||||
import { translate } from '../../utils/i18nHelpers.js';
|
||||
import { showToast } from '../../utils/uiHelpers.js';
|
||||
import { getModelApiClient } from '../../api/modelApiFactory.js';
|
||||
import { downloadManager } from '../../managers/DownloadManager.js';
|
||||
import { modalManager } from '../../managers/ModalManager.js';
|
||||
|
||||
const VIDEO_EXTENSIONS = ['.mp4', '.webm', '.mov', '.mkv'];
|
||||
const PREVIEW_PLACEHOLDER_URL = '/loras_static/images/no-preview.png';
|
||||
|
||||
const DISPLAY_FILTER_MODES = Object.freeze({
|
||||
SAME_BASE: 'same_base',
|
||||
ANY: 'any',
|
||||
});
|
||||
|
||||
export class VersionsTab {
|
||||
constructor(container) {
|
||||
this.element = container;
|
||||
this.model = null;
|
||||
this.modelType = null;
|
||||
this.versions = [];
|
||||
this.isLoading = false;
|
||||
this.displayMode = DISPLAY_FILTER_MODES.ANY;
|
||||
this.record = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the versions tab
|
||||
*/
|
||||
async render({ model, modelType }) {
|
||||
this.model = model;
|
||||
this.modelType = modelType;
|
||||
this.element.innerHTML = this.getLoadingTemplate();
|
||||
|
||||
await this.loadVersions();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get loading template
|
||||
*/
|
||||
getLoadingTemplate() {
|
||||
return `
|
||||
<div class="versions-loading">
|
||||
<i class="fas fa-spinner fa-spin"></i>
|
||||
<span>${translate('modals.model.loading.versions', {}, 'Loading versions...')}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load versions from API
|
||||
*/
|
||||
async loadVersions() {
|
||||
const modelId = this.model?.civitai?.modelId;
|
||||
|
||||
if (!modelId) {
|
||||
this.renderError(translate('modals.model.versions.missingModelId', {}, 'This model is missing a Civitai model id.'));
|
||||
return;
|
||||
}
|
||||
|
||||
this.isLoading = true;
|
||||
|
||||
try {
|
||||
const client = getModelApiClient(this.modelType);
|
||||
const response = await client.fetchModelUpdateVersions(modelId, { refresh: false });
|
||||
|
||||
if (!response?.success) {
|
||||
throw new Error(response?.error || 'Failed to load versions');
|
||||
}
|
||||
|
||||
this.record = response.record;
|
||||
this.renderVersions();
|
||||
} catch (error) {
|
||||
console.error('Failed to load versions:', error);
|
||||
this.renderError(error.message);
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render error state
|
||||
*/
|
||||
renderError(message) {
|
||||
this.element.innerHTML = `
|
||||
<div class="versions-error">
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
<p>${escapeHtml(message || translate('modals.model.versions.error', {}, 'Failed to load versions.'))}</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render empty state
|
||||
*/
|
||||
renderEmpty() {
|
||||
this.element.innerHTML = `
|
||||
<div class="versions-empty">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
<p>${translate('modals.model.versions.empty', {}, 'No version history available for this model yet.')}</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render versions list
|
||||
*/
|
||||
renderVersions() {
|
||||
if (!this.record || !Array.isArray(this.record.versions) || this.record.versions.length === 0) {
|
||||
this.renderEmpty();
|
||||
return;
|
||||
}
|
||||
|
||||
const currentVersionId = this.model?.civitai?.versionId;
|
||||
const sortedVersions = [...this.record.versions].sort((a, b) => Number(b.versionId) - Number(a.versionId));
|
||||
|
||||
// Filter versions based on display mode
|
||||
const filteredVersions = this.filterVersions(sortedVersions, currentVersionId);
|
||||
|
||||
if (filteredVersions.length === 0) {
|
||||
this.renderFilteredEmpty();
|
||||
return;
|
||||
}
|
||||
|
||||
this.element.innerHTML = `
|
||||
${this.renderToolbar()}
|
||||
<div class="versions-list">
|
||||
${filteredVersions.map(version => this.renderVersionCard(version, currentVersionId)).join('')}
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.bindEvents();
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter versions based on display mode
|
||||
*/
|
||||
filterVersions(versions, currentVersionId) {
|
||||
const currentVersion = versions.find(v => v.versionId === currentVersionId);
|
||||
const currentBaseModel = currentVersion?.baseModel;
|
||||
|
||||
if (this.displayMode !== DISPLAY_FILTER_MODES.SAME_BASE || !currentBaseModel) {
|
||||
return versions;
|
||||
}
|
||||
|
||||
return versions.filter(version => {
|
||||
const versionBase = version.baseModel?.toLowerCase().trim();
|
||||
const targetBase = currentBaseModel.toLowerCase().trim();
|
||||
return versionBase === targetBase;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Render filtered empty state
|
||||
*/
|
||||
renderFilteredEmpty() {
|
||||
const currentVersion = this.record.versions.find(v => v.versionId === this.model?.civitai?.versionId);
|
||||
const baseModelLabel = currentVersion?.baseModel || translate('modals.model.metadata.unknown', {}, 'Unknown');
|
||||
|
||||
this.element.innerHTML = `
|
||||
${this.renderToolbar()}
|
||||
<div class="versions-empty versions-empty-filter">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
<p>${translate('modals.model.versions.filters.empty', { baseModel: baseModelLabel }, 'No versions match the current base model filter.')}</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.bindEvents();
|
||||
}
|
||||
|
||||
/**
|
||||
* Render toolbar with actions
|
||||
*/
|
||||
renderToolbar() {
|
||||
const ignoreText = this.record.shouldIgnore
|
||||
? translate('modals.model.versions.actions.resumeModelUpdates', {}, 'Resume updates for this model')
|
||||
: translate('modals.model.versions.actions.ignoreModelUpdates', {}, 'Ignore updates for this model');
|
||||
|
||||
const isFilteringActive = this.displayMode === DISPLAY_FILTER_MODES.SAME_BASE;
|
||||
const toggleTooltip = isFilteringActive
|
||||
? translate('modals.model.versions.filters.tooltip.showAllVersions', {}, 'Switch to showing all versions')
|
||||
: translate('modals.model.versions.filters.tooltip.showSameBaseVersions', {}, 'Switch to showing only versions with the current base model');
|
||||
|
||||
return `
|
||||
<header class="versions-toolbar">
|
||||
<div class="versions-toolbar-info">
|
||||
<div class="versions-toolbar-info-heading">
|
||||
<h3>${translate('modals.model.versions.heading', {}, 'Model versions')}</h3>
|
||||
<button class="versions-filter-toggle ${isFilteringActive ? 'active' : ''}"
|
||||
data-action="toggle-filter"
|
||||
title="${escapeHtml(toggleTooltip)}"
|
||||
type="button">
|
||||
<i class="fas fa-th-list"></i>
|
||||
</button>
|
||||
</div>
|
||||
<p>${translate('modals.model.versions.copy', { count: this.record.versions.length }, 'Track and manage every version of this model in one place.')}</p>
|
||||
</div>
|
||||
<div class="versions-toolbar-actions">
|
||||
<button class="versions-toolbar-btn versions-toolbar-btn-primary" data-action="toggle-model-ignore">
|
||||
${escapeHtml(ignoreText)}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a single version card
|
||||
*/
|
||||
renderVersionCard(version, currentVersionId) {
|
||||
const isCurrent = version.versionId === currentVersionId;
|
||||
const isInLibrary = version.isInLibrary;
|
||||
const isNewer = this.isNewerVersion(version);
|
||||
const badges = this.buildBadges(version, isCurrent, isNewer);
|
||||
const actions = this.buildActions(version);
|
||||
|
||||
const metaParts = [];
|
||||
if (version.baseModel) metaParts.push(`<span class="version-meta-primary">${escapeHtml(version.baseModel)}</span>`);
|
||||
if (version.releasedAt) {
|
||||
const date = new Date(version.releasedAt);
|
||||
if (!isNaN(date.getTime())) {
|
||||
metaParts.push(escapeHtml(date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })));
|
||||
}
|
||||
}
|
||||
if (version.sizeBytes > 0) metaParts.push(escapeHtml(formatFileSize(version.sizeBytes)));
|
||||
|
||||
const metaMarkup = metaParts.length > 0
|
||||
? metaParts.map(m => `<span class="version-meta-item">${m}</span>`).join('<span class="version-meta-separator">•</span>')
|
||||
: escapeHtml(translate('modals.model.versions.labels.noDetails', {}, 'No additional details'));
|
||||
|
||||
const civitaiUrl = this.buildCivitaiUrl(version.modelId, version.versionId);
|
||||
const clickAction = civitaiUrl ? `data-civitai-url="${escapeHtml(civitaiUrl)}"` : '';
|
||||
|
||||
return `
|
||||
<div class="version-card ${isCurrent ? 'is-current' : ''} ${civitaiUrl ? 'is-clickable' : ''}"
|
||||
data-version-id="${version.versionId}"
|
||||
${clickAction}>
|
||||
${this.renderMedia(version)}
|
||||
<div class="version-details">
|
||||
<div class="version-title">
|
||||
<span class="version-name">${escapeHtml(version.name || translate('modals.model.versions.labels.unnamed', {}, 'Untitled Version'))}</span>
|
||||
</div>
|
||||
<div class="version-badges">${badges}</div>
|
||||
<div class="version-meta">${metaMarkup}</div>
|
||||
</div>
|
||||
<div class="version-actions">
|
||||
${actions}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if version is newer than any in library
|
||||
*/
|
||||
isNewerVersion(version) {
|
||||
if (!this.record?.inLibraryVersionIds?.length) return false;
|
||||
if (version.isInLibrary) return false;
|
||||
const maxInLibrary = Math.max(...this.record.inLibraryVersionIds);
|
||||
return version.versionId > maxInLibrary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build badges HTML
|
||||
*/
|
||||
buildBadges(version, isCurrent, isNewer) {
|
||||
const badges = [];
|
||||
|
||||
if (isCurrent) {
|
||||
badges.push(this.createBadge(
|
||||
translate('modals.model.versions.badges.current', {}, 'Current Version'),
|
||||
'current'
|
||||
));
|
||||
}
|
||||
|
||||
if (version.isInLibrary) {
|
||||
badges.push(this.createBadge(
|
||||
translate('modals.model.versions.badges.inLibrary', {}, 'In Library'),
|
||||
'success'
|
||||
));
|
||||
} else if (isNewer && !version.shouldIgnore) {
|
||||
badges.push(this.createBadge(
|
||||
translate('modals.model.versions.badges.newer', {}, 'Newer Version'),
|
||||
'info'
|
||||
));
|
||||
}
|
||||
|
||||
if (version.shouldIgnore) {
|
||||
badges.push(this.createBadge(
|
||||
translate('modals.model.versions.badges.ignored', {}, 'Ignored'),
|
||||
'muted'
|
||||
));
|
||||
}
|
||||
|
||||
return badges.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a badge element
|
||||
*/
|
||||
createBadge(label, tone) {
|
||||
return `<span class="version-badge version-badge-${tone}">${escapeHtml(label)}</span>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build actions HTML
|
||||
*/
|
||||
buildActions(version) {
|
||||
const actions = [];
|
||||
|
||||
if (!version.isInLibrary) {
|
||||
actions.push(`
|
||||
<button class="version-action version-action-primary" data-action="download">
|
||||
${escapeHtml(translate('modals.model.versions.actions.download', {}, 'Download'))}
|
||||
</button>
|
||||
`);
|
||||
} else if (version.filePath) {
|
||||
actions.push(`
|
||||
<button class="version-action version-action-danger" data-action="delete">
|
||||
${escapeHtml(translate('modals.model.versions.actions.delete', {}, 'Delete'))}
|
||||
</button>
|
||||
`);
|
||||
}
|
||||
|
||||
const ignoreLabel = version.shouldIgnore
|
||||
? translate('modals.model.versions.actions.unignore', {}, 'Unignore')
|
||||
: translate('modals.model.versions.actions.ignore', {}, 'Ignore');
|
||||
|
||||
actions.push(`
|
||||
<button class="version-action version-action-ghost" data-action="toggle-ignore">
|
||||
${escapeHtml(ignoreLabel)}
|
||||
</button>
|
||||
`);
|
||||
|
||||
return actions.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Render media (image/video)
|
||||
*/
|
||||
renderMedia(version) {
|
||||
if (!version.previewUrl) {
|
||||
return `
|
||||
<div class="version-media version-media-placeholder">
|
||||
${escapeHtml(translate('modals.model.versions.media.placeholder', {}, 'No preview'))}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (this.isVideoUrl(version.previewUrl)) {
|
||||
return `
|
||||
<div class="version-media">
|
||||
<video src="${escapeHtml(version.previewUrl)}"
|
||||
controls muted loop playsinline preload="metadata">
|
||||
</video>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="version-media">
|
||||
<img src="${escapeHtml(version.previewUrl)}"
|
||||
alt="${escapeHtml(version.name || 'preview')}"
|
||||
loading="lazy">
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if URL is a video
|
||||
*/
|
||||
isVideoUrl(url) {
|
||||
if (!url) return false;
|
||||
const extension = url.split('.').pop()?.toLowerCase()?.split('?')[0];
|
||||
return VIDEO_EXTENSIONS.includes(`.${extension}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build Civitai URL
|
||||
*/
|
||||
buildCivitaiUrl(modelId, versionId) {
|
||||
if (!modelId || !versionId) return null;
|
||||
return `https://civitai.com/models/${encodeURIComponent(modelId)}?modelVersionId=${encodeURIComponent(versionId)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind event listeners
|
||||
*/
|
||||
bindEvents() {
|
||||
this.element.addEventListener('click', (e) => {
|
||||
const target = e.target.closest('[data-action]');
|
||||
if (!target) {
|
||||
// Check if clicked on a clickable card
|
||||
const card = e.target.closest('.version-card.is-clickable');
|
||||
if (card && !e.target.closest('.version-actions')) {
|
||||
const url = card.dataset.civitaiUrl;
|
||||
if (url) window.open(url, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const action = target.dataset.action;
|
||||
const card = target.closest('.version-card');
|
||||
const versionId = card ? parseInt(card.dataset.versionId, 10) : null;
|
||||
|
||||
switch (action) {
|
||||
case 'toggle-filter':
|
||||
this.toggleFilterMode();
|
||||
break;
|
||||
case 'toggle-model-ignore':
|
||||
this.handleToggleModelIgnore();
|
||||
break;
|
||||
case 'download':
|
||||
if (versionId) this.handleDownload(versionId, target);
|
||||
break;
|
||||
case 'delete':
|
||||
if (versionId) this.handleDelete(versionId, target);
|
||||
break;
|
||||
case 'toggle-ignore':
|
||||
if (versionId) this.handleToggleVersionIgnore(versionId, target);
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle filter mode
|
||||
*/
|
||||
toggleFilterMode() {
|
||||
this.displayMode = this.displayMode === DISPLAY_FILTER_MODES.SAME_BASE
|
||||
? DISPLAY_FILTER_MODES.ANY
|
||||
: DISPLAY_FILTER_MODES.SAME_BASE;
|
||||
this.renderVersions();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle toggle model ignore
|
||||
*/
|
||||
async handleToggleModelIgnore() {
|
||||
if (!this.record) return;
|
||||
|
||||
const modelId = this.record.modelId;
|
||||
const nextValue = !this.record.shouldIgnore;
|
||||
|
||||
try {
|
||||
const client = getModelApiClient(this.modelType);
|
||||
const response = await client.setModelUpdateIgnore(modelId, nextValue);
|
||||
|
||||
if (!response?.success) {
|
||||
throw new Error(response?.error || 'Request failed');
|
||||
}
|
||||
|
||||
this.record = response.record;
|
||||
this.renderVersions();
|
||||
|
||||
const toastKey = nextValue
|
||||
? 'modals.model.versions.toast.modelIgnored'
|
||||
: 'modals.model.versions.toast.modelResumed';
|
||||
showToast(toastKey, {}, 'success');
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle model ignore:', error);
|
||||
showToast(error?.message || 'Failed to update ignore preference', {}, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle download version
|
||||
*/
|
||||
async handleDownload(versionId, button) {
|
||||
const version = this.record.versions.find(v => v.versionId === versionId);
|
||||
if (!version) return;
|
||||
|
||||
button.disabled = true;
|
||||
|
||||
try {
|
||||
await downloadManager.downloadVersionWithDefaults(
|
||||
this.modelType,
|
||||
this.record.modelId,
|
||||
versionId,
|
||||
{ versionName: version.name || `#${versionId}` }
|
||||
);
|
||||
|
||||
// Reload versions after download starts
|
||||
setTimeout(() => this.loadVersions(), 1000);
|
||||
} catch (error) {
|
||||
console.error('Failed to download version:', error);
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle delete version
|
||||
*/
|
||||
async handleDelete(versionId, button) {
|
||||
const version = this.record.versions.find(v => v.versionId === versionId);
|
||||
if (!version?.filePath) return;
|
||||
|
||||
const confirmed = await this.showDeleteConfirmation(version);
|
||||
if (!confirmed) return;
|
||||
|
||||
button.disabled = true;
|
||||
|
||||
try {
|
||||
const client = getModelApiClient(this.modelType);
|
||||
await client.deleteModel(version.filePath);
|
||||
|
||||
showToast('modals.model.versions.toast.versionDeleted', {}, 'success');
|
||||
await this.loadVersions();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete version:', error);
|
||||
showToast(error?.message || 'Failed to delete version', {}, 'error');
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show delete confirmation modal
|
||||
*/
|
||||
async showDeleteConfirmation(version) {
|
||||
return new Promise((resolve) => {
|
||||
const modalRecord = modalManager?.getModal?.('deleteModal');
|
||||
if (!modalRecord?.element) {
|
||||
// Fallback to browser confirm
|
||||
const message = translate('modals.model.versions.confirm.delete', {}, 'Delete this version from your library?');
|
||||
resolve(window.confirm(message));
|
||||
return;
|
||||
}
|
||||
|
||||
const title = translate('modals.model.versions.actions.delete', {}, 'Delete');
|
||||
const message = translate('modals.model.versions.confirm.delete', {}, 'Delete this version from your library?');
|
||||
const versionName = version.name || translate('modals.model.versions.labels.unnamed', {}, 'Untitled Version');
|
||||
|
||||
const content = `
|
||||
<div class="modal-content delete-modal-content version-delete-modal">
|
||||
<h2>${escapeHtml(title)}</h2>
|
||||
<p class="delete-message">${escapeHtml(message)}</p>
|
||||
<div class="delete-model-info">
|
||||
<div class="delete-preview">
|
||||
${version.previewUrl ? `
|
||||
<img src="${escapeHtml(version.previewUrl)}" alt="${escapeHtml(versionName)}"
|
||||
onerror="this.src='${PREVIEW_PLACEHOLDER_URL}'">
|
||||
` : `<img src="${PREVIEW_PLACEHOLDER_URL}" alt="${escapeHtml(versionName)}">`}
|
||||
</div>
|
||||
<div class="delete-info">
|
||||
<h3>${escapeHtml(versionName)}</h3>
|
||||
${version.baseModel ? `<p class="version-base-model">${escapeHtml(version.baseModel)}</p>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="cancel-btn" data-action="cancel">${escapeHtml(translate('common.actions.cancel', {}, 'Cancel'))}</button>
|
||||
<button class="delete-btn" data-action="confirm">${escapeHtml(translate('common.actions.delete', {}, 'Delete'))}</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
modalManager.showModal('deleteModal', content);
|
||||
|
||||
const modalElement = modalRecord.element;
|
||||
const handleAction = (e) => {
|
||||
const action = e.target.closest('[data-action]')?.dataset.action;
|
||||
if (action === 'confirm') {
|
||||
modalManager.closeModal('deleteModal');
|
||||
resolve(true);
|
||||
} else if (action === 'cancel') {
|
||||
modalManager.closeModal('deleteModal');
|
||||
resolve(false);
|
||||
}
|
||||
};
|
||||
|
||||
modalElement.addEventListener('click', handleAction, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle toggle version ignore
|
||||
*/
|
||||
async handleToggleVersionIgnore(versionId, button) {
|
||||
const version = this.record.versions.find(v => v.versionId === versionId);
|
||||
if (!version) return;
|
||||
|
||||
const nextValue = !version.shouldIgnore;
|
||||
button.disabled = true;
|
||||
|
||||
try {
|
||||
const client = getModelApiClient(this.modelType);
|
||||
const response = await client.setVersionUpdateIgnore(
|
||||
this.record.modelId,
|
||||
versionId,
|
||||
nextValue
|
||||
);
|
||||
|
||||
if (!response?.success) {
|
||||
throw new Error(response?.error || 'Request failed');
|
||||
}
|
||||
|
||||
this.record = response.record;
|
||||
this.renderVersions();
|
||||
|
||||
const updatedVersion = response.record.versions.find(v => v.versionId === versionId);
|
||||
const toastKey = updatedVersion?.shouldIgnore
|
||||
? 'modals.model.versions.toast.versionIgnored'
|
||||
: 'modals.model.versions.toast.versionUnignored';
|
||||
showToast(toastKey, {}, 'success');
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle version ignore:', error);
|
||||
showToast(error?.message || 'Failed to update version preference', {}, 'error');
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh versions
|
||||
*/
|
||||
async refresh() {
|
||||
await this.loadVersions();
|
||||
}
|
||||
}
|
||||
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 };
|
||||
|
||||
@@ -63,6 +63,9 @@ export class FilterManager {
|
||||
this.initializeLicenseFilters();
|
||||
}
|
||||
|
||||
// Initialize tag logic toggle
|
||||
this.initializeTagLogicToggle();
|
||||
|
||||
// Add click handler for filter button
|
||||
if (this.filterButton) {
|
||||
this.filterButton.addEventListener('click', () => {
|
||||
@@ -84,6 +87,45 @@ export class FilterManager {
|
||||
this.loadFiltersFromStorage();
|
||||
}
|
||||
|
||||
initializeTagLogicToggle() {
|
||||
const toggleContainer = document.getElementById('tagLogicToggle');
|
||||
if (!toggleContainer) return;
|
||||
|
||||
const options = toggleContainer.querySelectorAll('.tag-logic-option');
|
||||
|
||||
options.forEach(option => {
|
||||
option.addEventListener('click', async () => {
|
||||
const value = option.dataset.value;
|
||||
if (this.filters.tagLogic === value) return;
|
||||
|
||||
this.filters.tagLogic = value;
|
||||
this.updateTagLogicToggleUI();
|
||||
|
||||
// Auto-apply filter when logic changes
|
||||
await this.applyFilters(false);
|
||||
});
|
||||
});
|
||||
|
||||
// Set initial state
|
||||
this.updateTagLogicToggleUI();
|
||||
}
|
||||
|
||||
updateTagLogicToggleUI() {
|
||||
const toggleContainer = document.getElementById('tagLogicToggle');
|
||||
if (!toggleContainer) return;
|
||||
|
||||
const options = toggleContainer.querySelectorAll('.tag-logic-option');
|
||||
const currentLogic = this.filters.tagLogic || 'any';
|
||||
|
||||
options.forEach(option => {
|
||||
if (option.dataset.value === currentLogic) {
|
||||
option.classList.add('active');
|
||||
} else {
|
||||
option.classList.remove('active');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async loadTopTags() {
|
||||
try {
|
||||
// Show loading state
|
||||
@@ -549,6 +591,17 @@ export class FilterManager {
|
||||
showToast('toast.filters.cleared', {}, 'info');
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh duplicates with new filters
|
||||
if (window.modelDuplicatesManager) {
|
||||
if (window.modelDuplicatesManager.inDuplicateMode) {
|
||||
// In duplicate mode: refresh the duplicate list
|
||||
await window.modelDuplicatesManager.findDuplicates();
|
||||
} else {
|
||||
// Not in duplicate mode: just update badge count
|
||||
window.modelDuplicatesManager.checkDuplicatesCount();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async clearFilters() {
|
||||
@@ -562,9 +615,13 @@ export class FilterManager {
|
||||
baseModel: [],
|
||||
tags: {},
|
||||
license: {},
|
||||
modelTypes: []
|
||||
modelTypes: [],
|
||||
tagLogic: 'any'
|
||||
});
|
||||
|
||||
// Update tag logic toggle UI
|
||||
this.updateTagLogicToggleUI();
|
||||
|
||||
// Update state
|
||||
const pageState = getCurrentPageState();
|
||||
pageState.filters = this.cloneFilters();
|
||||
@@ -609,6 +666,7 @@ export class FilterManager {
|
||||
pageState.filters = this.cloneFilters();
|
||||
|
||||
this.updateTagSelections();
|
||||
this.updateTagLogicToggleUI();
|
||||
this.updateActiveFiltersCount();
|
||||
|
||||
if (this.hasActiveFilters()) {
|
||||
@@ -644,7 +702,8 @@ export class FilterManager {
|
||||
baseModel: Array.isArray(source.baseModel) ? [...source.baseModel] : [],
|
||||
tags: this.normalizeTagFilters(source.tags),
|
||||
license: this.shouldShowLicenseFilters() ? this.normalizeLicenseFilters(source.license) : {},
|
||||
modelTypes: this.normalizeModelTypeFilters(source.modelTypes)
|
||||
modelTypes: this.normalizeModelTypeFilters(source.modelTypes),
|
||||
tagLogic: source.tagLogic || 'any'
|
||||
};
|
||||
}
|
||||
|
||||
@@ -726,7 +785,8 @@ export class FilterManager {
|
||||
baseModel: [...(this.filters.baseModel || [])],
|
||||
tags: { ...(this.filters.tags || {}) },
|
||||
license: { ...(this.filters.license || {}) },
|
||||
modelTypes: [...(this.filters.modelTypes || [])]
|
||||
modelTypes: [...(this.filters.modelTypes || [])],
|
||||
tagLogic: this.filters.tagLogic || 'any'
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -150,7 +150,13 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="filter-section">
|
||||
<h4>{{ t('header.filter.modelTags') }}</h4>
|
||||
<div class="filter-section-header">
|
||||
<h4>{{ t('header.filter.modelTags') }}</h4>
|
||||
<div class="tag-logic-toggle" id="tagLogicToggle">
|
||||
<button class="tag-logic-option" data-value="any" title="{{ t('header.filter.tagLogicAny') }}">{{ t('header.filter.any') }}</button>
|
||||
<button class="tag-logic-option" data-value="all" title="{{ t('header.filter.tagLogicAll') }}">{{ t('header.filter.all') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="filter-tags" id="modelTagsFilter">
|
||||
<!-- Top tags will be dynamically inserted here -->
|
||||
<div class="tags-loading">{{ t('common.status.loading') }}</div>
|
||||
|
||||
290
tests/frontend/managers/FilterManager.tagLogic.test.js
Normal file
290
tests/frontend/managers/FilterManager.tagLogic.test.js
Normal file
@@ -0,0 +1,290 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('../../../static/js/state/index.js', () => ({
|
||||
getCurrentPageState: vi.fn(() => ({
|
||||
filters: {},
|
||||
})),
|
||||
state: {
|
||||
currentPageType: 'loras',
|
||||
loadingManager: {
|
||||
showSimpleLoading: vi.fn(),
|
||||
hide: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
|
||||
showToast: vi.fn(),
|
||||
updatePanelPositions: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/api/modelApiFactory.js', () => ({
|
||||
getModelApiClient: vi.fn(() => ({
|
||||
loadMoreWithVirtualScroll: vi.fn().mockResolvedValue(),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/utils/storageHelpers.js', () => ({
|
||||
getStorageItem: vi.fn(),
|
||||
setStorageItem: vi.fn(),
|
||||
removeStorageItem: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({
|
||||
translate: vi.fn((key, _params, fallback) => fallback || key),
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/managers/FilterPresetManager.js', () => ({
|
||||
FilterPresetManager: vi.fn().mockImplementation(() => ({
|
||||
renderPresets: vi.fn(),
|
||||
saveActivePreset: vi.fn(),
|
||||
restoreActivePreset: vi.fn(),
|
||||
updateAddButtonState: vi.fn(),
|
||||
hasEmptyWildcardResult: vi.fn(() => false),
|
||||
})),
|
||||
EMPTY_WILDCARD_MARKER: '__EMPTY_WILDCARD_RESULT__',
|
||||
}));
|
||||
|
||||
import { FilterManager } from '../../../static/js/managers/FilterManager.js';
|
||||
import { getStorageItem, setStorageItem } from '../../../static/js/utils/storageHelpers.js';
|
||||
|
||||
describe('FilterManager - Tag Logic', () => {
|
||||
let manager;
|
||||
let mockFilterPanel;
|
||||
let mockTagLogicToggle;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Setup DOM mocks
|
||||
mockFilterPanel = document.createElement('div');
|
||||
mockFilterPanel.id = 'filterPanel';
|
||||
mockFilterPanel.classList.add('hidden');
|
||||
|
||||
mockTagLogicToggle = document.createElement('div');
|
||||
mockTagLogicToggle.id = 'tagLogicToggle';
|
||||
|
||||
// Create tag logic options
|
||||
const anyOption = document.createElement('button');
|
||||
anyOption.className = 'tag-logic-option';
|
||||
anyOption.dataset.value = 'any';
|
||||
mockTagLogicToggle.appendChild(anyOption);
|
||||
|
||||
const allOption = document.createElement('button');
|
||||
allOption.className = 'tag-logic-option';
|
||||
allOption.dataset.value = 'all';
|
||||
mockTagLogicToggle.appendChild(allOption);
|
||||
|
||||
document.body.appendChild(mockFilterPanel);
|
||||
document.body.appendChild(mockTagLogicToggle);
|
||||
|
||||
// Mock getElementById
|
||||
const originalGetElementById = document.getElementById;
|
||||
document.getElementById = vi.fn((id) => {
|
||||
if (id === 'filterPanel') return mockFilterPanel;
|
||||
if (id === 'tagLogicToggle') return mockTagLogicToggle;
|
||||
if (id === 'filterButton') return document.createElement('button');
|
||||
if (id === 'activeFiltersCount') return document.createElement('span');
|
||||
if (id === 'baseModelTags') return document.createElement('div');
|
||||
if (id === 'modelTypeTags') return document.createElement('div');
|
||||
return originalGetElementById.call(document, id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('initializeFilters', () => {
|
||||
it('should default tagLogic to "any" when not provided', () => {
|
||||
manager = new FilterManager({ page: 'loras' });
|
||||
|
||||
expect(manager.filters.tagLogic).toBe('any');
|
||||
});
|
||||
|
||||
it('should use provided tagLogic value', () => {
|
||||
getStorageItem.mockReturnValue({
|
||||
tagLogic: 'all',
|
||||
tags: {},
|
||||
baseModel: [],
|
||||
});
|
||||
|
||||
manager = new FilterManager({ page: 'loras' });
|
||||
|
||||
expect(manager.filters.tagLogic).toBe('all');
|
||||
});
|
||||
});
|
||||
|
||||
describe('initializeTagLogicToggle', () => {
|
||||
it('should set "any" option as active by default', () => {
|
||||
manager = new FilterManager({ page: 'loras' });
|
||||
|
||||
// Ensure filters.tagLogic is set to default
|
||||
manager.filters.tagLogic = 'any';
|
||||
|
||||
const anyOption = mockTagLogicToggle.querySelector('[data-value="any"]');
|
||||
const allOption = mockTagLogicToggle.querySelector('[data-value="all"]');
|
||||
|
||||
// Manually update UI to ensure correct state
|
||||
manager.updateTagLogicToggleUI();
|
||||
|
||||
expect(manager.filters.tagLogic).toBe('any');
|
||||
expect(anyOption.classList.contains('active')).toBe(true);
|
||||
expect(allOption.classList.contains('active')).toBe(false);
|
||||
});
|
||||
|
||||
it('should set "all" option as active when tagLogic is "all"', () => {
|
||||
getStorageItem.mockReturnValue({
|
||||
tagLogic: 'all',
|
||||
tags: {},
|
||||
baseModel: [],
|
||||
});
|
||||
|
||||
manager = new FilterManager({ page: 'loras' });
|
||||
|
||||
// Ensure filters.tagLogic is set correctly
|
||||
manager.filters.tagLogic = 'all';
|
||||
|
||||
const anyOption = mockTagLogicToggle.querySelector('[data-value="any"]');
|
||||
const allOption = mockTagLogicToggle.querySelector('[data-value="all"]');
|
||||
|
||||
// Manually update UI to ensure correct state
|
||||
manager.updateTagLogicToggleUI();
|
||||
|
||||
expect(manager.filters.tagLogic).toBe('all');
|
||||
expect(anyOption.classList.contains('active')).toBe(false);
|
||||
expect(allOption.classList.contains('active')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateTagLogicToggleUI', () => {
|
||||
it('should update UI when tagLogic changes', () => {
|
||||
// Clear any existing active classes first
|
||||
mockTagLogicToggle.querySelectorAll('.tag-logic-option').forEach(el => {
|
||||
el.classList.remove('active');
|
||||
});
|
||||
|
||||
manager = new FilterManager({ page: 'loras' });
|
||||
|
||||
let anyOption = mockTagLogicToggle.querySelector('[data-value="any"]');
|
||||
let allOption = mockTagLogicToggle.querySelector('[data-value="all"]');
|
||||
|
||||
// Ensure initial state
|
||||
manager.filters.tagLogic = 'any';
|
||||
manager.updateTagLogicToggleUI();
|
||||
expect(anyOption.classList.contains('active')).toBe(true);
|
||||
expect(allOption.classList.contains('active')).toBe(false);
|
||||
|
||||
// Change to "all"
|
||||
manager.filters.tagLogic = 'all';
|
||||
manager.updateTagLogicToggleUI();
|
||||
|
||||
expect(anyOption.classList.contains('active')).toBe(false);
|
||||
expect(allOption.classList.contains('active')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cloneFilters', () => {
|
||||
it('should include tagLogic in cloned filters', () => {
|
||||
manager = new FilterManager({ page: 'loras' });
|
||||
manager.filters.tagLogic = 'all';
|
||||
|
||||
const cloned = manager.cloneFilters();
|
||||
|
||||
expect(cloned.tagLogic).toBe('all');
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearFilters', () => {
|
||||
it('should reset tagLogic to "any"', () => {
|
||||
getStorageItem.mockReturnValue({
|
||||
tagLogic: 'all',
|
||||
tags: { anime: 'include' },
|
||||
baseModel: ['SDXL'],
|
||||
});
|
||||
|
||||
manager = new FilterManager({ page: 'loras' });
|
||||
expect(manager.filters.tagLogic).toBe('all');
|
||||
|
||||
manager.clearFilters();
|
||||
|
||||
expect(manager.filters.tagLogic).toBe('any');
|
||||
});
|
||||
|
||||
it('should update UI after clearing', () => {
|
||||
getStorageItem.mockReturnValue({
|
||||
tagLogic: 'all',
|
||||
tags: {},
|
||||
baseModel: [],
|
||||
});
|
||||
|
||||
manager = new FilterManager({ page: 'loras' });
|
||||
|
||||
const anyOption = mockTagLogicToggle.querySelector('[data-value="any"]');
|
||||
const allOption = mockTagLogicToggle.querySelector('[data-value="all"]');
|
||||
|
||||
// Initially "all" is active
|
||||
expect(allOption.classList.contains('active')).toBe(true);
|
||||
|
||||
manager.clearFilters();
|
||||
|
||||
// After clear, "any" should be active
|
||||
expect(anyOption.classList.contains('active')).toBe(true);
|
||||
expect(allOption.classList.contains('active')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadFiltersFromStorage', () => {
|
||||
it('should restore tagLogic from storage', () => {
|
||||
getStorageItem.mockReturnValue({
|
||||
tagLogic: 'all',
|
||||
tags: { anime: 'include' },
|
||||
baseModel: [],
|
||||
});
|
||||
|
||||
manager = new FilterManager({ page: 'loras' });
|
||||
|
||||
expect(manager.filters.tagLogic).toBe('all');
|
||||
expect(manager.filters.tags).toEqual({ anime: 'include' });
|
||||
});
|
||||
|
||||
it('should default to "any" when no tagLogic in storage', () => {
|
||||
getStorageItem.mockReturnValue({
|
||||
tags: {},
|
||||
baseModel: [],
|
||||
});
|
||||
|
||||
manager = new FilterManager({ page: 'loras' });
|
||||
|
||||
expect(manager.filters.tagLogic).toBe('any');
|
||||
});
|
||||
});
|
||||
|
||||
describe('tag logic toggle interaction', () => {
|
||||
it('should update tagLogic when clicking "all" option', async () => {
|
||||
manager = new FilterManager({ page: 'loras' });
|
||||
|
||||
const allOption = mockTagLogicToggle.querySelector('[data-value="all"]');
|
||||
|
||||
// Simulate click
|
||||
allOption.click();
|
||||
|
||||
// Wait for async operation
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
|
||||
expect(manager.filters.tagLogic).toBe('all');
|
||||
});
|
||||
|
||||
it('should not change tagLogic when clicking already active option', async () => {
|
||||
manager = new FilterManager({ page: 'loras' });
|
||||
|
||||
const anyOption = mockTagLogicToggle.querySelector('[data-value="any"]');
|
||||
const applyFiltersSpy = vi.spyOn(manager, 'applyFilters');
|
||||
|
||||
// Click already active option
|
||||
anyOption.click();
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
|
||||
// applyFilters should not be called since value didn't change
|
||||
expect(applyFiltersSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
166
tests/routes/test_tag_logic_param_parsing.py
Normal file
166
tests/routes/test_tag_logic_param_parsing.py
Normal file
@@ -0,0 +1,166 @@
|
||||
"""Tests for tag_logic parameter parsing in model handlers."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import Mock
|
||||
from aiohttp import web
|
||||
from aiohttp.test_utils import TestClient, TestServer
|
||||
|
||||
import sys
|
||||
import types
|
||||
|
||||
folder_paths_stub = types.SimpleNamespace(get_folder_paths=lambda *_: [])
|
||||
sys.modules.setdefault("folder_paths", folder_paths_stub)
|
||||
|
||||
from py.routes.handlers.model_handlers import ModelListingHandler
|
||||
|
||||
|
||||
class MockService:
|
||||
"""Mock service for testing."""
|
||||
|
||||
def __init__(self):
|
||||
self.model_type = "test-model"
|
||||
|
||||
async def get_paginated_data(self, **kwargs):
|
||||
# Store the kwargs for verification
|
||||
self.last_call_kwargs = kwargs
|
||||
return {
|
||||
"items": [],
|
||||
"total": 0,
|
||||
"page": 1,
|
||||
"page_size": 20,
|
||||
"total_pages": 0,
|
||||
}
|
||||
|
||||
async def format_response(self, item):
|
||||
return item
|
||||
|
||||
|
||||
def parse_specific_params(request):
|
||||
"""No specific params for testing."""
|
||||
return {}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def handler():
|
||||
service = MockService()
|
||||
logger = Mock()
|
||||
return ModelListingHandler(
|
||||
service=service,
|
||||
parse_specific_params=parse_specific_params,
|
||||
logger=logger,
|
||||
), service
|
||||
|
||||
|
||||
async def make_request(handler, query_string=""):
|
||||
"""Helper to create a request and call get_models."""
|
||||
app = web.Application()
|
||||
|
||||
async def test_handler(request):
|
||||
return await handler.get_models(request)
|
||||
|
||||
app.router.add_get("/test", test_handler)
|
||||
server = TestServer(app)
|
||||
client = TestClient(server)
|
||||
await client.start_server()
|
||||
|
||||
try:
|
||||
response = await client.get(f"/test?{query_string}")
|
||||
return response
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tag_logic_param_default_is_any(handler):
|
||||
"""Test that tag_logic defaults to 'any' when not provided."""
|
||||
h, service = handler
|
||||
|
||||
response = await make_request(h, "tag_include=anime&tag_include=realistic")
|
||||
assert response.status == 200
|
||||
|
||||
# Verify tag_logic was set to 'any' by default
|
||||
assert service.last_call_kwargs["tag_logic"] == "any"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tag_logic_param_explicit_any(handler):
|
||||
"""Test that tag_logic='any' is correctly parsed."""
|
||||
h, service = handler
|
||||
|
||||
response = await make_request(h, "tag_include=anime&tag_logic=any")
|
||||
assert response.status == 200
|
||||
|
||||
assert service.last_call_kwargs["tag_logic"] == "any"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tag_logic_param_explicit_all(handler):
|
||||
"""Test that tag_logic='all' is correctly parsed."""
|
||||
h, service = handler
|
||||
|
||||
response = await make_request(h, "tag_include=anime&tag_include=realistic&tag_logic=all")
|
||||
assert response.status == 200
|
||||
|
||||
assert service.last_call_kwargs["tag_logic"] == "all"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tag_logic_param_case_insensitive(handler):
|
||||
"""Test that tag_logic values are case insensitive."""
|
||||
h, service = handler
|
||||
|
||||
# Test uppercase
|
||||
response = await make_request(h, "tag_logic=ALL")
|
||||
assert response.status == 200
|
||||
assert service.last_call_kwargs["tag_logic"] == "all"
|
||||
|
||||
# Test mixed case
|
||||
response = await make_request(h, "tag_logic=Any")
|
||||
assert response.status == 200
|
||||
assert service.last_call_kwargs["tag_logic"] == "any"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tag_logic_param_invalid_value_defaults_to_any(handler):
|
||||
"""Test that invalid tag_logic values default to 'any'."""
|
||||
h, service = handler
|
||||
|
||||
response = await make_request(h, "tag_logic=invalid")
|
||||
assert response.status == 200
|
||||
|
||||
# Should default to 'any' for invalid values
|
||||
assert service.last_call_kwargs["tag_logic"] == "any"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tag_logic_param_with_other_filters(handler):
|
||||
"""Test that tag_logic works correctly with other filter parameters."""
|
||||
h, service = handler
|
||||
|
||||
query = (
|
||||
"tag_include=anime&"
|
||||
"tag_include=character&"
|
||||
"tag_exclude=nsfw&"
|
||||
"base_model=SDXL&"
|
||||
"tag_logic=all"
|
||||
)
|
||||
response = await make_request(h, query)
|
||||
assert response.status == 200
|
||||
|
||||
assert service.last_call_kwargs["tag_logic"] == "all"
|
||||
assert service.last_call_kwargs["base_models"] == ["SDXL"]
|
||||
assert "anime" in service.last_call_kwargs["tags"]
|
||||
assert "character" in service.last_call_kwargs["tags"]
|
||||
assert "nsfw" in service.last_call_kwargs["tags"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tag_logic_without_include_tags(handler):
|
||||
"""Test that tag_logic is still passed even without include tags."""
|
||||
h, service = handler
|
||||
|
||||
response = await make_request(h, "tag_logic=all&base_model=SDXL")
|
||||
assert response.status == 200
|
||||
|
||||
# tag_logic should still be set even without tag filters
|
||||
assert service.last_call_kwargs["tag_logic"] == "all"
|
||||
276
tests/services/test_tag_logic_filter.py
Normal file
276
tests/services/test_tag_logic_filter.py
Normal file
@@ -0,0 +1,276 @@
|
||||
"""Tests for tag logic (OR/AND) filtering functionality."""
|
||||
|
||||
import pytest
|
||||
from py.services.model_query import ModelFilterSet, FilterCriteria
|
||||
|
||||
|
||||
class StubSettings:
|
||||
def get(self, key, default=None):
|
||||
return default
|
||||
|
||||
|
||||
class TestTagLogicFilter:
|
||||
"""Test cases for tag_logic parameter in FilterCriteria."""
|
||||
|
||||
def test_tag_logic_any_returns_items_with_any_tag(self):
|
||||
"""Test that tag_logic='any' (OR) returns items matching any include tag."""
|
||||
filter_set = ModelFilterSet(StubSettings())
|
||||
data = [
|
||||
{"name": "m1", "tags": ["anime"]},
|
||||
{"name": "m2", "tags": ["realistic"]},
|
||||
{"name": "m3", "tags": ["anime", "realistic"]},
|
||||
{"name": "m4", "tags": ["style"]},
|
||||
{"name": "m5", "tags": []},
|
||||
]
|
||||
|
||||
# Include anime OR realistic (should match m1, m2, m3)
|
||||
criteria = FilterCriteria(
|
||||
tags={"anime": "include", "realistic": "include"},
|
||||
tag_logic="any"
|
||||
)
|
||||
result = filter_set.apply(data, criteria)
|
||||
assert len(result) == 3
|
||||
assert {item["name"] for item in result} == {"m1", "m2", "m3"}
|
||||
|
||||
def test_tag_logic_all_returns_items_with_all_tags(self):
|
||||
"""Test that tag_logic='all' (AND) returns only items matching all include tags."""
|
||||
filter_set = ModelFilterSet(StubSettings())
|
||||
data = [
|
||||
{"name": "m1", "tags": ["anime"]},
|
||||
{"name": "m2", "tags": ["realistic"]},
|
||||
{"name": "m3", "tags": ["anime", "realistic"]},
|
||||
{"name": "m4", "tags": ["style"]},
|
||||
{"name": "m5", "tags": []},
|
||||
]
|
||||
|
||||
# Include anime AND realistic (should match only m3)
|
||||
criteria = FilterCriteria(
|
||||
tags={"anime": "include", "realistic": "include"},
|
||||
tag_logic="all"
|
||||
)
|
||||
result = filter_set.apply(data, criteria)
|
||||
assert len(result) == 1
|
||||
assert result[0]["name"] == "m3"
|
||||
|
||||
def test_tag_logic_all_with_single_tag(self):
|
||||
"""Test that tag_logic='all' with single tag works same as 'any'."""
|
||||
filter_set = ModelFilterSet(StubSettings())
|
||||
data = [
|
||||
{"name": "m1", "tags": ["anime"]},
|
||||
{"name": "m2", "tags": ["realistic"]},
|
||||
{"name": "m3", "tags": ["anime", "realistic"]},
|
||||
]
|
||||
|
||||
# Include only anime with 'all' logic
|
||||
criteria = FilterCriteria(
|
||||
tags={"anime": "include"},
|
||||
tag_logic="all"
|
||||
)
|
||||
result = filter_set.apply(data, criteria)
|
||||
assert len(result) == 2
|
||||
assert {item["name"] for item in result} == {"m1", "m3"}
|
||||
|
||||
def test_tag_logic_any_with_exclude_tags(self):
|
||||
"""Test that tag_logic='any' works correctly with exclude tags."""
|
||||
filter_set = ModelFilterSet(StubSettings())
|
||||
data = [
|
||||
{"name": "m1", "tags": ["anime"]},
|
||||
{"name": "m2", "tags": ["realistic"]},
|
||||
{"name": "m3", "tags": ["anime", "realistic"]},
|
||||
{"name": "m4", "tags": ["nsfw"]},
|
||||
{"name": "m5", "tags": ["anime", "nsfw"]},
|
||||
]
|
||||
|
||||
# Include anime OR realistic, exclude nsfw
|
||||
criteria = FilterCriteria(
|
||||
tags={
|
||||
"anime": "include",
|
||||
"realistic": "include",
|
||||
"nsfw": "exclude"
|
||||
},
|
||||
tag_logic="any"
|
||||
)
|
||||
result = filter_set.apply(data, criteria)
|
||||
# Should match m1 (anime), m2 (realistic), m3 (both)
|
||||
# m4 excluded by nsfw, m5 excluded by nsfw
|
||||
assert len(result) == 3
|
||||
assert {item["name"] for item in result} == {"m1", "m2", "m3"}
|
||||
|
||||
def test_tag_logic_all_with_exclude_tags(self):
|
||||
"""Test that tag_logic='all' works correctly with exclude tags."""
|
||||
filter_set = ModelFilterSet(StubSettings())
|
||||
data = [
|
||||
{"name": "m1", "tags": ["anime", "character"]},
|
||||
{"name": "m2", "tags": ["realistic", "character"]},
|
||||
{"name": "m3", "tags": ["anime", "realistic", "character"]},
|
||||
{"name": "m4", "tags": ["anime", "character", "nsfw"]},
|
||||
]
|
||||
|
||||
# Include anime AND character, exclude nsfw
|
||||
criteria = FilterCriteria(
|
||||
tags={
|
||||
"anime": "include",
|
||||
"character": "include",
|
||||
"nsfw": "exclude"
|
||||
},
|
||||
tag_logic="all"
|
||||
)
|
||||
result = filter_set.apply(data, criteria)
|
||||
# m1: has anime+character, no nsfw ✓
|
||||
# m2: missing anime ✗
|
||||
# m3: has anime+character, no nsfw ✓
|
||||
# m4: has anime+character but also nsfw ✗
|
||||
assert len(result) == 2
|
||||
assert {item["name"] for item in result} == {"m1", "m3"}
|
||||
|
||||
def test_tag_logic_all_with_no_tags_special_case(self):
|
||||
"""Test tag_logic='all' with __no_tags__ special tag.
|
||||
|
||||
When __no_tags__ is used with 'all' logic along with regular tags,
|
||||
the behavior is: items with no tags are returned (since they satisfy
|
||||
__no_tags__), OR items that have all the regular tags.
|
||||
This is because __no_tags__ is a special condition that can't be ANDed
|
||||
with regular tags in a meaningful way.
|
||||
"""
|
||||
filter_set = ModelFilterSet(StubSettings())
|
||||
data = [
|
||||
{"name": "m1", "tags": ["anime"]},
|
||||
{"name": "m2", "tags": []},
|
||||
{"name": "m3", "tags": None},
|
||||
{"name": "m4", "tags": ["anime", "character"]},
|
||||
]
|
||||
|
||||
# Include anime AND __no_tags__ with 'all' logic
|
||||
# Implementation treats this as: no tags OR (all regular tags)
|
||||
criteria = FilterCriteria(
|
||||
tags={"anime": "include", "__no_tags__": "include"},
|
||||
tag_logic="all"
|
||||
)
|
||||
result = filter_set.apply(data, criteria)
|
||||
# Items with no tags: m2, m3
|
||||
# Items with all regular tags (anime): m1, m4
|
||||
# Combined: m1, m2, m3, m4 (all items)
|
||||
assert len(result) == 4
|
||||
|
||||
def test_tag_logic_any_with_no_tags_special_case(self):
|
||||
"""Test tag_logic='any' with __no_tags__ special tag."""
|
||||
filter_set = ModelFilterSet(StubSettings())
|
||||
data = [
|
||||
{"name": "m1", "tags": ["anime"]},
|
||||
{"name": "m2", "tags": []},
|
||||
{"name": "m3", "tags": None},
|
||||
{"name": "m4", "tags": ["realistic"]},
|
||||
]
|
||||
|
||||
# Include anime OR __no_tags__
|
||||
criteria = FilterCriteria(
|
||||
tags={"anime": "include", "__no_tags__": "include"},
|
||||
tag_logic="any"
|
||||
)
|
||||
result = filter_set.apply(data, criteria)
|
||||
# Should match m1 (anime), m2 (no tags), m3 (no tags)
|
||||
assert len(result) == 3
|
||||
assert {item["name"] for item in result} == {"m1", "m2", "m3"}
|
||||
|
||||
def test_tag_logic_default_is_any(self):
|
||||
"""Test that default tag_logic is 'any' when not specified."""
|
||||
filter_set = ModelFilterSet(StubSettings())
|
||||
data = [
|
||||
{"name": "m1", "tags": ["anime"]},
|
||||
{"name": "m2", "tags": ["realistic"]},
|
||||
{"name": "m3", "tags": ["anime", "realistic"]},
|
||||
]
|
||||
|
||||
# Not specifying tag_logic should default to 'any'
|
||||
criteria = FilterCriteria(
|
||||
tags={"anime": "include", "realistic": "include"}
|
||||
)
|
||||
result = filter_set.apply(data, criteria)
|
||||
# Should match m1, m2, m3 (OR behavior)
|
||||
assert len(result) == 3
|
||||
assert {item["name"] for item in result} == {"m1", "m2", "m3"}
|
||||
|
||||
def test_tag_logic_case_insensitive(self):
|
||||
"""Test that tag_logic values are case insensitive."""
|
||||
filter_set = ModelFilterSet(StubSettings())
|
||||
data = [
|
||||
{"name": "m1", "tags": ["anime"]},
|
||||
{"name": "m2", "tags": ["realistic"]},
|
||||
{"name": "m3", "tags": ["anime", "realistic"]},
|
||||
]
|
||||
|
||||
# Test uppercase 'ALL'
|
||||
criteria = FilterCriteria(
|
||||
tags={"anime": "include", "realistic": "include"},
|
||||
tag_logic="ALL"
|
||||
)
|
||||
result = filter_set.apply(data, criteria)
|
||||
assert len(result) == 1
|
||||
assert result[0]["name"] == "m3"
|
||||
|
||||
# Test mixed case 'Any'
|
||||
criteria = FilterCriteria(
|
||||
tags={"anime": "include", "realistic": "include"},
|
||||
tag_logic="Any"
|
||||
)
|
||||
result = filter_set.apply(data, criteria)
|
||||
assert len(result) == 3
|
||||
|
||||
def test_tag_logic_all_with_three_tags(self):
|
||||
"""Test tag_logic='all' with three include tags."""
|
||||
filter_set = ModelFilterSet(StubSettings())
|
||||
data = [
|
||||
{"name": "m1", "tags": ["anime"]},
|
||||
{"name": "m2", "tags": ["anime", "character"]},
|
||||
{"name": "m3", "tags": ["anime", "character", "style"]},
|
||||
{"name": "m4", "tags": ["character", "style"]},
|
||||
]
|
||||
|
||||
# Include anime AND character AND style
|
||||
criteria = FilterCriteria(
|
||||
tags={
|
||||
"anime": "include",
|
||||
"character": "include",
|
||||
"style": "include"
|
||||
},
|
||||
tag_logic="all"
|
||||
)
|
||||
result = filter_set.apply(data, criteria)
|
||||
# Only m3 has all three tags
|
||||
assert len(result) == 1
|
||||
assert result[0]["name"] == "m3"
|
||||
|
||||
def test_tag_logic_empty_include_tags(self):
|
||||
"""Test that empty include tags with any logic returns all items."""
|
||||
filter_set = ModelFilterSet(StubSettings())
|
||||
data = [
|
||||
{"name": "m1", "tags": ["anime"]},
|
||||
{"name": "m2", "tags": ["realistic"]},
|
||||
]
|
||||
|
||||
# Only exclude tags, no include tags
|
||||
criteria = FilterCriteria(
|
||||
tags={"nsfw": "exclude"},
|
||||
tag_logic="all"
|
||||
)
|
||||
result = filter_set.apply(data, criteria)
|
||||
# Both should match since no include filters
|
||||
assert len(result) == 2
|
||||
|
||||
def test_tag_logic_with_none_tags_field(self):
|
||||
"""Test tag_logic handles items with None tags field."""
|
||||
filter_set = ModelFilterSet(StubSettings())
|
||||
data = [
|
||||
{"name": "m1", "tags": ["anime", "realistic"]},
|
||||
{"name": "m2", "tags": None},
|
||||
{"name": "m3", "tags": ["anime"]},
|
||||
]
|
||||
|
||||
criteria = FilterCriteria(
|
||||
tags={"anime": "include", "realistic": "include"},
|
||||
tag_logic="all"
|
||||
)
|
||||
result = filter_set.apply(data, criteria)
|
||||
# Only m1 has both anime and realistic
|
||||
assert len(result) == 1
|
||||
assert result[0]["name"] == "m1"
|
||||
@@ -1,17 +1,31 @@
|
||||
<template>
|
||||
<div class="autocomplete-text-widget">
|
||||
<textarea
|
||||
ref="textareaRef"
|
||||
:placeholder="placeholder"
|
||||
:spellcheck="spellcheck ?? false"
|
||||
:class="['text-input', { 'vue-dom-mode': isVueDomMode }]"
|
||||
@input="onInput"
|
||||
/>
|
||||
<div class="input-wrapper">
|
||||
<textarea
|
||||
ref="textareaRef"
|
||||
:placeholder="placeholder"
|
||||
:spellcheck="spellcheck ?? false"
|
||||
:class="['text-input', { 'vue-dom-mode': isVueDomMode }]"
|
||||
@input="onInput"
|
||||
/>
|
||||
<button
|
||||
v-if="showClearButton"
|
||||
type="button"
|
||||
class="clear-button"
|
||||
title="Clear text"
|
||||
@click="clearText"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { ref, onMounted, onUnmounted, computed } from 'vue'
|
||||
import { useAutocomplete } from '@/composables/useAutocomplete'
|
||||
|
||||
// Access LiteGraph global for initial mode detection
|
||||
@@ -20,6 +34,7 @@ declare const LiteGraph: { vueNodesMode?: boolean } | undefined
|
||||
export interface AutocompleteTextWidgetInterface {
|
||||
inputEl?: HTMLTextAreaElement
|
||||
callback?: (v: string) => void
|
||||
onSetValue?: (v: string) => void
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -41,6 +56,10 @@ const onModeChange = (event: Event) => {
|
||||
}
|
||||
|
||||
const textareaRef = ref<HTMLTextAreaElement | null>(null)
|
||||
const hasText = ref(false)
|
||||
|
||||
// Show clear button when there is text
|
||||
const showClearButton = computed(() => hasText.value)
|
||||
|
||||
// Initialize autocomplete with direct ref access
|
||||
useAutocomplete(
|
||||
@@ -49,17 +68,60 @@ useAutocomplete(
|
||||
{ showPreview: props.showPreview ?? true }
|
||||
)
|
||||
|
||||
const updateHasTextState = () => {
|
||||
hasText.value = textareaRef.value ? textareaRef.value.value.length > 0 : false
|
||||
}
|
||||
|
||||
const onInput = () => {
|
||||
// Update hasText state
|
||||
updateHasTextState()
|
||||
|
||||
// Call widget callback when text changes
|
||||
if (textareaRef.value && typeof props.widget.callback === 'function') {
|
||||
props.widget.callback(textareaRef.value.value)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle external value changes (e.g., from "send lora to workflow")
|
||||
const onExternalValueChange = (event: CustomEvent<{ value: string }>) => {
|
||||
updateHasTextState()
|
||||
}
|
||||
|
||||
// Setup widget.onSetValue callback for external value changes
|
||||
const setupWidgetOnSetValue = () => {
|
||||
if (props.widget) {
|
||||
props.widget.onSetValue = (value: string) => {
|
||||
// The DOM value is already set by setValue, just update our state
|
||||
hasText.value = value.length > 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const clearText = () => {
|
||||
if (textareaRef.value) {
|
||||
textareaRef.value.value = ''
|
||||
hasText.value = false
|
||||
textareaRef.value.focus()
|
||||
|
||||
// Trigger callback with empty value
|
||||
if (typeof props.widget.callback === 'function') {
|
||||
props.widget.callback('')
|
||||
}
|
||||
|
||||
// Dispatch input event to ensure autocomplete handles the change
|
||||
textareaRef.value.dispatchEvent(new Event('input'))
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// Register textarea reference with widget
|
||||
if (textareaRef.value) {
|
||||
props.widget.inputEl = textareaRef.value
|
||||
// Initialize hasText state
|
||||
hasText.value = textareaRef.value.value.length > 0
|
||||
|
||||
// Listen for external value change events from setValue
|
||||
textareaRef.value.addEventListener('lora-manager:autocomplete-value-changed', onExternalValueChange as EventListener)
|
||||
}
|
||||
|
||||
// Setup callback for input changes
|
||||
@@ -67,6 +129,9 @@ onMounted(() => {
|
||||
props.widget.callback(textareaRef.value.value)
|
||||
}
|
||||
|
||||
// Setup widget.onSetValue callback
|
||||
setupWidgetOnSetValue()
|
||||
|
||||
// Listen for custom event dispatched by main.ts
|
||||
document.addEventListener('lora-manager:vue-mode-change', onModeChange)
|
||||
})
|
||||
@@ -76,6 +141,16 @@ onUnmounted(() => {
|
||||
if (props.widget.inputEl === textareaRef.value) {
|
||||
props.widget.inputEl = undefined
|
||||
}
|
||||
|
||||
// Remove external value change event listener
|
||||
if (textareaRef.value) {
|
||||
textareaRef.value.removeEventListener('lora-manager:autocomplete-value-changed', onExternalValueChange as EventListener)
|
||||
}
|
||||
|
||||
// Clean up onSetValue callback
|
||||
if (props.widget) {
|
||||
props.widget.onSetValue = undefined
|
||||
}
|
||||
|
||||
// Remove event listener
|
||||
document.removeEventListener('lora-manager:vue-mode-change', onModeChange)
|
||||
@@ -91,6 +166,13 @@ onUnmounted(() => {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.input-wrapper {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Canvas mode styles (default) - matches built-in comfy-multiline-input */
|
||||
.text-input {
|
||||
flex: 1;
|
||||
@@ -122,4 +204,54 @@ onUnmounted(() => {
|
||||
.text-input:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Clear button styles */
|
||||
.clear-button {
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
top: 4px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: rgba(128, 128, 128, 0.5);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.2s ease, background-color 0.2s ease;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.clear-button:hover {
|
||||
opacity: 1;
|
||||
background: rgba(255, 100, 100, 0.8);
|
||||
}
|
||||
|
||||
.clear-button svg {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
/* Vue DOM mode adjustments for clear button */
|
||||
.text-input.vue-dom-mode ~ .clear-button {
|
||||
right: 8px;
|
||||
top: 8px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: rgba(107, 114, 128, 0.6);
|
||||
}
|
||||
|
||||
.text-input.vue-dom-mode ~ .clear-button:hover {
|
||||
background: oklch(62% 0.18 25);
|
||||
}
|
||||
|
||||
.text-input.vue-dom-mode ~ .clear-button svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -416,6 +416,14 @@ function createAutocompleteTextWidgetFactory(
|
||||
setValue(v: string) {
|
||||
if (widget.inputEl) {
|
||||
widget.inputEl.value = v ?? ''
|
||||
// Notify Vue component of value change via custom event
|
||||
widget.inputEl.dispatchEvent(new CustomEvent('lora-manager:autocomplete-value-changed', {
|
||||
detail: { value: v ?? '' }
|
||||
}))
|
||||
}
|
||||
// Also call onSetValue if defined (for Vue component integration)
|
||||
if (typeof widget.onSetValue === 'function') {
|
||||
widget.onSetValue(v ?? '')
|
||||
}
|
||||
},
|
||||
serialize: true,
|
||||
|
||||
@@ -1979,16 +1979,22 @@ to { transform: rotate(360deg);
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.autocomplete-text-widget[data-v-f4679753] {
|
||||
.autocomplete-text-widget[data-v-2081708c] {
|
||||
background: transparent;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.input-wrapper[data-v-2081708c] {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Canvas mode styles (default) - matches built-in comfy-multiline-input */
|
||||
.text-input[data-v-f4679753] {
|
||||
.text-input[data-v-2081708c] {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
background-color: var(--comfy-input-bg, #222);
|
||||
@@ -2005,7 +2011,7 @@ to { transform: rotate(360deg);
|
||||
}
|
||||
|
||||
/* Vue DOM mode styles - matches built-in p-textarea in Vue DOM mode */
|
||||
.text-input.vue-dom-mode[data-v-f4679753] {
|
||||
.text-input.vue-dom-mode[data-v-2081708c] {
|
||||
background-color: var(--color-charcoal-400, #313235);
|
||||
color: #fff;
|
||||
padding: 8px 12px;
|
||||
@@ -2014,8 +2020,54 @@ to { transform: rotate(360deg);
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
}
|
||||
.text-input[data-v-f4679753]:focus {
|
||||
.text-input[data-v-2081708c]:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Clear button styles */
|
||||
.clear-button[data-v-2081708c] {
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
top: 4px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: rgba(128, 128, 128, 0.5);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.2s ease, background-color 0.2s ease;
|
||||
z-index: 10;
|
||||
}
|
||||
.clear-button[data-v-2081708c]:hover {
|
||||
opacity: 1;
|
||||
background: rgba(255, 100, 100, 0.8);
|
||||
}
|
||||
.clear-button svg[data-v-2081708c] {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
/* Vue DOM mode adjustments for clear button */
|
||||
.text-input.vue-dom-mode ~ .clear-button[data-v-2081708c] {
|
||||
right: 8px;
|
||||
top: 8px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: rgba(107, 114, 128, 0.6);
|
||||
}
|
||||
.text-input.vue-dom-mode ~ .clear-button[data-v-2081708c]:hover {
|
||||
background: oklch(62% 0.18 25);
|
||||
}
|
||||
.text-input.vue-dom-mode ~ .clear-button svg[data-v-2081708c] {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}`));
|
||||
document.head.appendChild(elementStyle);
|
||||
}
|
||||
@@ -10490,7 +10542,7 @@ const _sfc_main$m = /* @__PURE__ */ defineComponent({
|
||||
const EditButton = /* @__PURE__ */ _export_sfc(_sfc_main$m, [["__scopeId", "data-v-8da8aa4b"]]);
|
||||
const _hoisted_1$l = { class: "section" };
|
||||
const _hoisted_2$h = { class: "section__header" };
|
||||
const _hoisted_3$e = { class: "section__content" };
|
||||
const _hoisted_3$f = { class: "section__content" };
|
||||
const _hoisted_4$d = {
|
||||
key: 0,
|
||||
class: "section__placeholder"
|
||||
@@ -10520,7 +10572,7 @@ const _sfc_main$l = /* @__PURE__ */ defineComponent({
|
||||
onClick: _cache[0] || (_cache[0] = ($event) => _ctx.$emit("edit"))
|
||||
})
|
||||
]),
|
||||
createBaseVNode("div", _hoisted_3$e, [
|
||||
createBaseVNode("div", _hoisted_3$f, [
|
||||
__props.selected.length === 0 ? (openBlock(), createElementBlock("div", _hoisted_4$d, " All models ")) : (openBlock(), createElementBlock("div", _hoisted_5$b, [
|
||||
(openBlock(true), createElementBlock(Fragment, null, renderList(__props.selected, (name) => {
|
||||
return openBlock(), createBlock(FilterChip, {
|
||||
@@ -10539,7 +10591,7 @@ const _sfc_main$l = /* @__PURE__ */ defineComponent({
|
||||
const BaseModelSection = /* @__PURE__ */ _export_sfc(_sfc_main$l, [["__scopeId", "data-v-12f059e2"]]);
|
||||
const _hoisted_1$k = { class: "section" };
|
||||
const _hoisted_2$g = { class: "section__columns" };
|
||||
const _hoisted_3$d = { class: "section__column" };
|
||||
const _hoisted_3$e = { class: "section__column" };
|
||||
const _hoisted_4$c = { class: "section__column-header" };
|
||||
const _hoisted_5$a = { class: "section__column-content" };
|
||||
const _hoisted_6$a = {
|
||||
@@ -10575,7 +10627,7 @@ const _sfc_main$k = /* @__PURE__ */ defineComponent({
|
||||
createBaseVNode("span", { class: "section__title" }, "TAGS")
|
||||
], -1)),
|
||||
createBaseVNode("div", _hoisted_2$g, [
|
||||
createBaseVNode("div", _hoisted_3$d, [
|
||||
createBaseVNode("div", _hoisted_3$e, [
|
||||
createBaseVNode("div", _hoisted_4$c, [
|
||||
_cache[2] || (_cache[2] = createBaseVNode("span", { class: "section__column-title section__column-title--include" }, "INCLUDE", -1)),
|
||||
createVNode(EditButton, {
|
||||
@@ -10621,7 +10673,7 @@ const _sfc_main$k = /* @__PURE__ */ defineComponent({
|
||||
const TagsSection = /* @__PURE__ */ _export_sfc(_sfc_main$k, [["__scopeId", "data-v-b869b780"]]);
|
||||
const _hoisted_1$j = { class: "section" };
|
||||
const _hoisted_2$f = { class: "section__columns" };
|
||||
const _hoisted_3$c = { class: "section__column" };
|
||||
const _hoisted_3$d = { class: "section__column" };
|
||||
const _hoisted_4$b = { class: "section__column-header" };
|
||||
const _hoisted_5$9 = { class: "section__content" };
|
||||
const _hoisted_6$9 = {
|
||||
@@ -10669,7 +10721,7 @@ const _sfc_main$j = /* @__PURE__ */ defineComponent({
|
||||
createBaseVNode("span", { class: "section__title" }, "FOLDERS")
|
||||
], -1)),
|
||||
createBaseVNode("div", _hoisted_2$f, [
|
||||
createBaseVNode("div", _hoisted_3$c, [
|
||||
createBaseVNode("div", _hoisted_3$d, [
|
||||
createBaseVNode("div", _hoisted_4$b, [
|
||||
_cache[3] || (_cache[3] = createBaseVNode("span", { class: "section__column-title section__column-title--include" }, "INCLUDE", -1)),
|
||||
createBaseVNode("button", {
|
||||
@@ -10737,7 +10789,7 @@ const _sfc_main$j = /* @__PURE__ */ defineComponent({
|
||||
const FoldersSection = /* @__PURE__ */ _export_sfc(_sfc_main$j, [["__scopeId", "data-v-af9caf84"]]);
|
||||
const _hoisted_1$i = { class: "section" };
|
||||
const _hoisted_2$e = { class: "section__toggles" };
|
||||
const _hoisted_3$b = { class: "toggle-item" };
|
||||
const _hoisted_3$c = { class: "toggle-item" };
|
||||
const _hoisted_4$a = ["aria-checked"];
|
||||
const _hoisted_5$8 = { class: "toggle-item" };
|
||||
const _hoisted_6$8 = ["aria-checked"];
|
||||
@@ -10755,7 +10807,7 @@ const _sfc_main$i = /* @__PURE__ */ defineComponent({
|
||||
createBaseVNode("span", { class: "section__title" }, "LICENSE")
|
||||
], -1)),
|
||||
createBaseVNode("div", _hoisted_2$e, [
|
||||
createBaseVNode("label", _hoisted_3$b, [
|
||||
createBaseVNode("label", _hoisted_3$c, [
|
||||
_cache[3] || (_cache[3] = createBaseVNode("span", { class: "toggle-item__label" }, "No Credit Required", -1)),
|
||||
createBaseVNode("button", {
|
||||
type: "button",
|
||||
@@ -10789,7 +10841,7 @@ const _sfc_main$i = /* @__PURE__ */ defineComponent({
|
||||
const LicenseSection = /* @__PURE__ */ _export_sfc(_sfc_main$i, [["__scopeId", "data-v-dea4adf6"]]);
|
||||
const _hoisted_1$h = { class: "preview" };
|
||||
const _hoisted_2$d = { class: "preview__title" };
|
||||
const _hoisted_3$a = ["disabled"];
|
||||
const _hoisted_3$b = ["disabled"];
|
||||
const _hoisted_4$9 = {
|
||||
key: 0,
|
||||
class: "preview__tooltip"
|
||||
@@ -10848,7 +10900,7 @@ const _sfc_main$h = /* @__PURE__ */ defineComponent({
|
||||
d: "M8 3c-1.552 0-2.94.707-3.857 1.818a.5.5 0 1 1-.771-.636A6.002 6.002 0 0 1 13.917 7H12.9A5.002 5.002 0 0 0 8 3zM3.1 9a5.002 5.002 0 0 0 8.757 2.182.5.5 0 1 1 .771.636A6.002 6.002 0 0 1 2.083 9H3.1z"
|
||||
})
|
||||
], -1)
|
||||
])], 10, _hoisted_3$a)
|
||||
])], 10, _hoisted_3$b)
|
||||
], 32),
|
||||
createVNode(Transition, { name: "tooltip" }, {
|
||||
default: withCtx(() => [
|
||||
@@ -10949,7 +11001,7 @@ const _sfc_main$g = /* @__PURE__ */ defineComponent({
|
||||
const LoraPoolSummaryView = /* @__PURE__ */ _export_sfc(_sfc_main$g, [["__scopeId", "data-v-328e7526"]]);
|
||||
const _hoisted_1$f = { class: "lora-pool-modal__header" };
|
||||
const _hoisted_2$b = { class: "lora-pool-modal__title-container" };
|
||||
const _hoisted_3$9 = { class: "lora-pool-modal__title" };
|
||||
const _hoisted_3$a = { class: "lora-pool-modal__title" };
|
||||
const _hoisted_4$8 = {
|
||||
key: 0,
|
||||
class: "lora-pool-modal__subtitle"
|
||||
@@ -11009,7 +11061,7 @@ const _sfc_main$f = /* @__PURE__ */ defineComponent({
|
||||
}, [
|
||||
createBaseVNode("div", _hoisted_1$f, [
|
||||
createBaseVNode("div", _hoisted_2$b, [
|
||||
createBaseVNode("h3", _hoisted_3$9, toDisplayString(__props.title), 1),
|
||||
createBaseVNode("h3", _hoisted_3$a, toDisplayString(__props.title), 1),
|
||||
__props.subtitle ? (openBlock(), createElementBlock("p", _hoisted_4$8, toDisplayString(__props.subtitle), 1)) : createCommentVNode("", true)
|
||||
]),
|
||||
createBaseVNode("button", {
|
||||
@@ -11037,7 +11089,7 @@ const _sfc_main$f = /* @__PURE__ */ defineComponent({
|
||||
const ModalWrapper = /* @__PURE__ */ _export_sfc(_sfc_main$f, [["__scopeId", "data-v-7b4de03d"]]);
|
||||
const _hoisted_1$e = { class: "search-container" };
|
||||
const _hoisted_2$a = { class: "model-list" };
|
||||
const _hoisted_3$8 = ["checked", "onChange"];
|
||||
const _hoisted_3$9 = ["checked", "onChange"];
|
||||
const _hoisted_4$7 = { class: "model-checkbox-visual" };
|
||||
const _hoisted_5$5 = {
|
||||
key: 0,
|
||||
@@ -11147,7 +11199,7 @@ const _sfc_main$e = /* @__PURE__ */ defineComponent({
|
||||
checked: isSelected(model.name),
|
||||
onChange: ($event) => toggleModel(model.name),
|
||||
class: "model-checkbox"
|
||||
}, null, 40, _hoisted_3$8),
|
||||
}, null, 40, _hoisted_3$9),
|
||||
createBaseVNode("span", _hoisted_4$7, [
|
||||
isSelected(model.name) ? (openBlock(), createElementBlock("svg", _hoisted_5$5, [..._cache[4] || (_cache[4] = [
|
||||
createBaseVNode("path", { d: "M13.854 3.646a.5.5 0 0 1 0 .708l-7 7a.5.5 0 0 1-.708 0l-3.5-3.5a.5.5 0 1 1 .708-.708L6.5 10.293l6.646-6.647a.5.5 0 0 1 .708 0z" }, null, -1)
|
||||
@@ -11168,7 +11220,7 @@ const _sfc_main$e = /* @__PURE__ */ defineComponent({
|
||||
const BaseModelModal = /* @__PURE__ */ _export_sfc(_sfc_main$e, [["__scopeId", "data-v-e02ca44a"]]);
|
||||
const _hoisted_1$d = { class: "search-container" };
|
||||
const _hoisted_2$9 = { class: "tags-container" };
|
||||
const _hoisted_3$7 = ["onClick"];
|
||||
const _hoisted_3$8 = ["onClick"];
|
||||
const _hoisted_4$6 = {
|
||||
key: 0,
|
||||
class: "no-results"
|
||||
@@ -11270,7 +11322,7 @@ const _sfc_main$d = /* @__PURE__ */ defineComponent({
|
||||
type: "button",
|
||||
class: normalizeClass(["tag-chip", { "tag-chip--selected": isSelected(tag.tag) }]),
|
||||
onClick: ($event) => toggleTag(tag.tag)
|
||||
}, toDisplayString(tag.tag), 11, _hoisted_3$7);
|
||||
}, toDisplayString(tag.tag), 11, _hoisted_3$8);
|
||||
}), 128)),
|
||||
filteredTags.value.length === 0 ? (openBlock(), createElementBlock("div", _hoisted_4$6, " No tags found ")) : createCommentVNode("", true)
|
||||
])
|
||||
@@ -11286,7 +11338,7 @@ const _hoisted_2$8 = {
|
||||
key: 1,
|
||||
class: "tree-node__toggle-spacer"
|
||||
};
|
||||
const _hoisted_3$6 = { class: "tree-node__checkbox-label" };
|
||||
const _hoisted_3$7 = { class: "tree-node__checkbox-label" };
|
||||
const _hoisted_4$5 = ["checked"];
|
||||
const _hoisted_5$4 = {
|
||||
key: 0,
|
||||
@@ -11347,7 +11399,7 @@ const _sfc_main$c = /* @__PURE__ */ defineComponent({
|
||||
createBaseVNode("path", { d: "M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708z" }, null, -1)
|
||||
])], 2))
|
||||
])) : (openBlock(), createElementBlock("span", _hoisted_2$8)),
|
||||
createBaseVNode("label", _hoisted_3$6, [
|
||||
createBaseVNode("label", _hoisted_3$7, [
|
||||
createBaseVNode("input", {
|
||||
type: "checkbox",
|
||||
class: "tree-node__checkbox",
|
||||
@@ -11393,7 +11445,7 @@ const _sfc_main$c = /* @__PURE__ */ defineComponent({
|
||||
const FolderTreeNode = /* @__PURE__ */ _export_sfc(_sfc_main$c, [["__scopeId", "data-v-90187dd4"]]);
|
||||
const _hoisted_1$b = { class: "search-container" };
|
||||
const _hoisted_2$7 = { class: "folder-tree" };
|
||||
const _hoisted_3$5 = {
|
||||
const _hoisted_3$6 = {
|
||||
key: 1,
|
||||
class: "no-results"
|
||||
};
|
||||
@@ -11492,7 +11544,7 @@ const _sfc_main$b = /* @__PURE__ */ defineComponent({
|
||||
onToggleExpand: toggleExpand,
|
||||
onToggleSelect: toggleSelect
|
||||
}, null, 8, ["node", "selected", "expanded", "variant"]);
|
||||
}), 128)) : (openBlock(), createElementBlock("div", _hoisted_3$5, " No folders found "))
|
||||
}), 128)) : (openBlock(), createElementBlock("div", _hoisted_3$6, " No folders found "))
|
||||
])
|
||||
]),
|
||||
_: 1
|
||||
@@ -11836,7 +11888,7 @@ const _sfc_main$a = /* @__PURE__ */ defineComponent({
|
||||
const LoraPoolWidget = /* @__PURE__ */ _export_sfc(_sfc_main$a, [["__scopeId", "data-v-4456abba"]]);
|
||||
const _hoisted_1$9 = { class: "last-used-preview" };
|
||||
const _hoisted_2$6 = { class: "last-used-preview__content" };
|
||||
const _hoisted_3$4 = ["src", "onError"];
|
||||
const _hoisted_3$5 = ["src", "onError"];
|
||||
const _hoisted_4$4 = {
|
||||
key: 1,
|
||||
class: "last-used-preview__thumb last-used-preview__thumb--placeholder"
|
||||
@@ -11888,7 +11940,7 @@ const _sfc_main$9 = /* @__PURE__ */ defineComponent({
|
||||
src: previewUrls.value[lora.name],
|
||||
class: "last-used-preview__thumb",
|
||||
onError: ($event) => onImageError(lora.name)
|
||||
}, null, 40, _hoisted_3$4)) : (openBlock(), createElementBlock("div", _hoisted_4$4, [..._cache[0] || (_cache[0] = [
|
||||
}, null, 40, _hoisted_3$5)) : (openBlock(), createElementBlock("div", _hoisted_4$4, [..._cache[0] || (_cache[0] = [
|
||||
createBaseVNode("svg", {
|
||||
viewBox: "0 0 16 16",
|
||||
fill: "currentColor"
|
||||
@@ -12329,7 +12381,7 @@ const _sfc_main$7 = /* @__PURE__ */ defineComponent({
|
||||
const DualRangeSlider = /* @__PURE__ */ _export_sfc(_sfc_main$7, [["__scopeId", "data-v-9f6c6950"]]);
|
||||
const _hoisted_1$6 = { class: "randomizer-settings" };
|
||||
const _hoisted_2$4 = { class: "setting-section" };
|
||||
const _hoisted_3$3 = { class: "count-mode-tabs" };
|
||||
const _hoisted_3$4 = { class: "count-mode-tabs" };
|
||||
const _hoisted_4$3 = ["checked"];
|
||||
const _hoisted_5$2 = ["checked"];
|
||||
const _hoisted_6$2 = { class: "slider-container" };
|
||||
@@ -12394,7 +12446,7 @@ const _sfc_main$6 = /* @__PURE__ */ defineComponent({
|
||||
], -1)),
|
||||
createBaseVNode("div", _hoisted_2$4, [
|
||||
_cache[20] || (_cache[20] = createBaseVNode("label", { class: "setting-label" }, "LoRA Count", -1)),
|
||||
createBaseVNode("div", _hoisted_3$3, [
|
||||
createBaseVNode("div", _hoisted_3$4, [
|
||||
createBaseVNode("label", {
|
||||
class: normalizeClass(["count-mode-tab", { active: __props.countMode === "fixed" }])
|
||||
}, [
|
||||
@@ -12959,7 +13011,7 @@ const _sfc_main$5 = /* @__PURE__ */ defineComponent({
|
||||
const LoraRandomizerWidget = /* @__PURE__ */ _export_sfc(_sfc_main$5, [["__scopeId", "data-v-8063df56"]]);
|
||||
const _hoisted_1$4 = { class: "cycler-settings" };
|
||||
const _hoisted_2$3 = { class: "setting-section progress-section" };
|
||||
const _hoisted_3$2 = { class: "progress-label" };
|
||||
const _hoisted_3$3 = { class: "progress-label" };
|
||||
const _hoisted_4$2 = ["title"];
|
||||
const _hoisted_5$1 = { class: "progress-counter" };
|
||||
const _hoisted_6$1 = { class: "progress-index" };
|
||||
@@ -13074,7 +13126,7 @@ const _sfc_main$4 = /* @__PURE__ */ defineComponent({
|
||||
class: normalizeClass(["progress-info", { disabled: __props.isPauseDisabled }]),
|
||||
onClick: handleOpenSelector
|
||||
}, [
|
||||
createBaseVNode("span", _hoisted_3$2, toDisplayString(__props.isWorkflowExecuting ? "Using LoRA:" : "Next LoRA:"), 1),
|
||||
createBaseVNode("span", _hoisted_3$3, toDisplayString(__props.isWorkflowExecuting ? "Using LoRA:" : "Next LoRA:"), 1),
|
||||
createBaseVNode("span", {
|
||||
class: normalizeClass(["progress-name clickable", { disabled: __props.isPauseDisabled }]),
|
||||
title: __props.currentLoraFilename
|
||||
@@ -13229,7 +13281,7 @@ const _sfc_main$4 = /* @__PURE__ */ defineComponent({
|
||||
const LoraCyclerSettingsView = /* @__PURE__ */ _export_sfc(_sfc_main$4, [["__scopeId", "data-v-5b16b9d3"]]);
|
||||
const _hoisted_1$3 = { class: "search-container" };
|
||||
const _hoisted_2$2 = { class: "lora-list" };
|
||||
const _hoisted_3$1 = ["onMouseenter", "onClick"];
|
||||
const _hoisted_3$2 = ["onMouseenter", "onClick"];
|
||||
const _hoisted_4$1 = { class: "lora-index" };
|
||||
const _hoisted_5 = ["title"];
|
||||
const _hoisted_6 = {
|
||||
@@ -13399,7 +13451,7 @@ const _sfc_main$3 = /* @__PURE__ */ defineComponent({
|
||||
title: item.lora.file_name
|
||||
}, toDisplayString(item.lora.file_name), 9, _hoisted_5),
|
||||
__props.currentIndex === item.index ? (openBlock(), createElementBlock("span", _hoisted_6, "Current")) : createCommentVNode("", true)
|
||||
], 42, _hoisted_3$1);
|
||||
], 42, _hoisted_3$2);
|
||||
}), 128)),
|
||||
filteredList.value.length === 0 ? (openBlock(), createElementBlock("div", _hoisted_7, " No LoRAs found ")) : createCommentVNode("", true)
|
||||
])
|
||||
@@ -13926,7 +13978,7 @@ const _hoisted_2$1 = {
|
||||
class: "json-content",
|
||||
ref: "contentRef"
|
||||
};
|
||||
const _hoisted_3 = ["innerHTML"];
|
||||
const _hoisted_3$1 = ["innerHTML"];
|
||||
const _hoisted_4 = {
|
||||
key: 1,
|
||||
class: "placeholder"
|
||||
@@ -14021,7 +14073,7 @@ const _sfc_main$1 = /* @__PURE__ */ defineComponent({
|
||||
hasMetadata.value ? (openBlock(), createElementBlock("pre", {
|
||||
key: 0,
|
||||
innerHTML: highlightedJson.value
|
||||
}, null, 8, _hoisted_3)) : (openBlock(), createElementBlock("div", _hoisted_4, "No metadata available"))
|
||||
}, null, 8, _hoisted_3$1)) : (openBlock(), createElementBlock("div", _hoisted_4, "No metadata available"))
|
||||
], 512)
|
||||
]);
|
||||
};
|
||||
@@ -14094,7 +14146,8 @@ function useAutocomplete(textareaRef, modelType = "loras", options = {}) {
|
||||
};
|
||||
}
|
||||
const _hoisted_1 = { class: "autocomplete-text-widget" };
|
||||
const _hoisted_2 = ["placeholder", "spellcheck"];
|
||||
const _hoisted_2 = { class: "input-wrapper" };
|
||||
const _hoisted_3 = ["placeholder", "spellcheck"];
|
||||
const _sfc_main = /* @__PURE__ */ defineComponent({
|
||||
__name: "AutocompleteTextWidget",
|
||||
props: {
|
||||
@@ -14113,46 +14166,111 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
|
||||
isVueDomMode.value = customEvent.detail.isVueDomMode;
|
||||
};
|
||||
const textareaRef = ref(null);
|
||||
const hasText = ref(false);
|
||||
const showClearButton = computed(() => hasText.value);
|
||||
useAutocomplete(
|
||||
textareaRef,
|
||||
props.modelType ?? "loras",
|
||||
{ showPreview: props.showPreview ?? true }
|
||||
);
|
||||
const updateHasTextState = () => {
|
||||
hasText.value = textareaRef.value ? textareaRef.value.value.length > 0 : false;
|
||||
};
|
||||
const onInput = () => {
|
||||
updateHasTextState();
|
||||
if (textareaRef.value && typeof props.widget.callback === "function") {
|
||||
props.widget.callback(textareaRef.value.value);
|
||||
}
|
||||
};
|
||||
const onExternalValueChange = (event) => {
|
||||
updateHasTextState();
|
||||
};
|
||||
const setupWidgetOnSetValue = () => {
|
||||
if (props.widget) {
|
||||
props.widget.onSetValue = (value) => {
|
||||
hasText.value = value.length > 0;
|
||||
};
|
||||
}
|
||||
};
|
||||
const clearText = () => {
|
||||
if (textareaRef.value) {
|
||||
textareaRef.value.value = "";
|
||||
hasText.value = false;
|
||||
textareaRef.value.focus();
|
||||
if (typeof props.widget.callback === "function") {
|
||||
props.widget.callback("");
|
||||
}
|
||||
textareaRef.value.dispatchEvent(new Event("input"));
|
||||
}
|
||||
};
|
||||
onMounted(() => {
|
||||
if (textareaRef.value) {
|
||||
props.widget.inputEl = textareaRef.value;
|
||||
hasText.value = textareaRef.value.value.length > 0;
|
||||
textareaRef.value.addEventListener("lora-manager:autocomplete-value-changed", onExternalValueChange);
|
||||
}
|
||||
if (textareaRef.value && typeof props.widget.callback === "function") {
|
||||
props.widget.callback(textareaRef.value.value);
|
||||
}
|
||||
setupWidgetOnSetValue();
|
||||
document.addEventListener("lora-manager:vue-mode-change", onModeChange);
|
||||
});
|
||||
onUnmounted(() => {
|
||||
if (props.widget.inputEl === textareaRef.value) {
|
||||
props.widget.inputEl = void 0;
|
||||
}
|
||||
if (textareaRef.value) {
|
||||
textareaRef.value.removeEventListener("lora-manager:autocomplete-value-changed", onExternalValueChange);
|
||||
}
|
||||
if (props.widget) {
|
||||
props.widget.onSetValue = void 0;
|
||||
}
|
||||
document.removeEventListener("lora-manager:vue-mode-change", onModeChange);
|
||||
});
|
||||
return (_ctx, _cache) => {
|
||||
return openBlock(), createElementBlock("div", _hoisted_1, [
|
||||
createBaseVNode("textarea", {
|
||||
ref_key: "textareaRef",
|
||||
ref: textareaRef,
|
||||
placeholder: __props.placeholder,
|
||||
spellcheck: __props.spellcheck ?? false,
|
||||
class: normalizeClass(["text-input", { "vue-dom-mode": isVueDomMode.value }]),
|
||||
onInput
|
||||
}, null, 42, _hoisted_2)
|
||||
createBaseVNode("div", _hoisted_2, [
|
||||
createBaseVNode("textarea", {
|
||||
ref_key: "textareaRef",
|
||||
ref: textareaRef,
|
||||
placeholder: __props.placeholder,
|
||||
spellcheck: __props.spellcheck ?? false,
|
||||
class: normalizeClass(["text-input", { "vue-dom-mode": isVueDomMode.value }]),
|
||||
onInput
|
||||
}, null, 42, _hoisted_3),
|
||||
showClearButton.value ? (openBlock(), createElementBlock("button", {
|
||||
key: 0,
|
||||
type: "button",
|
||||
class: "clear-button",
|
||||
title: "Clear text",
|
||||
onClick: clearText
|
||||
}, [..._cache[0] || (_cache[0] = [
|
||||
createBaseVNode("svg", {
|
||||
viewBox: "0 0 24 24",
|
||||
fill: "none",
|
||||
stroke: "currentColor",
|
||||
"stroke-width": "2"
|
||||
}, [
|
||||
createBaseVNode("line", {
|
||||
x1: "18",
|
||||
y1: "6",
|
||||
x2: "6",
|
||||
y2: "18"
|
||||
}),
|
||||
createBaseVNode("line", {
|
||||
x1: "6",
|
||||
y1: "6",
|
||||
x2: "18",
|
||||
y2: "18"
|
||||
})
|
||||
], -1)
|
||||
])])) : createCommentVNode("", true)
|
||||
])
|
||||
]);
|
||||
};
|
||||
}
|
||||
});
|
||||
const AutocompleteTextWidget = /* @__PURE__ */ _export_sfc(_sfc_main, [["__scopeId", "data-v-f4679753"]]);
|
||||
const AutocompleteTextWidget = /* @__PURE__ */ _export_sfc(_sfc_main, [["__scopeId", "data-v-2081708c"]]);
|
||||
const LORA_PROVIDER_NODE_TYPES$1 = [
|
||||
"Lora Stacker (LoraManager)",
|
||||
"Lora Randomizer (LoraManager)",
|
||||
@@ -14734,6 +14852,12 @@ function createAutocompleteTextWidgetFactory(node, widgetName, modelType, inputO
|
||||
setValue(v2) {
|
||||
if (widget.inputEl) {
|
||||
widget.inputEl.value = v2 ?? "";
|
||||
widget.inputEl.dispatchEvent(new CustomEvent("lora-manager:autocomplete-value-changed", {
|
||||
detail: { value: v2 ?? "" }
|
||||
}));
|
||||
}
|
||||
if (typeof widget.onSetValue === "function") {
|
||||
widget.onSetValue(v2 ?? "");
|
||||
}
|
||||
},
|
||||
serialize: true,
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user