Compare commits

...

7 Commits

Author SHA1 Message Date
Will Miao
26884630d3 feat(model-modal): improve transition animations and fix navigation logic
- Add `!important` to overlay background and closing opacity for consistent styling
- Remove navigation spam prevention to allow consecutive transitions
- Implement in-place content updates during model transitions instead of reopening modal
- Add opacity transitions for showcase and metadata components
- Fetch complete Civitai metadata during transitions for updated model data
2026-02-07 10:20:05 +08:00
Will Miao
66e9d77c67 feat: implement lazy loading and image caching for thumbnails
Add lazy loading with skeleton animations and IndexedDB-based image caching to improve thumbnail loading performance. The changes include:

- CSS animations for loading states with shimmer effects
- Priority-based image loading queue with configurable concurrency
- Persistent image cache with automatic cleanup
- Error handling and cached image highlighting
- Increased concurrent loading from 3 to 6 for faster initial display

This reduces network requests and provides smoother user experience when browsing large model collections.
2026-02-07 09:53:24 +08:00
Will Miao
5ffca15172 feat(showcase): add wheel navigation, horizontal thumbnail rail scroll, and image counter
- Add horizontal scroll to thumbnail rail on wheel event
- Add wheel-based image navigation in main image area (150 threshold)
- Add image counter showing current position (e.g., "1 / 12")
- Use tabular-nums and min-width: 2ch to prevent counter layout shift
- Respect params panel scrolling when using wheel navigation
2026-02-07 00:35:29 +08:00
Will Miao
4d9115339b feat(showcase): improve remote image loading with skeleton animation and fade-in effects
- Add preloadMedia() for async image/video loading before display
- Implement renderLoadingSkeleton() with fa-circle-notch fa-spin animation
- Add fadeIn transition (opacity 0→1) for main media elements
- Remove shimmer gradient animation from thumbnails for cleaner look
- Use solid background color placeholder with subtle fade-in for thumbnails
- Fixes progressive rendering of remote images from top to bottom
- Prevents black flash during loading with proper loading states
2026-02-06 23:49:45 +08:00
Will Miao
469f7a1829 feat(showcase): add Show button to NSFW notice in main media area
- Add showcase__nsfw-notice-content wrapper for better layout
- Add showcase__nsfw-show-btn with styling matching card.css show-content-btn
- Add show-content action handler that triggers global blur toggle
- Button uses blue accent color with eye icon and hover effects
- Clicking Show button syncs with blur toggle button icon state
- Use unique class names to avoid conflicts with card.css
2026-02-06 23:06:02 +08:00
Will Miao
d27e3c8126 Phase 2: Model Modal Tabs and Edit Features
- Implement Versions Tab with version cards, badges, and actions
- Implement Recipes Tab with recipe cards grid
- Add Usage Tips editing (add/remove parameters)
- Add Trigger Words editing (add/remove/copy)
- Optimize Notes textarea with auto-save indicator
- Implement custom example upload area with drag-drop
- Add missing i18n translation keys
- Add CSS styles for versions, recipes, and upload components
- Fix async/await syntax error in RecipesTab.js
2026-02-06 20:13:07 +08:00
Will Miao
7bc63d7631 Phase 1: Model Modal Split-View Redesign
- Implement new split-view overlay layout (left showcase, right metadata)
- Add keyboard navigation (↑↓ for model, ←→ for examples, ESC to close)
- Create Thumbnail Rail for quick example navigation
- Add image controls (view params, set preview, delete)
- Implement parameter panel with prompt display
- Add metadata panel with model info, tags, licenses
- Create tabs (Description/Versions/Recipes) with accordion content
- Integrate with existing ModelCard click handlers
- Add first-use keyboard hint overlay

New files:
- static/js/components/model-modal/*.js
- static/css/components/model-modal/*.css
- docs/plan/model-modal-redesign.md
2026-02-06 19:24:49 +08:00
19 changed files with 6435 additions and 14 deletions

View File

@@ -0,0 +1,449 @@
# Model Modal UI/UX 重构计划
> **Status**: Phase 1 Complete ✓
> **Created**: 2026-02-06
> **Target**: v2.x Release
---
## 1. 项目概述
### 1.1 背景与问题
当前 Model Modal 存在以下 UX 问题:
1. **空间利用率低** - 固定 800px 宽度,大屏环境下大量留白
2. **Tab 切换繁琐** - 4 个 TabExamples/Description/Versions/Recipes隐藏了重要信息
3. **Examples 浏览不便** - 需持续向下滚动,无快速导航
4. **添加自定义示例困难** - 需滚动到底部,操作路径长
### 1.2 设计目标
- **空间效率**: 利用 header 以下、sidebar 右侧的全部可用空间
- **浏览体验**: 类似 Midjourney 的沉浸式图片浏览
- **信息架构**: 关键元数据固定可见,次要信息可折叠
- **操作效率**: 直觉化的键盘导航,减少点击次数
---
## 2. 设计方案
### 2.1 布局架构: Split-View Overlay
```
┌──────────────────────────────────────────────────────────────────────┐
│ HEADER (保持现有) │
├──────────┬───────────────────────────────────────────────────────────┤
│ │ ┌───────────────────────────┬────────────────────────┐ │
│ FOLDER │ │ │ MODEL HEADER │ │
│ SIDEBAR │ │ EXAMPLES SHOWCASE │ ├─ Name │ │
│ (可折叠) │ │ │ ├─ Creator + Actions │ │
│ │ │ ┌─────────────────┐ │ ├─ Tags │ │
│ │ │ │ │ ├────────────────────────┤ │
│ │ │ │ MAIN IMAGE │ │ COMPACT METADATA │ │
│ │ │ │ (自适应高度) │ │ ├─ Ver | Base | Size │ │
│ │ │ │ │ │ ├─ Location │ │
│ │ │ └─────────────────┘ │ ├─ Usage Tips │ │
│ │ │ │ ├─ Trigger Words │ │
│ │ │ [PARAMS PREVIEW] │ ├─ Notes │ │
│ │ │ (Prompt + Copy) ├────────────────────────┤ │
│ │ │ │ CONTENT TABS │ │
│ │ │ ┌─────────────────┐ │ [Desc][Versions][Rec] │ │
│ │ │ │ THUMBNAIL RAIL │ │ │ │
│ │ │ │ [1][2][3][4][+]│ │ TAB CONTENT AREA │ │
│ │ │ └─────────────────┘ │ (Accordion / List) │ │
│ │ └───────────────────────────┴────────────────────────┘ │
└──────────┴───────────────────────────────────────────────────────────┘
```
**尺寸规格**:
- Sidebar 展开: Left 60% | Right 40%
- Sidebar 折叠: Left 65% | Right 35%
- 最小宽度: 1200px (低于此值触发移动端适配)
### 2.2 左侧: Examples Showcase
#### 2.2.1 组件结构
| 组件 | 描述 | 优先级 |
|------|------|--------|
| Main Image | 自适应容器,保持原始比例,最大高度 70vh | P0 |
| Params Panel | 底部滑出面板,显示 Prompt/Negative/Params | P0 |
| Thumbnail Rail | 底部横向滚动条,支持点击跳转 | P0 |
| Add Button | Rail 最右侧 "+" 按钮,打开上传区 | P0 |
| Nav Arrows | 图片左右两侧悬停显示 | P1 |
#### 2.2.2 图片悬停操作
```
┌─────────────────┐
│ [👁] [📌] [🗑] │ ← 查看参数 | 设为预览 | 删除
│ │
│ IMAGE │
│ │
└─────────────────┘
```
#### 2.2.3 键盘导航
| 按键 | 功能 | 说明 |
|------|------|------|
| ← | 上一个 Example | 循环(首张时到最后一张) |
| → | 下一个 Example | 循环(末张时到第一张) |
| I | Toggle Params Panel | 显示/隐藏图片参数 |
| C | Copy Prompt | 复制当前 Prompt 到剪贴板 |
### 2.3 右侧: Metadata + Content
#### 2.3.1 固定头部 (不可折叠)
```
┌────────────────────────┐
│ MODEL NAME [×] │
│ [👤 Creator] [🌐 Civ] │
│ [tag1] [tag2] [tag3] │
├────────────────────────┤
│ Ver: v1.0 Size: 96MB │
│ Base: SDXL │
│ 📁 /path/to/file │
├────────────────────────┤
│ USAGE TIPS [✏️] │
│ [strength: 0.8] [+] │
├────────────────────────┤
│ TRIGGER WORDS [✏️] │
│ [word1] [word2] [📋] │
├────────────────────────┤
│ NOTES [✏️] │
│ "Add your notes..." │
└────────────────────────┘
```
#### 2.3.2 Tabs 设计
保留横向 Tab 切换,但优化内容展示:
| Tab | 内容 | 交互方式 |
|-----|------|----------|
| Description | About this version + Model Description | Accordion 折叠 |
| Versions | 版本列表卡片 | 完整列表视图 |
| Recipes | Recipe 卡片网格 | 网格布局 |
**Accordion 行为**:
- 手风琴模式:同时只能展开一个 section
- 默认About this version 展开Description 折叠
- 动画300ms ease-out
### 2.4 全局导航
#### 2.4.1 Model 切换
| 按键 | 功能 |
|------|------|
| ↑ | 上一个 Model |
| ↓ | 下一个 Model |
**切换动画**:
1. 当前 Modal 淡出 (150ms)
2. 加载新 Model 数据
3. 新 Modal 淡入 (150ms)
4. 保持当前 Tab 状态(不重置到默认)
#### 2.4.2 首次使用提示
Modal 首次打开时,顶部显示提示条:
```
┌─────────────────────────────────────────────────────────────┐
│ 💡 Tip: ↑↓ 切换模型 | ←→ 浏览示例 | I 查看参数 | ESC 关闭 │
└─────────────────────────────────────────────────────────────┘
```
- 3 秒后自动淡出
- 提供 "不再显示" 选项
---
## 3. 技术实现
### 3.1 文件结构变更
```
static/
├── js/
│ └── components/
│ └── model-modal/ # 新目录
│ ├── index.js # 主入口
│ ├── ModelModal.js # Modal 容器
│ ├── ExampleShowcase.js # 左侧展示
│ ├── ThumbnailRail.js # 缩略图导航
│ ├── MetadataPanel.js # 右侧元数据
│ ├── ContentTabs.js # Tabs 容器
│ └── accordions/ # Accordion 组件
│ ├── DescriptionAccordion.js
│ └── VersionsList.js
├── css/
│ └── components/
│ └── model-modal/ # 新目录
│ ├── modal-overlay.css
│ ├── showcase.css
│ ├── thumbnail-rail.css
│ ├── metadata.css
│ └── tabs.css
```
### 3.2 核心 CSS 架构
```css
/* modal-overlay.css */
.model-overlay {
position: fixed;
top: var(--header-height);
left: var(--sidebar-width, 250px);
right: 0;
bottom: 0;
z-index: var(--z-modal);
display: grid;
grid-template-columns: 1.2fr 0.8fr;
gap: 0;
background: var(--bg-color);
animation: modalSlideIn 0.2s ease-out;
}
.model-overlay.sidebar-collapsed {
left: var(--sidebar-collapsed-width, 60px);
grid-template-columns: 1.3fr 0.7fr;
}
/* 移动端适配 */
@media (max-width: 768px) {
.model-overlay {
left: 0;
grid-template-columns: 1fr;
grid-template-rows: auto 1fr;
}
}
```
### 3.3 响应式断点
| 断点 | 布局 | 说明 |
|------|------|------|
| > 1400px | Split 60/40 | 大屏优化 |
| 1200-1400px | Split 50/50 | 标准桌面 |
| 768-1200px | Split 50/50 | 小屏桌面/平板 |
| < 768px | Stack | 移动端Examples 在上Metadata 在下 |
---
## 4. 实施阶段
### Phase 1: 核心重构 (预计 2-3 周)
**目标**: MVP 可用基础功能完整
**任务清单**:
- [ ] 创建新的文件结构和基础组件
- [ ] 实现 Split-View Overlay 布局
- [ ] CSS Grid 布局系统
- [ ] Sidebar 状态联动
- [ ] 响应式断点处理
- [ ] 迁移左侧 Examples 区域
- [ ] Main Image 自适应容器
- [ ] Thumbnail Rail 组件
- [ ] Params Panel 滑出动画
- [ ] 实现新的快捷键系统
- [ ] ↑↓ 切换 Model
- [ ] ←→ 切换 Example
- [ ] I/C/ESC 功能键
- [ ] 移除旧 Modal max-width 限制
- [ ] 基础动画过渡
**验收标准**:
- [ ] 新布局在各种屏幕尺寸下正常显示
- [ ] 键盘导航正常工作
- [ ] 无阻塞性 Bug
---
### Phase 2: 体验优化 (预计 1-2 周)
**目标**: 信息架构优化交互细节完善
**任务清单**:
- [ ] Accordion 组件实现
- [ ] Description Tab 的折叠面板
- [ ] 手风琴交互逻辑
- [ ] 动画优化
- [ ] 右侧 Metadata 区域固定化
- [ ] 滚动行为优化
- [ ] 编辑功能迁移
- [ ] Example 添加流程优化
- [ ] Rail 上的 "+" 按钮
- [ ] Inline Upload Area
- [ ] 拖拽上传支持
- [ ] Model 切换动画优化
- [ ] 淡入淡出效果
- [ ] 加载状态指示
- [ ] 首次使用提示
**验收标准**:
- [ ] Accordion 交互流畅
- [ ] 添加 Example 操作路径 < 2
- [ ] Model 切换视觉反馈清晰
---
### Phase 3: 功能完整化 (预计 1-2 周)
**目标**: 所有现有功能迁移完成
**任务清单**:
- [ ] Versions Tab 完整实现
- [ ] 版本列表卡片
- [ ] 下载/忽略/删除操作
- [ ] 更新状态 Badge
- [ ] Recipes Tab 完整实现
- [ ] Recipe 卡片网格
- [ ] 复制/应用操作
- [ ] Tab 状态保持
- [ ] 切换 Model 时保持当前 Tab
- [ ] Tab 内容滚动位置记忆
- [ ] 所有编辑功能迁移
- [ ] Model Name 编辑
- [ ] Base Model 编辑
- [ ] File Name 编辑
- [ ] Tags 编辑
- [ ] Usage Tips 编辑
- [ ] Notes 编辑
**验收标准**:
- [ ] 所有现有功能可用
- [ ] 单元测试覆盖率 > 80%
---
### Phase 4: 打磨与优化 (预计 1 周)
**目标**: 性能优化,边缘 case 处理
**任务清单**:
- [ ] 移动端适配完善
- [ ] Stack 布局优化
- [ ] 触摸手势支持(滑动切换)
- [ ] 性能优化
- [ ] 图片懒加载优化
- [ ] 虚拟滚动(大量 Examples 时)
- [ ] 减少重渲染
- [ ] 无障碍支持
- [ ] ARIA 标签
- [ ] 键盘导航焦点管理
- [ ] 屏幕阅读器测试
- [ ] 动画性能优化
- [ ] will-change 优化
- [ ] 减少 layout thrashing
**验收标准**:
- [ ] Lighthouse Performance > 90
- [ ] 无障碍检查无严重问题
---
### Phase 5: 发布准备 (预计 3-5 天)
**目标**: 稳定版本,文档完整
**任务清单**:
- [ ] Bug 修复
- [ ] 用户测试
- [ ] 更新文档
- [ ] README 更新
- [ ] 快捷键说明
- [ ] 截图/GIF 演示
- [ ] 发布说明
---
## 5. 风险与应对
| 风险 | 影响 | 应对策略 |
|------|------|----------|
| 用户不适应新布局 | 中 | 提供设置选项,允许切换回旧版(临时) |
| 性能问题(大量 Examples | 高 | Phase 4 重点优化,必要时虚拟滚动 |
| 移动端体验不佳 | 中 | 单独设计移动端布局,非简单缩放 |
| 与现有扩展冲突 | 低 | 充分的回归测试 |
---
## 6. 关联文件
### 6.1 需修改的现有文件
```
static/js/components/shared/ModelModal.js # 完全重构
static/js/components/shared/showcase/ # 迁移至新目录
static/css/components/lora-modal/ # 样式重写
static/css/components/modal/_base.css # Overlay 样式调整
```
### 6.2 参考资源
- [Midjourney Explore](https://www.midjourney.com/explore) - 交互参考
- [Pinterest Pin View](https://www.pinterest.com) - 布局参考
- [AGENTS.md](/AGENTS.md) - 项目代码规范
---
## 7. Checklist
### 7.1 启动前
- [ ] 创建 feature branch: `feature/model-modal-redesign`
- [ ] 设置开发环境
- [ ] 准备测试数据集(多种 Model 类型)
### 7.2 每个 Phase 完成时
- [ ] 代码审查
- [ ] 功能测试
- [ ] 更新本文档状态
### 7.3 发布前
- [ ] 完整回归测试
- [ ] 更新 CHANGELOG
- [ ] 更新版本号
---
## 8. 附录
### 8.1 命名规范
| 类型 | 规范 | 示例 |
|------|------|------|
| 文件 | kebab-case | `thumbnail-rail.js` |
| 组件 | PascalCase | `ThumbnailRail` |
| CSS 类 | BEM | `.thumbnail-rail__item--active` |
| 变量 | camelCase | `currentExampleIndex` |
### 8.2 颜色规范
使用现有 CSS 变量,不引入新颜色:
```css
--lora-accent: #4299e1;
--lora-accent-l: 60%;
--lora-accent-c: 0.2;
--lora-accent-h: 250;
--lora-surface: var(--card-bg);
--lora-border: var(--border-color);
```
---
*Last Updated: 2026-02-06*

View File

@@ -911,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",
@@ -930,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",
@@ -945,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",
@@ -965,6 +979,9 @@
"wordSuggestions": "Word Suggestions",
"wordsFound": "{count} words found",
"loading": "Loading suggestions..."
},
"validation": {
"duplicate": "This trigger word already exists"
}
},
"description": {
@@ -990,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",
@@ -1002,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...",

View File

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

View File

@@ -0,0 +1,354 @@
/* Metadata Panel - Right Panel */
.metadata {
display: flex;
flex-direction: column;
height: 100%;
background: var(--card-bg);
border-left: 1px solid var(--lora-border);
overflow: hidden;
}
/* Header section */
.metadata__header {
padding: var(--space-3);
border-bottom: 1px solid var(--lora-border);
background: var(--lora-surface);
}
.metadata__title-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-2);
margin-bottom: var(--space-2);
}
.metadata__name {
font-size: 1.4em;
font-weight: 600;
line-height: 1.3;
color: var(--text-color);
margin: 0;
word-break: break-word;
}
.metadata__edit-btn {
flex-shrink: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
border-radius: 50%;
color: var(--text-color);
opacity: 0;
cursor: pointer;
transition: opacity 0.2s, background-color 0.2s;
}
.metadata__header:hover .metadata__edit-btn {
opacity: 0.5;
}
.metadata__edit-btn:hover {
opacity: 1 !important;
background: var(--lora-border);
}
/* Creator and actions */
.metadata__actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2);
margin-bottom: var(--space-2);
}
.metadata__creator {
display: flex;
align-items: center;
gap: var(--space-1);
padding: var(--space-1) var(--space-2);
background: rgba(0, 0, 0, 0.03);
border: 1px solid var(--lora-border);
border-radius: var(--border-radius-sm);
cursor: pointer;
transition: all 0.2s;
}
[data-theme="dark"] .metadata__creator {
background: rgba(255, 255, 255, 0.03);
}
.metadata__creator:hover {
border-color: var(--lora-accent);
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1);
}
.metadata__creator-avatar {
width: 24px;
height: 24px;
border-radius: 50%;
overflow: hidden;
background: var(--lora-accent);
display: flex;
align-items: center;
justify-content: center;
}
.metadata__creator-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.metadata__creator-avatar i {
color: white;
font-size: 0.8rem;
}
.metadata__creator-name {
font-size: 0.9em;
font-weight: 500;
}
.metadata__civitai-link {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: rgba(0, 0, 0, 0.03);
border: 1px solid var(--lora-border);
border-radius: var(--border-radius-sm);
color: var(--text-color);
font-size: 0.85em;
text-decoration: none;
transition: all 0.2s;
}
.metadata__civitai-link:hover {
border-color: var(--lora-accent);
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1);
}
/* License icons */
.metadata__licenses {
display: flex;
gap: var(--space-1);
margin-left: auto;
}
.metadata__license-icon {
width: 22px;
height: 22px;
display: inline-block;
background-color: var(--text-muted);
-webkit-mask: var(--license-icon-image) center/contain no-repeat;
mask: var(--license-icon-image) center/contain no-repeat;
transition: background-color 0.2s ease, transform 0.2s ease;
}
.metadata__license-icon:hover {
background-color: var(--text-color);
transform: translateY(-1px);
}
/* Tags */
.metadata__tags {
display: flex;
flex-wrap: wrap;
gap: var(--space-1);
}
.metadata__tag {
display: inline-flex;
align-items: center;
padding: 4px 10px;
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1);
border: 1px solid oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.2);
border-radius: 999px;
font-size: 0.8em;
color: var(--lora-accent);
transition: all 0.2s;
}
.metadata__tag:hover {
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.2);
border-color: var(--lora-accent);
}
/* Info grid */
.metadata__info {
padding: var(--space-2) var(--space-3);
border-bottom: 1px solid var(--lora-border);
}
.metadata__info-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--space-2);
}
.metadata__info-item {
display: flex;
flex-direction: column;
gap: 2px;
}
.metadata__info-item--full {
grid-column: 1 / -1;
}
.metadata__info-label {
font-size: 0.75em;
color: var(--text-color);
opacity: 0.7;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.metadata__info-value {
font-size: 0.9em;
color: var(--text-color);
word-break: break-word;
}
.metadata__info-value--mono {
font-family: monospace;
font-size: 0.85em;
}
.metadata__info-value--path {
cursor: pointer;
text-decoration: underline;
text-decoration-style: dotted;
}
.metadata__info-value--path:hover {
opacity: 0.8;
}
/* Editable sections */
.metadata__section {
padding: var(--space-2) var(--space-3);
border-bottom: 1px solid var(--lora-border);
}
.metadata__section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-1);
}
.metadata__section-title {
font-size: 0.75em;
color: var(--text-color);
opacity: 0.7;
text-transform: uppercase;
letter-spacing: 0.05em;
font-weight: 600;
}
.metadata__section-edit {
background: transparent;
border: none;
color: var(--text-color);
opacity: 0;
cursor: pointer;
padding: 4px;
border-radius: var(--border-radius-xs);
transition: opacity 0.2s, background-color 0.2s;
}
.metadata__section:hover .metadata__section-edit {
opacity: 0.5;
}
.metadata__section-edit:hover {
opacity: 1 !important;
background: var(--lora-border);
}
/* Usage tips / Trigger words */
.metadata__tags--editable {
display: flex;
flex-wrap: wrap;
gap: var(--space-1);
}
.metadata__tag--editable {
cursor: pointer;
}
.metadata__tag--editable:hover {
background: var(--lora-error);
border-color: var(--lora-error);
color: white;
}
.metadata__tag--add {
background: transparent;
border-style: dashed;
cursor: pointer;
}
.metadata__tag--add:hover {
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1);
border-style: solid;
}
/* Notes textarea */
.metadata__notes {
min-height: 60px;
max-height: 120px;
padding: var(--space-2);
background: var(--bg-color);
border: 1px solid var(--lora-border);
border-radius: var(--border-radius-xs);
font-size: 0.9em;
line-height: 1.5;
color: var(--text-color);
resize: vertical;
width: 100%;
}
.metadata__notes:focus {
outline: none;
border-color: var(--lora-accent);
}
.metadata__notes::placeholder {
color: var(--text-color);
opacity: 0.5;
}
/* Content area (tabs + scrollable content) */
.metadata__content {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
}
/* Mobile adjustments */
@media (max-width: 768px) {
.metadata__header {
padding: var(--space-2);
}
.metadata__name {
font-size: 1.2em;
}
.metadata__info {
padding: var(--space-2);
}
.metadata__section {
padding: var(--space-2);
}
}

View File

@@ -0,0 +1,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;
}

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

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

View File

@@ -0,0 +1,153 @@
/* Tabs - Content Area */
.tabs {
display: flex;
border-bottom: 1px solid var(--lora-border);
background: var(--lora-surface);
}
.tab {
flex: 1;
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-1);
padding: var(--space-2) var(--space-1);
background: transparent;
border: none;
border-bottom: 2px solid transparent;
color: var(--text-color);
cursor: pointer;
font-size: 0.85em;
font-weight: 500;
transition: all 0.2s;
opacity: 0.7;
}
.tab:hover {
opacity: 1;
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.05);
}
.tab.active {
border-bottom-color: var(--lora-accent);
opacity: 1;
font-weight: 600;
}
.tab__badge {
display: inline-flex;
align-items: center;
padding: 2px 6px;
border-radius: var(--border-radius-xs);
background: var(--badge-update-bg);
color: var(--badge-update-text);
font-size: 0.65em;
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.tab__badge--pulse {
animation: tabBadgePulse 2s ease-in-out infinite;
}
@keyframes tabBadgePulse {
0%, 100% {
box-shadow: 0 0 0 0 color-mix(in oklch, var(--badge-update-bg) 50%, transparent);
}
50% {
box-shadow: 0 0 0 4px color-mix(in oklch, var(--badge-update-bg) 0%, transparent);
}
}
/* Tab content */
.tab-panels {
flex: 1;
overflow-y: auto;
padding: var(--space-2);
}
.tab-panel {
display: none;
}
.tab-panel.active {
display: block;
animation: tabPanelFadeIn 0.2s ease-out;
}
@keyframes tabPanelFadeIn {
from {
opacity: 0;
transform: translateY(5px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Accordion within tab panels */
.accordion {
border: 1px solid var(--lora-border);
border-radius: var(--border-radius-sm);
overflow: hidden;
margin-bottom: var(--space-2);
}
.accordion__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-2) var(--space-3);
background: var(--lora-surface);
cursor: pointer;
transition: background-color 0.2s;
}
.accordion__header:hover {
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.05);
}
.accordion__title {
font-weight: 600;
font-size: 0.9em;
}
.accordion__icon {
transition: transform 0.3s ease;
}
.accordion.expanded .accordion__icon {
transform: rotate(180deg);
}
.accordion__content {
max-height: 0;
overflow: hidden;
transition: max-height 0.3s ease-out;
}
.accordion.expanded .accordion__content {
max-height: 500px; /* Adjust based on content */
}
.accordion__body {
padding: var(--space-3);
border-top: 1px solid var(--lora-border);
font-size: 0.9em;
line-height: 1.6;
}
/* Mobile adjustments */
@media (max-width: 768px) {
.tab {
font-size: 0.8em;
padding: var(--space-2) var(--space-1);
}
.tab__badge {
display: none; /* Hide badges on small screens */
}
}

View File

@@ -0,0 +1,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;
}
}

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

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

View File

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

View 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');
}
}
}

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

View 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();
}
}

File diff suppressed because it is too large Load Diff

View 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();
}
}

View File

@@ -0,0 +1,16 @@
/**
* Model Modal - New Split-View Overlay Design
* Phase 1 Implementation
*/
import { ModelModal } from './ModelModal.js';
// Export the public API
export const modelModal = {
show: ModelModal.show.bind(ModelModal),
close: ModelModal.close.bind(ModelModal),
isOpen: ModelModal.isOpen.bind(ModelModal),
};
// Default export for convenience
export default modelModal;

View File

@@ -22,6 +22,12 @@ import { loadRecipesForLora } from './RecipeTab.js';
import { translate } from '../../utils/i18nHelpers.js';
import { state } from '../../state/index.js';
// Import new ModelModal for split-view overlay (Phase 1)
import { modelModal as newModelModal } from '../model-modal/index.js';
// Feature flag: Use new split-view design
const USE_NEW_MODAL = true;
function getModalFilePath(fallback = '') {
const modalElement = document.getElementById('modelModal');
if (modalElement && modalElement.dataset && modalElement.dataset.filePath) {
@@ -238,6 +244,12 @@ function renderLicenseIcons(modelData) {
* @param {string} modelType - Type of model ('lora' or 'checkpoint')
*/
export async function showModelModal(model, modelType) {
// Use new split-view overlay design when feature flag is enabled
if (USE_NEW_MODAL) {
return newModelModal.show(model, modelType);
}
// Legacy implementation below (deprecated, kept for fallback)
const modalId = 'modelModal';
const modalTitle = model.model_name;
cleanupNavigationShortcuts();
@@ -1020,11 +1032,5 @@ async function openFileLocation(filePath) {
}
}
// Export the model modal API
const modelModal = {
show: showModelModal,
toggleShowcase,
scrollToTop
};
export { modelModal };
// Re-export for compatibility
export { toggleShowcase, scrollToTop };