Compare commits

..

12 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
Will Miao
1606a3ff46 feat: add clear button to autocomplete text widget and fix external value change sync
- Add clear button inside autocomplete text widget that shows when text exists
- Support both Canvas mode and Vue DOM mode with appropriate styling
- Fix clear button visibility when value is changed externally (e.g., via 'send lora to workflow')
- Implement dual notification mechanism: CustomEvent + onSetValue callback
- Update widget interface to include onSetValue property
2026-02-06 09:15:16 +08:00
Will Miao
b313f36be9 feat(duplicates): exit duplicate mode when no duplicates found, #783
When no duplicate groups are detected, the duplicate manager now checks if it is currently in duplicate mode and calls `exitDuplicateMode()` to clear the display. This prevents the UI from showing stale duplicate information when no duplicates exist.
2026-02-05 22:54:24 +08:00
Will Miao
fa3625ff72 feat(filter): add tag logic toggle (OR/AND) for include tags filtering
Add a segmented toggle in the Filter Panel to switch between 'Any' (OR)
and 'All' (AND) logic when filtering by multiple include tags.

Changes:
- Backend: Add tag_logic field to FilterCriteria and ModelFilterSet
- Backend: Parse tag_logic parameter in model handlers
- Frontend: Add segmented toggle UI in filter panel header
- Frontend: Add interaction logic and state management for tag logic
- Add translations for all supported languages
- Add comprehensive tests for the new feature

Closes #802
2026-02-05 22:36:30 +08:00
Will Miao
895d13dc96 feat(settings): clean up default values from settings.json
Add automatic cleanup of default values from settings.json to keep configuration files minimal and focused on user customizations. Introduces a threshold-based cleanup that only removes default values when the file contains a significant number of them (10+), preserving small template-based configurations while cleaning up legacy bloated files.

Key changes:
- Add DEFAULT_KEYS_CLEANUP_THRESHOLD constant to control cleanup aggressiveness
- Implement _cleanup_default_values_from_disk() method that removes default values from disk while keeping them available in memory
- Modify _ensure_default_settings() to only save when existing values are updated, not when defaults are inserted
- Update _serialize_settings_for_disk() to only persist settings that differ from defaults
- Add cleanup call during initialization for existing settings files

This reduces file size and noise in settings.json while maintaining full functionality at runtime.
2026-02-05 08:40:27 +08:00
Will Miao
b7e0821f66 feat(duplicates): add filter support for duplicate model finding, #783 2026-02-04 20:47:30 +08:00
44 changed files with 7928 additions and 107 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

@@ -223,7 +223,11 @@
"noCreditRequired": "Kein Credit erforderlich",
"allowSellingGeneratedContent": "Verkauf erlaubt",
"noTags": "Keine Tags",
"clearAll": "Alle Filter löschen"
"clearAll": "Alle Filter löschen",
"any": "Beliebig",
"all": "Alle",
"tagLogicAny": "Jedes Tag abgleichen (ODER)",
"tagLogicAll": "Alle Tags abgleichen (UND)"
},
"theme": {
"toggle": "Theme wechseln",

View File

@@ -223,7 +223,11 @@
"noCreditRequired": "No Credit Required",
"allowSellingGeneratedContent": "Allow Selling",
"noTags": "No tags",
"clearAll": "Clear All Filters"
"clearAll": "Clear All Filters",
"any": "Any",
"all": "All",
"tagLogicAny": "Match any tag (OR)",
"tagLogicAll": "Match all tags (AND)"
},
"theme": {
"toggle": "Toggle theme",
@@ -907,7 +911,12 @@
"viewOnCivitai": "View on Civitai",
"viewOnCivitaiText": "View on Civitai",
"viewCreatorProfile": "View Creator Profile",
"openFileLocation": "Open File Location"
"openFileLocation": "Open File Location",
"viewParams": "View parameters",
"setPreview": "Set as preview",
"previewSet": "Preview updated successfully",
"previewFailed": "Failed to update preview",
"delete": "Delete"
},
"openFileLocation": {
"success": "File location opened successfully",
@@ -926,13 +935,15 @@
"additionalNotes": "Additional Notes",
"notesHint": "Press Enter to save, Shift+Enter for new line",
"addNotesPlaceholder": "Add your notes here...",
"aboutThisVersion": "About this version"
"aboutThisVersion": "About this version",
"triggerWords": "Trigger Words"
},
"notes": {
"saved": "Notes saved successfully",
"saveFailed": "Failed to save notes"
},
"usageTips": {
"add": "Add",
"addPresetParameter": "Add preset parameter...",
"strengthMin": "Strength Min",
"strengthMax": "Strength Max",
@@ -941,17 +952,24 @@
"clipStrength": "Clip Strength",
"clipSkip": "Clip Skip",
"valuePlaceholder": "Value",
"add": "Add",
"invalidRange": "Invalid range format. Use x.x-y.y"
},
"params": {
"title": "Generation Parameters",
"prompt": "Prompt",
"negativePrompt": "Negative Prompt",
"noData": "No generation data available",
"promptCopied": "Prompt copied to clipboard"
},
"triggerWords": {
"label": "Trigger Words",
"noTriggerWordsNeeded": "No trigger word needed",
"noTriggerWordsNeeded": "No trigger words needed",
"edit": "Edit trigger words",
"cancel": "Cancel editing",
"save": "Save changes",
"addPlaceholder": "Type to add or click suggestions below",
"addPlaceholder": "Type to add trigger word...",
"copyWord": "Copy trigger word",
"copyAll": "Copy all trigger words",
"deleteWord": "Delete trigger word",
"suggestions": {
"noSuggestions": "No suggestions available",
@@ -961,6 +979,9 @@
"wordSuggestions": "Word Suggestions",
"wordsFound": "{count} words found",
"loading": "Loading suggestions..."
},
"validation": {
"duplicate": "This trigger word already exists"
}
},
"description": {
@@ -986,7 +1007,11 @@
"previousWithShortcut": "Previous model (←)",
"nextWithShortcut": "Next model (→)",
"noPrevious": "No previous model available",
"noNext": "No next model available"
"noNext": "No next model available",
"previous": "Previous",
"next": "Next",
"switchModel": "Switch model",
"browseExamples": "Browse examples"
},
"license": {
"noImageSell": "No selling generated content",
@@ -998,6 +1023,23 @@
"noReLicense": "Same permissions required",
"restrictionsLabel": "License restrictions"
},
"examples": {
"add": "Add",
"addFirst": "Add your first example",
"dropFiles": "Drop files here or click to browse",
"supportedFormats": "Supports: JPG, PNG, WEBP, MP4, WEBM",
"uploading": "Uploading...",
"uploadSuccess": "Example uploaded successfully",
"uploadFailed": "Failed to upload example",
"confirmDelete": "Delete this example image?",
"deleted": "Example deleted successfully",
"deleteFailed": "Failed to delete example",
"title": "Example",
"empty": "No example images available"
},
"accordion": {
"modelDescription": "Model Description"
},
"loading": {
"exampleImages": "Loading example images...",
"description": "Loading model description...",

View File

@@ -223,7 +223,11 @@
"noCreditRequired": "Sin crédito requerido",
"allowSellingGeneratedContent": "Venta permitida",
"noTags": "Sin etiquetas",
"clearAll": "Limpiar todos los filtros"
"clearAll": "Limpiar todos los filtros",
"any": "Cualquiera",
"all": "Todos",
"tagLogicAny": "Coincidir con cualquier etiqueta (O)",
"tagLogicAll": "Coincidir con todas las etiquetas (Y)"
},
"theme": {
"toggle": "Cambiar tema",

View File

@@ -223,7 +223,11 @@
"noCreditRequired": "Crédit non requis",
"allowSellingGeneratedContent": "Vente autorisée",
"noTags": "Aucun tag",
"clearAll": "Effacer tous les filtres"
"clearAll": "Effacer tous les filtres",
"any": "N'importe quel",
"all": "Tous",
"tagLogicAny": "Correspondre à n'importe quel tag (OU)",
"tagLogicAll": "Correspondre à tous les tags (ET)"
},
"theme": {
"toggle": "Basculer le thème",

View File

@@ -223,7 +223,11 @@
"noCreditRequired": "ללא קרדיט נדרש",
"allowSellingGeneratedContent": "אפשר מכירה",
"noTags": "ללא תגיות",
"clearAll": "נקה את כל המסננים"
"clearAll": "נקה את כל המסננים",
"any": "כלשהו",
"all": "כל התגים",
"tagLogicAny": "התאם כל תג (או)",
"tagLogicAll": "התאם את כל התגים (וגם)"
},
"theme": {
"toggle": "החלף ערכת נושא",

View File

@@ -223,7 +223,11 @@
"noCreditRequired": "クレジット不要",
"allowSellingGeneratedContent": "販売許可",
"noTags": "タグなし",
"clearAll": "すべてのフィルタをクリア"
"clearAll": "すべてのフィルタをクリア",
"any": "いずれか",
"all": "すべて",
"tagLogicAny": "いずれかのタグに一致 (OR)",
"tagLogicAll": "すべてのタグに一致 (AND)"
},
"theme": {
"toggle": "テーマの切り替え",

View File

@@ -223,7 +223,11 @@
"noCreditRequired": "크레딧 표기 없음",
"allowSellingGeneratedContent": "판매 허용",
"noTags": "태그 없음",
"clearAll": "모든 필터 지우기"
"clearAll": "모든 필터 지우기",
"any": "아무",
"all": "모두",
"tagLogicAny": "모든 태그 일치 (OR)",
"tagLogicAll": "모든 태그 일치 (AND)"
},
"theme": {
"toggle": "테마 토글",

View File

@@ -223,7 +223,11 @@
"noCreditRequired": "Без указания авторства",
"allowSellingGeneratedContent": "Продажа разрешена",
"noTags": "Без тегов",
"clearAll": "Очистить все фильтры"
"clearAll": "Очистить все фильтры",
"any": "Любой",
"all": "Все",
"tagLogicAny": "Совпадение с любым тегом (ИЛИ)",
"tagLogicAll": "Совпадение со всеми тегами (И)"
},
"theme": {
"toggle": "Переключить тему",

View File

@@ -223,7 +223,11 @@
"noCreditRequired": "无需署名",
"allowSellingGeneratedContent": "允许销售",
"noTags": "无标签",
"clearAll": "清除所有筛选"
"clearAll": "清除所有筛选",
"any": "任一",
"all": "全部",
"tagLogicAny": "匹配任一标签 (或)",
"tagLogicAll": "匹配所有标签 (与)"
},
"theme": {
"toggle": "切换主题",

View File

@@ -223,7 +223,11 @@
"noCreditRequired": "無需署名",
"allowSellingGeneratedContent": "允許銷售",
"noTags": "無標籤",
"clearAll": "清除所有篩選"
"clearAll": "清除所有篩選",
"any": "任一",
"all": "全部",
"tagLogicAny": "符合任一票籤 (或)",
"tagLogicAll": "符合所有標籤 (與)"
},
"theme": {
"toggle": "切換主題",

View File

@@ -6,6 +6,7 @@ import asyncio
import json
import logging
import os
import re
import time
from dataclasses import dataclass
from typing import Any, Awaitable, Callable, Dict, Iterable, List, Mapping, Optional
@@ -269,6 +270,11 @@ class ModelListingHandler:
request.query.get("update_available_only", "false").lower() == "true"
)
# Tag logic: "any" (OR) or "all" (AND) for include tags
tag_logic = request.query.get("tag_logic", "any").lower()
if tag_logic not in ("any", "all"):
tag_logic = "any"
# New license-based query filters
credit_required = request.query.get("credit_required")
if credit_required is not None:
@@ -297,6 +303,7 @@ class ModelListingHandler:
"fuzzy_search": fuzzy_search,
"base_models": base_models,
"tags": tag_filters,
"tag_logic": tag_logic,
"search_options": search_options,
"hash_filters": hash_filters,
"favorites_only": favorites_only,
@@ -755,19 +762,22 @@ class ModelQueryHandler:
async def find_duplicate_models(self, request: web.Request) -> web.Response:
try:
filters = self._parse_duplicate_filters(request)
duplicates = self._service.find_duplicate_hashes()
result = []
cache = await self._service.scanner.get_cached_data()
for sha256, paths in duplicates.items():
group = {"hash": sha256, "models": []}
# Collect all models in this group
all_models = []
for path in paths:
model = next(
(m for m in cache.raw_data if m["file_path"] == path), None
)
if model:
group["models"].append(
await self._service.format_response(model)
)
all_models.append(model)
# Include primary if not already in paths
primary_path = self._service.get_path_by_hash(sha256)
if primary_path and primary_path not in paths:
primary_model = next(
@@ -775,11 +785,25 @@ class ModelQueryHandler:
None,
)
if primary_model:
group["models"].insert(
0, await self._service.format_response(primary_model)
)
all_models.insert(0, primary_model)
# Apply filters
filtered = self._apply_duplicate_filters(all_models, filters)
# Sort: originals first, copies last
sorted_models = self._sort_duplicate_group(filtered)
# Format response
group = {"hash": sha256, "models": []}
for model in sorted_models:
group["models"].append(
await self._service.format_response(model)
)
# Only include groups with 2+ models after filtering
if len(group["models"]) > 1:
result.append(group)
return web.json_response(
{"success": True, "duplicates": result, "count": len(result)}
)
@@ -792,6 +816,83 @@ class ModelQueryHandler:
)
return web.json_response({"success": False, "error": str(exc)}, status=500)
def _parse_duplicate_filters(self, request: web.Request) -> Dict[str, Any]:
"""Parse filter parameters from the request for duplicate finding."""
return {
"base_models": request.query.getall("base_model", []),
"tag_include": request.query.getall("tag_include", []),
"tag_exclude": request.query.getall("tag_exclude", []),
"model_types": request.query.getall("model_type", []),
"folder": request.query.get("folder"),
"favorites_only": request.query.get("favorites_only", "").lower() == "true",
}
def _apply_duplicate_filters(self, models: List[Dict[str, Any]], filters: Dict[str, Any]) -> List[Dict[str, Any]]:
"""Apply filters to a list of models within a duplicate group."""
result = models
# Apply base model filter
if filters.get("base_models"):
base_set = set(filters["base_models"])
result = [m for m in result if m.get("base_model") in base_set]
# Apply tag filters (include)
for tag in filters.get("tag_include", []):
if tag == "__no_tags__":
result = [m for m in result if not m.get("tags")]
else:
result = [m for m in result if tag in (m.get("tags") or [])]
# Apply tag filters (exclude)
for tag in filters.get("tag_exclude", []):
if tag == "__no_tags__":
result = [m for m in result if m.get("tags")]
else:
result = [m for m in result if tag not in (m.get("tags") or [])]
# Apply model type filter
if filters.get("model_types"):
type_set = {t.lower() for t in filters["model_types"]}
result = [
m for m in result if (m.get("model_type") or "").lower() in type_set
]
# Apply folder filter
if filters.get("folder"):
folder = filters["folder"]
result = [m for m in result if m.get("folder", "").startswith(folder)]
# Apply favorites filter
if filters.get("favorites_only"):
result = [m for m in result if m.get("favorite", False)]
return result
def _sort_duplicate_group(self, models: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Sort models: originals first (left), copies (with -????. pattern) last (right)."""
if len(models) <= 1:
return models
min_len = min(len(m.get("file_name", "")) for m in models)
def copy_score(m):
fn = m.get("file_name", "")
score = 0
# Match -0001.safetensors, -1234.safetensors etc.
if re.search(r"-\d{4}\.", fn):
score += 100
# Match (1), (2) etc.
if re.search(r"\(\d+\)", fn):
score += 50
# Match 'copy' in filename
if "copy" in fn.lower():
score += 50
# Longer filenames are more likely copies
score += len(fn) - min_len
return (score, fn.lower())
return sorted(models, key=copy_score)
async def find_filename_conflicts(self, request: web.Request) -> web.Response:
try:
duplicates = self._service.find_duplicate_filenames()

View File

@@ -81,6 +81,7 @@ class BaseModelService(ABC):
update_available_only: bool = False,
credit_required: Optional[bool] = None,
allow_selling_generated_content: Optional[bool] = None,
tag_logic: str = "any",
**kwargs,
) -> Dict:
"""Get paginated and filtered model data"""
@@ -109,6 +110,7 @@ class BaseModelService(ABC):
tags=tags,
favorites_only=favorites_only,
search_options=search_options,
tag_logic=tag_logic,
)
if search:
@@ -241,6 +243,7 @@ class BaseModelService(ABC):
tags: Optional[Dict[str, str]] = None,
favorites_only: bool = False,
search_options: dict = None,
tag_logic: str = "any",
) -> List[Dict]:
"""Apply common filters that work across all model types"""
normalized_options = self.search_strategy.normalize_options(search_options)
@@ -253,6 +256,7 @@ class BaseModelService(ABC):
tags=tags,
favorites_only=favorites_only,
search_options=normalized_options,
tag_logic=tag_logic,
)
return self.filter_set.apply(data, criteria)

View File

@@ -99,6 +99,7 @@ class FilterCriteria:
favorites_only: bool = False
search_options: Optional[Dict[str, Any]] = None
model_types: Optional[Sequence[str]] = None
tag_logic: str = "any" # "any" (OR) or "all" (AND)
class ModelCacheRepository:
@@ -300,11 +301,29 @@ class ModelFilterSet:
include_tags = {tag for tag in tag_filters if tag}
if include_tags:
tag_logic = criteria.tag_logic.lower() if criteria.tag_logic else "any"
def matches_include(item_tags):
if not item_tags and "__no_tags__" in include_tags:
return True
return any(tag in include_tags for tag in (item_tags or []))
if tag_logic == "all":
# AND logic: item must have ALL include tags
# Special case: __no_tags__ is handled separately
non_special_tags = include_tags - {"__no_tags__"}
if "__no_tags__" in include_tags:
# If __no_tags__ is selected along with other tags,
# treat it as "no tags OR (all other tags)"
if not item_tags:
return True
# Otherwise, check if all non-special tags match
if non_special_tags:
return all(tag in (item_tags or []) for tag in non_special_tags)
return True
# Normal case: all tags must match
return all(tag in (item_tags or []) for tag in non_special_tags)
else:
# OR logic (default): item must have ANY include tag
return any(tag in include_tags for tag in (item_tags or []))
items = [item for item in items if matches_include(item.get("tags"))]

View File

@@ -28,6 +28,9 @@ CORE_USER_SETTING_KEYS: Tuple[str, ...] = (
"folder_paths",
)
# Threshold for aggressive cleanup: if file contains this many default keys, clean it up
DEFAULT_KEYS_CLEANUP_THRESHOLD = 10
DEFAULT_SETTINGS: Dict[str, Any] = {
"civitai_api_key": "",
@@ -95,6 +98,9 @@ class SettingsManager:
if self._needs_initial_save:
self._save_settings()
self._needs_initial_save = False
else:
# Clean up existing settings file by removing default values
self._cleanup_default_values_from_disk()
def _detect_standalone_mode(self) -> bool:
"""Return ``True`` when running in standalone mode."""
@@ -226,7 +232,7 @@ class SettingsManager:
return merged
def _ensure_default_settings(self) -> None:
"""Ensure all default settings keys exist"""
"""Ensure all default settings keys exist in memory (but don't save defaults to disk)"""
defaults = self._get_default_settings()
updated_existing = False
inserted_defaults = False
@@ -265,10 +271,10 @@ class SettingsManager:
self.settings[key] = value
inserted_defaults = True
if updated_existing or (
inserted_defaults and self._bootstrap_reason in {"invalid", "unreadable"}
):
# Save only if existing values were normalized/updated
if updated_existing:
self._save_settings()
# Note: inserted_defaults no longer triggers save - defaults stay in memory only
def _migrate_to_library_registry(self) -> None:
"""Ensure settings include the multi-library registry structure."""
@@ -711,6 +717,42 @@ class SettingsManager:
self._startup_messages.append(payload)
def _cleanup_default_values_from_disk(self) -> None:
"""Remove default values from existing settings.json to keep it clean.
Only performs cleanup if the file contains a significant number of default
values (indicating it's "bloated"). Small files (like template-based configs)
are preserved as-is to avoid unexpected changes.
"""
# Only cleanup existing files (not new ones)
if self._bootstrap_reason == "missing" or self._original_disk_payload is None:
return
defaults = self._get_default_settings()
disk_keys = set(self._original_disk_payload.keys())
# Count how many keys on disk are set to their default values
default_value_keys = set()
for key in disk_keys:
if key in CORE_USER_SETTING_KEYS:
continue # Core keys don't count as "cleanup candidates"
disk_value = self._original_disk_payload.get(key)
default_value = defaults.get(key)
# Compare using JSON serialization for complex objects
if json.dumps(disk_value, sort_keys=True, default=str) == json.dumps(default_value, sort_keys=True, default=str):
default_value_keys.add(key)
# Only cleanup if there are "many" default keys (indicating a bloated file)
# This preserves small/template-based configs while cleaning up legacy bloated files
if len(default_value_keys) >= DEFAULT_KEYS_CLEANUP_THRESHOLD:
logger.info(
"Cleaning up %d default value(s) from settings.json to keep it minimal",
len(default_value_keys)
)
self._save_settings()
# Update original payload to match what we just saved
self._original_disk_payload = self._serialize_settings_for_disk()
def _collect_configuration_warnings(self) -> None:
if not self._standalone_mode:
return
@@ -1101,7 +1143,12 @@ class SettingsManager:
self._seed_template = None
def _serialize_settings_for_disk(self) -> Dict[str, Any]:
"""Return the settings payload that should be persisted to disk."""
"""Return the settings payload that should be persisted to disk.
Only saves settings that differ from defaults, keeping the config file
clean and focused on user customizations. Default values are still
available at runtime via _get_default_settings().
"""
if self._bootstrap_reason == "missing":
minimal: Dict[str, Any] = {}
@@ -1115,7 +1162,25 @@ class SettingsManager:
return minimal
return copy.deepcopy(self.settings)
# Only save settings that differ from defaults
defaults = self._get_default_settings()
minimal = {}
for key, value in self.settings.items():
default_value = defaults.get(key)
# Core settings are always saved (even if equal to default)
if key in CORE_USER_SETTING_KEYS:
minimal[key] = copy.deepcopy(value)
# Complex objects need deep comparison
elif isinstance(value, (dict, list)) and default_value is not None:
if json.dumps(value, sort_keys=True, default=str) != json.dumps(default_value, sort_keys=True, default=str):
minimal[key] = copy.deepcopy(value)
# Simple values use direct comparison
elif value != default_value:
minimal[key] = copy.deepcopy(value)
return minimal
def get_libraries(self) -> Dict[str, Dict[str, Any]]:
"""Return a copy of the registered libraries."""

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

@@ -673,6 +673,57 @@
/* Tag Logic Toggle Styles */
.filter-section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.filter-section-header h4 {
margin: 0;
}
.tag-logic-toggle {
display: flex;
background-color: var(--lora-surface);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm);
overflow: hidden;
}
.tag-logic-option {
background: none;
border: none;
padding: 2px 8px;
font-size: 11px;
cursor: pointer;
color: var(--text-color);
opacity: 0.7;
transition: all 0.2s ease;
font-weight: 500;
}
.tag-logic-option:hover {
opacity: 1;
background-color: var(--lora-surface-hover);
}
.tag-logic-option.active {
background-color: var(--lora-accent);
color: white;
opacity: 1;
}
.tag-logic-option:first-child {
border-right: 1px solid var(--border-color);
}
.tag-logic-option.active:first-child {
border-right: 1px solid rgba(255, 255, 255, 0.3);
}
/* Mobile adjustments */
@media (max-width: 768px) {
.search-options-panel,

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

@@ -924,6 +924,11 @@ export class BaseModelApiClient {
params.append('model_type', type);
});
}
// Add tag logic parameter (any = OR, all = AND)
if (pageState.filters.tagLogic) {
params.append('tag_logic', pageState.filters.tagLogic);
}
}
this._addModelSpecificParams(params, pageState);

View File

@@ -48,15 +48,18 @@ export class ModelDuplicatesManager {
// Method to check for duplicates count using existing endpoint
async checkDuplicatesCount() {
try {
const params = this._buildFilterQueryParams();
const endpoint = `/api/lm/${this.modelType}/find-duplicates`;
const response = await fetch(endpoint);
const url = params.toString() ? `${endpoint}?${params}` : endpoint;
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to get duplicates count: ${response.statusText}`);
}
const data = await response.json();
if (data.success) {
const duplicatesCount = (data.duplicates || []).length;
this.updateDuplicatesBadge(duplicatesCount);
@@ -103,29 +106,34 @@ export class ModelDuplicatesManager {
async findDuplicates() {
try {
// Determine API endpoint based on model type
const params = this._buildFilterQueryParams();
const endpoint = `/api/lm/${this.modelType}/find-duplicates`;
const response = await fetch(endpoint);
const url = params.toString() ? `${endpoint}?${params}` : endpoint;
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to find duplicates: ${response.statusText}`);
}
const data = await response.json();
if (!data.success) {
throw new Error(data.error || 'Unknown error finding duplicates');
}
this.duplicateGroups = data.duplicates || [];
// Update the badge with the current count
this.updateDuplicatesBadge(this.duplicateGroups.length);
if (this.duplicateGroups.length === 0) {
showToast('toast.duplicates.noDuplicatesFound', { type: this.modelType }, 'info');
// If already in duplicate mode, exit to clear the display
if (this.inDuplicateMode) {
this.exitDuplicateMode();
}
return false;
}
this.enterDuplicateMode();
return true;
} catch (error) {
@@ -134,6 +142,51 @@ export class ModelDuplicatesManager {
return false;
}
}
/**
* Build query parameters from current filter state for duplicate finding.
* @returns {URLSearchParams} The query parameters to append to the API endpoint
*/
_buildFilterQueryParams() {
const params = new URLSearchParams();
const pageState = getCurrentPageState();
const filters = pageState?.filters;
if (!filters) return params;
// Base model filters
if (filters.baseModel && Array.isArray(filters.baseModel)) {
filters.baseModel.forEach(m => params.append('base_model', m));
}
// Tag filters (tri-state: include/exclude)
if (filters.tags && typeof filters.tags === 'object') {
Object.entries(filters.tags).forEach(([tag, state]) => {
if (state === 'include') {
params.append('tag_include', tag);
} else if (state === 'exclude') {
params.append('tag_exclude', tag);
}
});
}
// Model type filters
if (filters.modelTypes && Array.isArray(filters.modelTypes)) {
filters.modelTypes.forEach(t => params.append('model_type', t));
}
// Folder filter (from active folder state)
if (pageState.activeFolder) {
params.append('folder', pageState.activeFolder);
}
// Favorites filter
if (pageState.showFavoritesOnly) {
params.append('favorites_only', 'true');
}
return params;
}
enterDuplicateMode() {
this.inDuplicateMode = true;

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

View File

@@ -63,6 +63,9 @@ export class FilterManager {
this.initializeLicenseFilters();
}
// Initialize tag logic toggle
this.initializeTagLogicToggle();
// Add click handler for filter button
if (this.filterButton) {
this.filterButton.addEventListener('click', () => {
@@ -84,6 +87,45 @@ export class FilterManager {
this.loadFiltersFromStorage();
}
initializeTagLogicToggle() {
const toggleContainer = document.getElementById('tagLogicToggle');
if (!toggleContainer) return;
const options = toggleContainer.querySelectorAll('.tag-logic-option');
options.forEach(option => {
option.addEventListener('click', async () => {
const value = option.dataset.value;
if (this.filters.tagLogic === value) return;
this.filters.tagLogic = value;
this.updateTagLogicToggleUI();
// Auto-apply filter when logic changes
await this.applyFilters(false);
});
});
// Set initial state
this.updateTagLogicToggleUI();
}
updateTagLogicToggleUI() {
const toggleContainer = document.getElementById('tagLogicToggle');
if (!toggleContainer) return;
const options = toggleContainer.querySelectorAll('.tag-logic-option');
const currentLogic = this.filters.tagLogic || 'any';
options.forEach(option => {
if (option.dataset.value === currentLogic) {
option.classList.add('active');
} else {
option.classList.remove('active');
}
});
}
async loadTopTags() {
try {
// Show loading state
@@ -549,6 +591,17 @@ export class FilterManager {
showToast('toast.filters.cleared', {}, 'info');
}
}
// Refresh duplicates with new filters
if (window.modelDuplicatesManager) {
if (window.modelDuplicatesManager.inDuplicateMode) {
// In duplicate mode: refresh the duplicate list
await window.modelDuplicatesManager.findDuplicates();
} else {
// Not in duplicate mode: just update badge count
window.modelDuplicatesManager.checkDuplicatesCount();
}
}
}
async clearFilters() {
@@ -562,9 +615,13 @@ export class FilterManager {
baseModel: [],
tags: {},
license: {},
modelTypes: []
modelTypes: [],
tagLogic: 'any'
});
// Update tag logic toggle UI
this.updateTagLogicToggleUI();
// Update state
const pageState = getCurrentPageState();
pageState.filters = this.cloneFilters();
@@ -609,6 +666,7 @@ export class FilterManager {
pageState.filters = this.cloneFilters();
this.updateTagSelections();
this.updateTagLogicToggleUI();
this.updateActiveFiltersCount();
if (this.hasActiveFilters()) {
@@ -644,7 +702,8 @@ export class FilterManager {
baseModel: Array.isArray(source.baseModel) ? [...source.baseModel] : [],
tags: this.normalizeTagFilters(source.tags),
license: this.shouldShowLicenseFilters() ? this.normalizeLicenseFilters(source.license) : {},
modelTypes: this.normalizeModelTypeFilters(source.modelTypes)
modelTypes: this.normalizeModelTypeFilters(source.modelTypes),
tagLogic: source.tagLogic || 'any'
};
}
@@ -726,7 +785,8 @@ export class FilterManager {
baseModel: [...(this.filters.baseModel || [])],
tags: { ...(this.filters.tags || {}) },
license: { ...(this.filters.license || {}) },
modelTypes: [...(this.filters.modelTypes || [])]
modelTypes: [...(this.filters.modelTypes || [])],
tagLogic: this.filters.tagLogic || 'any'
};
}

View File

@@ -150,7 +150,13 @@
</div>
</div>
<div class="filter-section">
<h4>{{ t('header.filter.modelTags') }}</h4>
<div class="filter-section-header">
<h4>{{ t('header.filter.modelTags') }}</h4>
<div class="tag-logic-toggle" id="tagLogicToggle">
<button class="tag-logic-option" data-value="any" title="{{ t('header.filter.tagLogicAny') }}">{{ t('header.filter.any') }}</button>
<button class="tag-logic-option" data-value="all" title="{{ t('header.filter.tagLogicAll') }}">{{ t('header.filter.all') }}</button>
</div>
</div>
<div class="filter-tags" id="modelTagsFilter">
<!-- Top tags will be dynamically inserted here -->
<div class="tags-loading">{{ t('common.status.loading') }}</div>

View File

@@ -0,0 +1,290 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
// Mock dependencies
vi.mock('../../../static/js/state/index.js', () => ({
getCurrentPageState: vi.fn(() => ({
filters: {},
})),
state: {
currentPageType: 'loras',
loadingManager: {
showSimpleLoading: vi.fn(),
hide: vi.fn(),
},
},
}));
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
showToast: vi.fn(),
updatePanelPositions: vi.fn(),
}));
vi.mock('../../../static/js/api/modelApiFactory.js', () => ({
getModelApiClient: vi.fn(() => ({
loadMoreWithVirtualScroll: vi.fn().mockResolvedValue(),
})),
}));
vi.mock('../../../static/js/utils/storageHelpers.js', () => ({
getStorageItem: vi.fn(),
setStorageItem: vi.fn(),
removeStorageItem: vi.fn(),
}));
vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({
translate: vi.fn((key, _params, fallback) => fallback || key),
}));
vi.mock('../../../static/js/managers/FilterPresetManager.js', () => ({
FilterPresetManager: vi.fn().mockImplementation(() => ({
renderPresets: vi.fn(),
saveActivePreset: vi.fn(),
restoreActivePreset: vi.fn(),
updateAddButtonState: vi.fn(),
hasEmptyWildcardResult: vi.fn(() => false),
})),
EMPTY_WILDCARD_MARKER: '__EMPTY_WILDCARD_RESULT__',
}));
import { FilterManager } from '../../../static/js/managers/FilterManager.js';
import { getStorageItem, setStorageItem } from '../../../static/js/utils/storageHelpers.js';
describe('FilterManager - Tag Logic', () => {
let manager;
let mockFilterPanel;
let mockTagLogicToggle;
beforeEach(() => {
vi.clearAllMocks();
// Setup DOM mocks
mockFilterPanel = document.createElement('div');
mockFilterPanel.id = 'filterPanel';
mockFilterPanel.classList.add('hidden');
mockTagLogicToggle = document.createElement('div');
mockTagLogicToggle.id = 'tagLogicToggle';
// Create tag logic options
const anyOption = document.createElement('button');
anyOption.className = 'tag-logic-option';
anyOption.dataset.value = 'any';
mockTagLogicToggle.appendChild(anyOption);
const allOption = document.createElement('button');
allOption.className = 'tag-logic-option';
allOption.dataset.value = 'all';
mockTagLogicToggle.appendChild(allOption);
document.body.appendChild(mockFilterPanel);
document.body.appendChild(mockTagLogicToggle);
// Mock getElementById
const originalGetElementById = document.getElementById;
document.getElementById = vi.fn((id) => {
if (id === 'filterPanel') return mockFilterPanel;
if (id === 'tagLogicToggle') return mockTagLogicToggle;
if (id === 'filterButton') return document.createElement('button');
if (id === 'activeFiltersCount') return document.createElement('span');
if (id === 'baseModelTags') return document.createElement('div');
if (id === 'modelTypeTags') return document.createElement('div');
return originalGetElementById.call(document, id);
});
});
describe('initializeFilters', () => {
it('should default tagLogic to "any" when not provided', () => {
manager = new FilterManager({ page: 'loras' });
expect(manager.filters.tagLogic).toBe('any');
});
it('should use provided tagLogic value', () => {
getStorageItem.mockReturnValue({
tagLogic: 'all',
tags: {},
baseModel: [],
});
manager = new FilterManager({ page: 'loras' });
expect(manager.filters.tagLogic).toBe('all');
});
});
describe('initializeTagLogicToggle', () => {
it('should set "any" option as active by default', () => {
manager = new FilterManager({ page: 'loras' });
// Ensure filters.tagLogic is set to default
manager.filters.tagLogic = 'any';
const anyOption = mockTagLogicToggle.querySelector('[data-value="any"]');
const allOption = mockTagLogicToggle.querySelector('[data-value="all"]');
// Manually update UI to ensure correct state
manager.updateTagLogicToggleUI();
expect(manager.filters.tagLogic).toBe('any');
expect(anyOption.classList.contains('active')).toBe(true);
expect(allOption.classList.contains('active')).toBe(false);
});
it('should set "all" option as active when tagLogic is "all"', () => {
getStorageItem.mockReturnValue({
tagLogic: 'all',
tags: {},
baseModel: [],
});
manager = new FilterManager({ page: 'loras' });
// Ensure filters.tagLogic is set correctly
manager.filters.tagLogic = 'all';
const anyOption = mockTagLogicToggle.querySelector('[data-value="any"]');
const allOption = mockTagLogicToggle.querySelector('[data-value="all"]');
// Manually update UI to ensure correct state
manager.updateTagLogicToggleUI();
expect(manager.filters.tagLogic).toBe('all');
expect(anyOption.classList.contains('active')).toBe(false);
expect(allOption.classList.contains('active')).toBe(true);
});
});
describe('updateTagLogicToggleUI', () => {
it('should update UI when tagLogic changes', () => {
// Clear any existing active classes first
mockTagLogicToggle.querySelectorAll('.tag-logic-option').forEach(el => {
el.classList.remove('active');
});
manager = new FilterManager({ page: 'loras' });
let anyOption = mockTagLogicToggle.querySelector('[data-value="any"]');
let allOption = mockTagLogicToggle.querySelector('[data-value="all"]');
// Ensure initial state
manager.filters.tagLogic = 'any';
manager.updateTagLogicToggleUI();
expect(anyOption.classList.contains('active')).toBe(true);
expect(allOption.classList.contains('active')).toBe(false);
// Change to "all"
manager.filters.tagLogic = 'all';
manager.updateTagLogicToggleUI();
expect(anyOption.classList.contains('active')).toBe(false);
expect(allOption.classList.contains('active')).toBe(true);
});
});
describe('cloneFilters', () => {
it('should include tagLogic in cloned filters', () => {
manager = new FilterManager({ page: 'loras' });
manager.filters.tagLogic = 'all';
const cloned = manager.cloneFilters();
expect(cloned.tagLogic).toBe('all');
});
});
describe('clearFilters', () => {
it('should reset tagLogic to "any"', () => {
getStorageItem.mockReturnValue({
tagLogic: 'all',
tags: { anime: 'include' },
baseModel: ['SDXL'],
});
manager = new FilterManager({ page: 'loras' });
expect(manager.filters.tagLogic).toBe('all');
manager.clearFilters();
expect(manager.filters.tagLogic).toBe('any');
});
it('should update UI after clearing', () => {
getStorageItem.mockReturnValue({
tagLogic: 'all',
tags: {},
baseModel: [],
});
manager = new FilterManager({ page: 'loras' });
const anyOption = mockTagLogicToggle.querySelector('[data-value="any"]');
const allOption = mockTagLogicToggle.querySelector('[data-value="all"]');
// Initially "all" is active
expect(allOption.classList.contains('active')).toBe(true);
manager.clearFilters();
// After clear, "any" should be active
expect(anyOption.classList.contains('active')).toBe(true);
expect(allOption.classList.contains('active')).toBe(false);
});
});
describe('loadFiltersFromStorage', () => {
it('should restore tagLogic from storage', () => {
getStorageItem.mockReturnValue({
tagLogic: 'all',
tags: { anime: 'include' },
baseModel: [],
});
manager = new FilterManager({ page: 'loras' });
expect(manager.filters.tagLogic).toBe('all');
expect(manager.filters.tags).toEqual({ anime: 'include' });
});
it('should default to "any" when no tagLogic in storage', () => {
getStorageItem.mockReturnValue({
tags: {},
baseModel: [],
});
manager = new FilterManager({ page: 'loras' });
expect(manager.filters.tagLogic).toBe('any');
});
});
describe('tag logic toggle interaction', () => {
it('should update tagLogic when clicking "all" option', async () => {
manager = new FilterManager({ page: 'loras' });
const allOption = mockTagLogicToggle.querySelector('[data-value="all"]');
// Simulate click
allOption.click();
// Wait for async operation
await new Promise(resolve => setTimeout(resolve, 0));
expect(manager.filters.tagLogic).toBe('all');
});
it('should not change tagLogic when clicking already active option', async () => {
manager = new FilterManager({ page: 'loras' });
const anyOption = mockTagLogicToggle.querySelector('[data-value="any"]');
const applyFiltersSpy = vi.spyOn(manager, 'applyFilters');
// Click already active option
anyOption.click();
await new Promise(resolve => setTimeout(resolve, 0));
// applyFilters should not be called since value didn't change
expect(applyFiltersSpy).not.toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,166 @@
"""Tests for tag_logic parameter parsing in model handlers."""
import pytest
from unittest.mock import Mock
from aiohttp import web
from aiohttp.test_utils import TestClient, TestServer
import sys
import types
folder_paths_stub = types.SimpleNamespace(get_folder_paths=lambda *_: [])
sys.modules.setdefault("folder_paths", folder_paths_stub)
from py.routes.handlers.model_handlers import ModelListingHandler
class MockService:
"""Mock service for testing."""
def __init__(self):
self.model_type = "test-model"
async def get_paginated_data(self, **kwargs):
# Store the kwargs for verification
self.last_call_kwargs = kwargs
return {
"items": [],
"total": 0,
"page": 1,
"page_size": 20,
"total_pages": 0,
}
async def format_response(self, item):
return item
def parse_specific_params(request):
"""No specific params for testing."""
return {}
@pytest.fixture
def handler():
service = MockService()
logger = Mock()
return ModelListingHandler(
service=service,
parse_specific_params=parse_specific_params,
logger=logger,
), service
async def make_request(handler, query_string=""):
"""Helper to create a request and call get_models."""
app = web.Application()
async def test_handler(request):
return await handler.get_models(request)
app.router.add_get("/test", test_handler)
server = TestServer(app)
client = TestClient(server)
await client.start_server()
try:
response = await client.get(f"/test?{query_string}")
return response
finally:
await client.close()
@pytest.mark.asyncio
async def test_tag_logic_param_default_is_any(handler):
"""Test that tag_logic defaults to 'any' when not provided."""
h, service = handler
response = await make_request(h, "tag_include=anime&tag_include=realistic")
assert response.status == 200
# Verify tag_logic was set to 'any' by default
assert service.last_call_kwargs["tag_logic"] == "any"
@pytest.mark.asyncio
async def test_tag_logic_param_explicit_any(handler):
"""Test that tag_logic='any' is correctly parsed."""
h, service = handler
response = await make_request(h, "tag_include=anime&tag_logic=any")
assert response.status == 200
assert service.last_call_kwargs["tag_logic"] == "any"
@pytest.mark.asyncio
async def test_tag_logic_param_explicit_all(handler):
"""Test that tag_logic='all' is correctly parsed."""
h, service = handler
response = await make_request(h, "tag_include=anime&tag_include=realistic&tag_logic=all")
assert response.status == 200
assert service.last_call_kwargs["tag_logic"] == "all"
@pytest.mark.asyncio
async def test_tag_logic_param_case_insensitive(handler):
"""Test that tag_logic values are case insensitive."""
h, service = handler
# Test uppercase
response = await make_request(h, "tag_logic=ALL")
assert response.status == 200
assert service.last_call_kwargs["tag_logic"] == "all"
# Test mixed case
response = await make_request(h, "tag_logic=Any")
assert response.status == 200
assert service.last_call_kwargs["tag_logic"] == "any"
@pytest.mark.asyncio
async def test_tag_logic_param_invalid_value_defaults_to_any(handler):
"""Test that invalid tag_logic values default to 'any'."""
h, service = handler
response = await make_request(h, "tag_logic=invalid")
assert response.status == 200
# Should default to 'any' for invalid values
assert service.last_call_kwargs["tag_logic"] == "any"
@pytest.mark.asyncio
async def test_tag_logic_param_with_other_filters(handler):
"""Test that tag_logic works correctly with other filter parameters."""
h, service = handler
query = (
"tag_include=anime&"
"tag_include=character&"
"tag_exclude=nsfw&"
"base_model=SDXL&"
"tag_logic=all"
)
response = await make_request(h, query)
assert response.status == 200
assert service.last_call_kwargs["tag_logic"] == "all"
assert service.last_call_kwargs["base_models"] == ["SDXL"]
assert "anime" in service.last_call_kwargs["tags"]
assert "character" in service.last_call_kwargs["tags"]
assert "nsfw" in service.last_call_kwargs["tags"]
@pytest.mark.asyncio
async def test_tag_logic_without_include_tags(handler):
"""Test that tag_logic is still passed even without include tags."""
h, service = handler
response = await make_request(h, "tag_logic=all&base_model=SDXL")
assert response.status == 200
# tag_logic should still be set even without tag filters
assert service.last_call_kwargs["tag_logic"] == "all"

View File

@@ -0,0 +1,276 @@
"""Tests for tag logic (OR/AND) filtering functionality."""
import pytest
from py.services.model_query import ModelFilterSet, FilterCriteria
class StubSettings:
def get(self, key, default=None):
return default
class TestTagLogicFilter:
"""Test cases for tag_logic parameter in FilterCriteria."""
def test_tag_logic_any_returns_items_with_any_tag(self):
"""Test that tag_logic='any' (OR) returns items matching any include tag."""
filter_set = ModelFilterSet(StubSettings())
data = [
{"name": "m1", "tags": ["anime"]},
{"name": "m2", "tags": ["realistic"]},
{"name": "m3", "tags": ["anime", "realistic"]},
{"name": "m4", "tags": ["style"]},
{"name": "m5", "tags": []},
]
# Include anime OR realistic (should match m1, m2, m3)
criteria = FilterCriteria(
tags={"anime": "include", "realistic": "include"},
tag_logic="any"
)
result = filter_set.apply(data, criteria)
assert len(result) == 3
assert {item["name"] for item in result} == {"m1", "m2", "m3"}
def test_tag_logic_all_returns_items_with_all_tags(self):
"""Test that tag_logic='all' (AND) returns only items matching all include tags."""
filter_set = ModelFilterSet(StubSettings())
data = [
{"name": "m1", "tags": ["anime"]},
{"name": "m2", "tags": ["realistic"]},
{"name": "m3", "tags": ["anime", "realistic"]},
{"name": "m4", "tags": ["style"]},
{"name": "m5", "tags": []},
]
# Include anime AND realistic (should match only m3)
criteria = FilterCriteria(
tags={"anime": "include", "realistic": "include"},
tag_logic="all"
)
result = filter_set.apply(data, criteria)
assert len(result) == 1
assert result[0]["name"] == "m3"
def test_tag_logic_all_with_single_tag(self):
"""Test that tag_logic='all' with single tag works same as 'any'."""
filter_set = ModelFilterSet(StubSettings())
data = [
{"name": "m1", "tags": ["anime"]},
{"name": "m2", "tags": ["realistic"]},
{"name": "m3", "tags": ["anime", "realistic"]},
]
# Include only anime with 'all' logic
criteria = FilterCriteria(
tags={"anime": "include"},
tag_logic="all"
)
result = filter_set.apply(data, criteria)
assert len(result) == 2
assert {item["name"] for item in result} == {"m1", "m3"}
def test_tag_logic_any_with_exclude_tags(self):
"""Test that tag_logic='any' works correctly with exclude tags."""
filter_set = ModelFilterSet(StubSettings())
data = [
{"name": "m1", "tags": ["anime"]},
{"name": "m2", "tags": ["realistic"]},
{"name": "m3", "tags": ["anime", "realistic"]},
{"name": "m4", "tags": ["nsfw"]},
{"name": "m5", "tags": ["anime", "nsfw"]},
]
# Include anime OR realistic, exclude nsfw
criteria = FilterCriteria(
tags={
"anime": "include",
"realistic": "include",
"nsfw": "exclude"
},
tag_logic="any"
)
result = filter_set.apply(data, criteria)
# Should match m1 (anime), m2 (realistic), m3 (both)
# m4 excluded by nsfw, m5 excluded by nsfw
assert len(result) == 3
assert {item["name"] for item in result} == {"m1", "m2", "m3"}
def test_tag_logic_all_with_exclude_tags(self):
"""Test that tag_logic='all' works correctly with exclude tags."""
filter_set = ModelFilterSet(StubSettings())
data = [
{"name": "m1", "tags": ["anime", "character"]},
{"name": "m2", "tags": ["realistic", "character"]},
{"name": "m3", "tags": ["anime", "realistic", "character"]},
{"name": "m4", "tags": ["anime", "character", "nsfw"]},
]
# Include anime AND character, exclude nsfw
criteria = FilterCriteria(
tags={
"anime": "include",
"character": "include",
"nsfw": "exclude"
},
tag_logic="all"
)
result = filter_set.apply(data, criteria)
# m1: has anime+character, no nsfw ✓
# m2: missing anime ✗
# m3: has anime+character, no nsfw ✓
# m4: has anime+character but also nsfw ✗
assert len(result) == 2
assert {item["name"] for item in result} == {"m1", "m3"}
def test_tag_logic_all_with_no_tags_special_case(self):
"""Test tag_logic='all' with __no_tags__ special tag.
When __no_tags__ is used with 'all' logic along with regular tags,
the behavior is: items with no tags are returned (since they satisfy
__no_tags__), OR items that have all the regular tags.
This is because __no_tags__ is a special condition that can't be ANDed
with regular tags in a meaningful way.
"""
filter_set = ModelFilterSet(StubSettings())
data = [
{"name": "m1", "tags": ["anime"]},
{"name": "m2", "tags": []},
{"name": "m3", "tags": None},
{"name": "m4", "tags": ["anime", "character"]},
]
# Include anime AND __no_tags__ with 'all' logic
# Implementation treats this as: no tags OR (all regular tags)
criteria = FilterCriteria(
tags={"anime": "include", "__no_tags__": "include"},
tag_logic="all"
)
result = filter_set.apply(data, criteria)
# Items with no tags: m2, m3
# Items with all regular tags (anime): m1, m4
# Combined: m1, m2, m3, m4 (all items)
assert len(result) == 4
def test_tag_logic_any_with_no_tags_special_case(self):
"""Test tag_logic='any' with __no_tags__ special tag."""
filter_set = ModelFilterSet(StubSettings())
data = [
{"name": "m1", "tags": ["anime"]},
{"name": "m2", "tags": []},
{"name": "m3", "tags": None},
{"name": "m4", "tags": ["realistic"]},
]
# Include anime OR __no_tags__
criteria = FilterCriteria(
tags={"anime": "include", "__no_tags__": "include"},
tag_logic="any"
)
result = filter_set.apply(data, criteria)
# Should match m1 (anime), m2 (no tags), m3 (no tags)
assert len(result) == 3
assert {item["name"] for item in result} == {"m1", "m2", "m3"}
def test_tag_logic_default_is_any(self):
"""Test that default tag_logic is 'any' when not specified."""
filter_set = ModelFilterSet(StubSettings())
data = [
{"name": "m1", "tags": ["anime"]},
{"name": "m2", "tags": ["realistic"]},
{"name": "m3", "tags": ["anime", "realistic"]},
]
# Not specifying tag_logic should default to 'any'
criteria = FilterCriteria(
tags={"anime": "include", "realistic": "include"}
)
result = filter_set.apply(data, criteria)
# Should match m1, m2, m3 (OR behavior)
assert len(result) == 3
assert {item["name"] for item in result} == {"m1", "m2", "m3"}
def test_tag_logic_case_insensitive(self):
"""Test that tag_logic values are case insensitive."""
filter_set = ModelFilterSet(StubSettings())
data = [
{"name": "m1", "tags": ["anime"]},
{"name": "m2", "tags": ["realistic"]},
{"name": "m3", "tags": ["anime", "realistic"]},
]
# Test uppercase 'ALL'
criteria = FilterCriteria(
tags={"anime": "include", "realistic": "include"},
tag_logic="ALL"
)
result = filter_set.apply(data, criteria)
assert len(result) == 1
assert result[0]["name"] == "m3"
# Test mixed case 'Any'
criteria = FilterCriteria(
tags={"anime": "include", "realistic": "include"},
tag_logic="Any"
)
result = filter_set.apply(data, criteria)
assert len(result) == 3
def test_tag_logic_all_with_three_tags(self):
"""Test tag_logic='all' with three include tags."""
filter_set = ModelFilterSet(StubSettings())
data = [
{"name": "m1", "tags": ["anime"]},
{"name": "m2", "tags": ["anime", "character"]},
{"name": "m3", "tags": ["anime", "character", "style"]},
{"name": "m4", "tags": ["character", "style"]},
]
# Include anime AND character AND style
criteria = FilterCriteria(
tags={
"anime": "include",
"character": "include",
"style": "include"
},
tag_logic="all"
)
result = filter_set.apply(data, criteria)
# Only m3 has all three tags
assert len(result) == 1
assert result[0]["name"] == "m3"
def test_tag_logic_empty_include_tags(self):
"""Test that empty include tags with any logic returns all items."""
filter_set = ModelFilterSet(StubSettings())
data = [
{"name": "m1", "tags": ["anime"]},
{"name": "m2", "tags": ["realistic"]},
]
# Only exclude tags, no include tags
criteria = FilterCriteria(
tags={"nsfw": "exclude"},
tag_logic="all"
)
result = filter_set.apply(data, criteria)
# Both should match since no include filters
assert len(result) == 2
def test_tag_logic_with_none_tags_field(self):
"""Test tag_logic handles items with None tags field."""
filter_set = ModelFilterSet(StubSettings())
data = [
{"name": "m1", "tags": ["anime", "realistic"]},
{"name": "m2", "tags": None},
{"name": "m3", "tags": ["anime"]},
]
criteria = FilterCriteria(
tags={"anime": "include", "realistic": "include"},
tag_logic="all"
)
result = filter_set.apply(data, criteria)
# Only m1 has both anime and realistic
assert len(result) == 1
assert result[0]["name"] == "m1"

View File

@@ -1,17 +1,31 @@
<template>
<div class="autocomplete-text-widget">
<textarea
ref="textareaRef"
:placeholder="placeholder"
:spellcheck="spellcheck ?? false"
:class="['text-input', { 'vue-dom-mode': isVueDomMode }]"
@input="onInput"
/>
<div class="input-wrapper">
<textarea
ref="textareaRef"
:placeholder="placeholder"
:spellcheck="spellcheck ?? false"
:class="['text-input', { 'vue-dom-mode': isVueDomMode }]"
@input="onInput"
/>
<button
v-if="showClearButton"
type="button"
class="clear-button"
title="Clear text"
@click="clearText"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { ref, onMounted, onUnmounted, computed } from 'vue'
import { useAutocomplete } from '@/composables/useAutocomplete'
// Access LiteGraph global for initial mode detection
@@ -20,6 +34,7 @@ declare const LiteGraph: { vueNodesMode?: boolean } | undefined
export interface AutocompleteTextWidgetInterface {
inputEl?: HTMLTextAreaElement
callback?: (v: string) => void
onSetValue?: (v: string) => void
}
const props = defineProps<{
@@ -41,6 +56,10 @@ const onModeChange = (event: Event) => {
}
const textareaRef = ref<HTMLTextAreaElement | null>(null)
const hasText = ref(false)
// Show clear button when there is text
const showClearButton = computed(() => hasText.value)
// Initialize autocomplete with direct ref access
useAutocomplete(
@@ -49,17 +68,60 @@ useAutocomplete(
{ showPreview: props.showPreview ?? true }
)
const updateHasTextState = () => {
hasText.value = textareaRef.value ? textareaRef.value.value.length > 0 : false
}
const onInput = () => {
// Update hasText state
updateHasTextState()
// Call widget callback when text changes
if (textareaRef.value && typeof props.widget.callback === 'function') {
props.widget.callback(textareaRef.value.value)
}
}
// Handle external value changes (e.g., from "send lora to workflow")
const onExternalValueChange = (event: CustomEvent<{ value: string }>) => {
updateHasTextState()
}
// Setup widget.onSetValue callback for external value changes
const setupWidgetOnSetValue = () => {
if (props.widget) {
props.widget.onSetValue = (value: string) => {
// The DOM value is already set by setValue, just update our state
hasText.value = value.length > 0
}
}
}
const clearText = () => {
if (textareaRef.value) {
textareaRef.value.value = ''
hasText.value = false
textareaRef.value.focus()
// Trigger callback with empty value
if (typeof props.widget.callback === 'function') {
props.widget.callback('')
}
// Dispatch input event to ensure autocomplete handles the change
textareaRef.value.dispatchEvent(new Event('input'))
}
}
onMounted(() => {
// Register textarea reference with widget
if (textareaRef.value) {
props.widget.inputEl = textareaRef.value
// Initialize hasText state
hasText.value = textareaRef.value.value.length > 0
// Listen for external value change events from setValue
textareaRef.value.addEventListener('lora-manager:autocomplete-value-changed', onExternalValueChange as EventListener)
}
// Setup callback for input changes
@@ -67,6 +129,9 @@ onMounted(() => {
props.widget.callback(textareaRef.value.value)
}
// Setup widget.onSetValue callback
setupWidgetOnSetValue()
// Listen for custom event dispatched by main.ts
document.addEventListener('lora-manager:vue-mode-change', onModeChange)
})
@@ -76,6 +141,16 @@ onUnmounted(() => {
if (props.widget.inputEl === textareaRef.value) {
props.widget.inputEl = undefined
}
// Remove external value change event listener
if (textareaRef.value) {
textareaRef.value.removeEventListener('lora-manager:autocomplete-value-changed', onExternalValueChange as EventListener)
}
// Clean up onSetValue callback
if (props.widget) {
props.widget.onSetValue = undefined
}
// Remove event listener
document.removeEventListener('lora-manager:vue-mode-change', onModeChange)
@@ -91,6 +166,13 @@ onUnmounted(() => {
box-sizing: border-box;
}
.input-wrapper {
position: relative;
flex: 1;
display: flex;
width: 100%;
}
/* Canvas mode styles (default) - matches built-in comfy-multiline-input */
.text-input {
flex: 1;
@@ -122,4 +204,54 @@ onUnmounted(() => {
.text-input:focus {
outline: none;
}
/* Clear button styles */
.clear-button {
position: absolute;
right: 4px;
top: 4px;
width: 18px;
height: 18px;
padding: 0;
margin: 0;
border: none;
border-radius: 50%;
background: rgba(128, 128, 128, 0.5);
color: #fff;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
opacity: 0.7;
transition: opacity 0.2s ease, background-color 0.2s ease;
z-index: 10;
}
.clear-button:hover {
opacity: 1;
background: rgba(255, 100, 100, 0.8);
}
.clear-button svg {
width: 12px;
height: 12px;
}
/* Vue DOM mode adjustments for clear button */
.text-input.vue-dom-mode ~ .clear-button {
right: 8px;
top: 8px;
width: 20px;
height: 20px;
background: rgba(107, 114, 128, 0.6);
}
.text-input.vue-dom-mode ~ .clear-button:hover {
background: oklch(62% 0.18 25);
}
.text-input.vue-dom-mode ~ .clear-button svg {
width: 14px;
height: 14px;
}
</style>

View File

@@ -416,6 +416,14 @@ function createAutocompleteTextWidgetFactory(
setValue(v: string) {
if (widget.inputEl) {
widget.inputEl.value = v ?? ''
// Notify Vue component of value change via custom event
widget.inputEl.dispatchEvent(new CustomEvent('lora-manager:autocomplete-value-changed', {
detail: { value: v ?? '' }
}))
}
// Also call onSetValue if defined (for Vue component integration)
if (typeof widget.onSetValue === 'function') {
widget.onSetValue(v ?? '')
}
},
serialize: true,

View File

@@ -1979,16 +1979,22 @@ to { transform: rotate(360deg);
padding: 20px 0;
}
.autocomplete-text-widget[data-v-f4679753] {
.autocomplete-text-widget[data-v-2081708c] {
background: transparent;
height: 100%;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
.input-wrapper[data-v-2081708c] {
position: relative;
flex: 1;
display: flex;
width: 100%;
}
/* Canvas mode styles (default) - matches built-in comfy-multiline-input */
.text-input[data-v-f4679753] {
.text-input[data-v-2081708c] {
flex: 1;
width: 100%;
background-color: var(--comfy-input-bg, #222);
@@ -2005,7 +2011,7 @@ to { transform: rotate(360deg);
}
/* Vue DOM mode styles - matches built-in p-textarea in Vue DOM mode */
.text-input.vue-dom-mode[data-v-f4679753] {
.text-input.vue-dom-mode[data-v-2081708c] {
background-color: var(--color-charcoal-400, #313235);
color: #fff;
padding: 8px 12px;
@@ -2014,8 +2020,54 @@ to { transform: rotate(360deg);
font-size: 12px;
font-family: inherit;
}
.text-input[data-v-f4679753]:focus {
.text-input[data-v-2081708c]:focus {
outline: none;
}
/* Clear button styles */
.clear-button[data-v-2081708c] {
position: absolute;
right: 4px;
top: 4px;
width: 18px;
height: 18px;
padding: 0;
margin: 0;
border: none;
border-radius: 50%;
background: rgba(128, 128, 128, 0.5);
color: #fff;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
opacity: 0.7;
transition: opacity 0.2s ease, background-color 0.2s ease;
z-index: 10;
}
.clear-button[data-v-2081708c]:hover {
opacity: 1;
background: rgba(255, 100, 100, 0.8);
}
.clear-button svg[data-v-2081708c] {
width: 12px;
height: 12px;
}
/* Vue DOM mode adjustments for clear button */
.text-input.vue-dom-mode ~ .clear-button[data-v-2081708c] {
right: 8px;
top: 8px;
width: 20px;
height: 20px;
background: rgba(107, 114, 128, 0.6);
}
.text-input.vue-dom-mode ~ .clear-button[data-v-2081708c]:hover {
background: oklch(62% 0.18 25);
}
.text-input.vue-dom-mode ~ .clear-button svg[data-v-2081708c] {
width: 14px;
height: 14px;
}`));
document.head.appendChild(elementStyle);
}
@@ -10490,7 +10542,7 @@ const _sfc_main$m = /* @__PURE__ */ defineComponent({
const EditButton = /* @__PURE__ */ _export_sfc(_sfc_main$m, [["__scopeId", "data-v-8da8aa4b"]]);
const _hoisted_1$l = { class: "section" };
const _hoisted_2$h = { class: "section__header" };
const _hoisted_3$e = { class: "section__content" };
const _hoisted_3$f = { class: "section__content" };
const _hoisted_4$d = {
key: 0,
class: "section__placeholder"
@@ -10520,7 +10572,7 @@ const _sfc_main$l = /* @__PURE__ */ defineComponent({
onClick: _cache[0] || (_cache[0] = ($event) => _ctx.$emit("edit"))
})
]),
createBaseVNode("div", _hoisted_3$e, [
createBaseVNode("div", _hoisted_3$f, [
__props.selected.length === 0 ? (openBlock(), createElementBlock("div", _hoisted_4$d, " All models ")) : (openBlock(), createElementBlock("div", _hoisted_5$b, [
(openBlock(true), createElementBlock(Fragment, null, renderList(__props.selected, (name) => {
return openBlock(), createBlock(FilterChip, {
@@ -10539,7 +10591,7 @@ const _sfc_main$l = /* @__PURE__ */ defineComponent({
const BaseModelSection = /* @__PURE__ */ _export_sfc(_sfc_main$l, [["__scopeId", "data-v-12f059e2"]]);
const _hoisted_1$k = { class: "section" };
const _hoisted_2$g = { class: "section__columns" };
const _hoisted_3$d = { class: "section__column" };
const _hoisted_3$e = { class: "section__column" };
const _hoisted_4$c = { class: "section__column-header" };
const _hoisted_5$a = { class: "section__column-content" };
const _hoisted_6$a = {
@@ -10575,7 +10627,7 @@ const _sfc_main$k = /* @__PURE__ */ defineComponent({
createBaseVNode("span", { class: "section__title" }, "TAGS")
], -1)),
createBaseVNode("div", _hoisted_2$g, [
createBaseVNode("div", _hoisted_3$d, [
createBaseVNode("div", _hoisted_3$e, [
createBaseVNode("div", _hoisted_4$c, [
_cache[2] || (_cache[2] = createBaseVNode("span", { class: "section__column-title section__column-title--include" }, "INCLUDE", -1)),
createVNode(EditButton, {
@@ -10621,7 +10673,7 @@ const _sfc_main$k = /* @__PURE__ */ defineComponent({
const TagsSection = /* @__PURE__ */ _export_sfc(_sfc_main$k, [["__scopeId", "data-v-b869b780"]]);
const _hoisted_1$j = { class: "section" };
const _hoisted_2$f = { class: "section__columns" };
const _hoisted_3$c = { class: "section__column" };
const _hoisted_3$d = { class: "section__column" };
const _hoisted_4$b = { class: "section__column-header" };
const _hoisted_5$9 = { class: "section__content" };
const _hoisted_6$9 = {
@@ -10669,7 +10721,7 @@ const _sfc_main$j = /* @__PURE__ */ defineComponent({
createBaseVNode("span", { class: "section__title" }, "FOLDERS")
], -1)),
createBaseVNode("div", _hoisted_2$f, [
createBaseVNode("div", _hoisted_3$c, [
createBaseVNode("div", _hoisted_3$d, [
createBaseVNode("div", _hoisted_4$b, [
_cache[3] || (_cache[3] = createBaseVNode("span", { class: "section__column-title section__column-title--include" }, "INCLUDE", -1)),
createBaseVNode("button", {
@@ -10737,7 +10789,7 @@ const _sfc_main$j = /* @__PURE__ */ defineComponent({
const FoldersSection = /* @__PURE__ */ _export_sfc(_sfc_main$j, [["__scopeId", "data-v-af9caf84"]]);
const _hoisted_1$i = { class: "section" };
const _hoisted_2$e = { class: "section__toggles" };
const _hoisted_3$b = { class: "toggle-item" };
const _hoisted_3$c = { class: "toggle-item" };
const _hoisted_4$a = ["aria-checked"];
const _hoisted_5$8 = { class: "toggle-item" };
const _hoisted_6$8 = ["aria-checked"];
@@ -10755,7 +10807,7 @@ const _sfc_main$i = /* @__PURE__ */ defineComponent({
createBaseVNode("span", { class: "section__title" }, "LICENSE")
], -1)),
createBaseVNode("div", _hoisted_2$e, [
createBaseVNode("label", _hoisted_3$b, [
createBaseVNode("label", _hoisted_3$c, [
_cache[3] || (_cache[3] = createBaseVNode("span", { class: "toggle-item__label" }, "No Credit Required", -1)),
createBaseVNode("button", {
type: "button",
@@ -10789,7 +10841,7 @@ const _sfc_main$i = /* @__PURE__ */ defineComponent({
const LicenseSection = /* @__PURE__ */ _export_sfc(_sfc_main$i, [["__scopeId", "data-v-dea4adf6"]]);
const _hoisted_1$h = { class: "preview" };
const _hoisted_2$d = { class: "preview__title" };
const _hoisted_3$a = ["disabled"];
const _hoisted_3$b = ["disabled"];
const _hoisted_4$9 = {
key: 0,
class: "preview__tooltip"
@@ -10848,7 +10900,7 @@ const _sfc_main$h = /* @__PURE__ */ defineComponent({
d: "M8 3c-1.552 0-2.94.707-3.857 1.818a.5.5 0 1 1-.771-.636A6.002 6.002 0 0 1 13.917 7H12.9A5.002 5.002 0 0 0 8 3zM3.1 9a5.002 5.002 0 0 0 8.757 2.182.5.5 0 1 1 .771.636A6.002 6.002 0 0 1 2.083 9H3.1z"
})
], -1)
])], 10, _hoisted_3$a)
])], 10, _hoisted_3$b)
], 32),
createVNode(Transition, { name: "tooltip" }, {
default: withCtx(() => [
@@ -10949,7 +11001,7 @@ const _sfc_main$g = /* @__PURE__ */ defineComponent({
const LoraPoolSummaryView = /* @__PURE__ */ _export_sfc(_sfc_main$g, [["__scopeId", "data-v-328e7526"]]);
const _hoisted_1$f = { class: "lora-pool-modal__header" };
const _hoisted_2$b = { class: "lora-pool-modal__title-container" };
const _hoisted_3$9 = { class: "lora-pool-modal__title" };
const _hoisted_3$a = { class: "lora-pool-modal__title" };
const _hoisted_4$8 = {
key: 0,
class: "lora-pool-modal__subtitle"
@@ -11009,7 +11061,7 @@ const _sfc_main$f = /* @__PURE__ */ defineComponent({
}, [
createBaseVNode("div", _hoisted_1$f, [
createBaseVNode("div", _hoisted_2$b, [
createBaseVNode("h3", _hoisted_3$9, toDisplayString(__props.title), 1),
createBaseVNode("h3", _hoisted_3$a, toDisplayString(__props.title), 1),
__props.subtitle ? (openBlock(), createElementBlock("p", _hoisted_4$8, toDisplayString(__props.subtitle), 1)) : createCommentVNode("", true)
]),
createBaseVNode("button", {
@@ -11037,7 +11089,7 @@ const _sfc_main$f = /* @__PURE__ */ defineComponent({
const ModalWrapper = /* @__PURE__ */ _export_sfc(_sfc_main$f, [["__scopeId", "data-v-7b4de03d"]]);
const _hoisted_1$e = { class: "search-container" };
const _hoisted_2$a = { class: "model-list" };
const _hoisted_3$8 = ["checked", "onChange"];
const _hoisted_3$9 = ["checked", "onChange"];
const _hoisted_4$7 = { class: "model-checkbox-visual" };
const _hoisted_5$5 = {
key: 0,
@@ -11147,7 +11199,7 @@ const _sfc_main$e = /* @__PURE__ */ defineComponent({
checked: isSelected(model.name),
onChange: ($event) => toggleModel(model.name),
class: "model-checkbox"
}, null, 40, _hoisted_3$8),
}, null, 40, _hoisted_3$9),
createBaseVNode("span", _hoisted_4$7, [
isSelected(model.name) ? (openBlock(), createElementBlock("svg", _hoisted_5$5, [..._cache[4] || (_cache[4] = [
createBaseVNode("path", { d: "M13.854 3.646a.5.5 0 0 1 0 .708l-7 7a.5.5 0 0 1-.708 0l-3.5-3.5a.5.5 0 1 1 .708-.708L6.5 10.293l6.646-6.647a.5.5 0 0 1 .708 0z" }, null, -1)
@@ -11168,7 +11220,7 @@ const _sfc_main$e = /* @__PURE__ */ defineComponent({
const BaseModelModal = /* @__PURE__ */ _export_sfc(_sfc_main$e, [["__scopeId", "data-v-e02ca44a"]]);
const _hoisted_1$d = { class: "search-container" };
const _hoisted_2$9 = { class: "tags-container" };
const _hoisted_3$7 = ["onClick"];
const _hoisted_3$8 = ["onClick"];
const _hoisted_4$6 = {
key: 0,
class: "no-results"
@@ -11270,7 +11322,7 @@ const _sfc_main$d = /* @__PURE__ */ defineComponent({
type: "button",
class: normalizeClass(["tag-chip", { "tag-chip--selected": isSelected(tag.tag) }]),
onClick: ($event) => toggleTag(tag.tag)
}, toDisplayString(tag.tag), 11, _hoisted_3$7);
}, toDisplayString(tag.tag), 11, _hoisted_3$8);
}), 128)),
filteredTags.value.length === 0 ? (openBlock(), createElementBlock("div", _hoisted_4$6, " No tags found ")) : createCommentVNode("", true)
])
@@ -11286,7 +11338,7 @@ const _hoisted_2$8 = {
key: 1,
class: "tree-node__toggle-spacer"
};
const _hoisted_3$6 = { class: "tree-node__checkbox-label" };
const _hoisted_3$7 = { class: "tree-node__checkbox-label" };
const _hoisted_4$5 = ["checked"];
const _hoisted_5$4 = {
key: 0,
@@ -11347,7 +11399,7 @@ const _sfc_main$c = /* @__PURE__ */ defineComponent({
createBaseVNode("path", { d: "M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708z" }, null, -1)
])], 2))
])) : (openBlock(), createElementBlock("span", _hoisted_2$8)),
createBaseVNode("label", _hoisted_3$6, [
createBaseVNode("label", _hoisted_3$7, [
createBaseVNode("input", {
type: "checkbox",
class: "tree-node__checkbox",
@@ -11393,7 +11445,7 @@ const _sfc_main$c = /* @__PURE__ */ defineComponent({
const FolderTreeNode = /* @__PURE__ */ _export_sfc(_sfc_main$c, [["__scopeId", "data-v-90187dd4"]]);
const _hoisted_1$b = { class: "search-container" };
const _hoisted_2$7 = { class: "folder-tree" };
const _hoisted_3$5 = {
const _hoisted_3$6 = {
key: 1,
class: "no-results"
};
@@ -11492,7 +11544,7 @@ const _sfc_main$b = /* @__PURE__ */ defineComponent({
onToggleExpand: toggleExpand,
onToggleSelect: toggleSelect
}, null, 8, ["node", "selected", "expanded", "variant"]);
}), 128)) : (openBlock(), createElementBlock("div", _hoisted_3$5, " No folders found "))
}), 128)) : (openBlock(), createElementBlock("div", _hoisted_3$6, " No folders found "))
])
]),
_: 1
@@ -11836,7 +11888,7 @@ const _sfc_main$a = /* @__PURE__ */ defineComponent({
const LoraPoolWidget = /* @__PURE__ */ _export_sfc(_sfc_main$a, [["__scopeId", "data-v-4456abba"]]);
const _hoisted_1$9 = { class: "last-used-preview" };
const _hoisted_2$6 = { class: "last-used-preview__content" };
const _hoisted_3$4 = ["src", "onError"];
const _hoisted_3$5 = ["src", "onError"];
const _hoisted_4$4 = {
key: 1,
class: "last-used-preview__thumb last-used-preview__thumb--placeholder"
@@ -11888,7 +11940,7 @@ const _sfc_main$9 = /* @__PURE__ */ defineComponent({
src: previewUrls.value[lora.name],
class: "last-used-preview__thumb",
onError: ($event) => onImageError(lora.name)
}, null, 40, _hoisted_3$4)) : (openBlock(), createElementBlock("div", _hoisted_4$4, [..._cache[0] || (_cache[0] = [
}, null, 40, _hoisted_3$5)) : (openBlock(), createElementBlock("div", _hoisted_4$4, [..._cache[0] || (_cache[0] = [
createBaseVNode("svg", {
viewBox: "0 0 16 16",
fill: "currentColor"
@@ -12329,7 +12381,7 @@ const _sfc_main$7 = /* @__PURE__ */ defineComponent({
const DualRangeSlider = /* @__PURE__ */ _export_sfc(_sfc_main$7, [["__scopeId", "data-v-9f6c6950"]]);
const _hoisted_1$6 = { class: "randomizer-settings" };
const _hoisted_2$4 = { class: "setting-section" };
const _hoisted_3$3 = { class: "count-mode-tabs" };
const _hoisted_3$4 = { class: "count-mode-tabs" };
const _hoisted_4$3 = ["checked"];
const _hoisted_5$2 = ["checked"];
const _hoisted_6$2 = { class: "slider-container" };
@@ -12394,7 +12446,7 @@ const _sfc_main$6 = /* @__PURE__ */ defineComponent({
], -1)),
createBaseVNode("div", _hoisted_2$4, [
_cache[20] || (_cache[20] = createBaseVNode("label", { class: "setting-label" }, "LoRA Count", -1)),
createBaseVNode("div", _hoisted_3$3, [
createBaseVNode("div", _hoisted_3$4, [
createBaseVNode("label", {
class: normalizeClass(["count-mode-tab", { active: __props.countMode === "fixed" }])
}, [
@@ -12959,7 +13011,7 @@ const _sfc_main$5 = /* @__PURE__ */ defineComponent({
const LoraRandomizerWidget = /* @__PURE__ */ _export_sfc(_sfc_main$5, [["__scopeId", "data-v-8063df56"]]);
const _hoisted_1$4 = { class: "cycler-settings" };
const _hoisted_2$3 = { class: "setting-section progress-section" };
const _hoisted_3$2 = { class: "progress-label" };
const _hoisted_3$3 = { class: "progress-label" };
const _hoisted_4$2 = ["title"];
const _hoisted_5$1 = { class: "progress-counter" };
const _hoisted_6$1 = { class: "progress-index" };
@@ -13074,7 +13126,7 @@ const _sfc_main$4 = /* @__PURE__ */ defineComponent({
class: normalizeClass(["progress-info", { disabled: __props.isPauseDisabled }]),
onClick: handleOpenSelector
}, [
createBaseVNode("span", _hoisted_3$2, toDisplayString(__props.isWorkflowExecuting ? "Using LoRA:" : "Next LoRA:"), 1),
createBaseVNode("span", _hoisted_3$3, toDisplayString(__props.isWorkflowExecuting ? "Using LoRA:" : "Next LoRA:"), 1),
createBaseVNode("span", {
class: normalizeClass(["progress-name clickable", { disabled: __props.isPauseDisabled }]),
title: __props.currentLoraFilename
@@ -13229,7 +13281,7 @@ const _sfc_main$4 = /* @__PURE__ */ defineComponent({
const LoraCyclerSettingsView = /* @__PURE__ */ _export_sfc(_sfc_main$4, [["__scopeId", "data-v-5b16b9d3"]]);
const _hoisted_1$3 = { class: "search-container" };
const _hoisted_2$2 = { class: "lora-list" };
const _hoisted_3$1 = ["onMouseenter", "onClick"];
const _hoisted_3$2 = ["onMouseenter", "onClick"];
const _hoisted_4$1 = { class: "lora-index" };
const _hoisted_5 = ["title"];
const _hoisted_6 = {
@@ -13399,7 +13451,7 @@ const _sfc_main$3 = /* @__PURE__ */ defineComponent({
title: item.lora.file_name
}, toDisplayString(item.lora.file_name), 9, _hoisted_5),
__props.currentIndex === item.index ? (openBlock(), createElementBlock("span", _hoisted_6, "Current")) : createCommentVNode("", true)
], 42, _hoisted_3$1);
], 42, _hoisted_3$2);
}), 128)),
filteredList.value.length === 0 ? (openBlock(), createElementBlock("div", _hoisted_7, " No LoRAs found ")) : createCommentVNode("", true)
])
@@ -13926,7 +13978,7 @@ const _hoisted_2$1 = {
class: "json-content",
ref: "contentRef"
};
const _hoisted_3 = ["innerHTML"];
const _hoisted_3$1 = ["innerHTML"];
const _hoisted_4 = {
key: 1,
class: "placeholder"
@@ -14021,7 +14073,7 @@ const _sfc_main$1 = /* @__PURE__ */ defineComponent({
hasMetadata.value ? (openBlock(), createElementBlock("pre", {
key: 0,
innerHTML: highlightedJson.value
}, null, 8, _hoisted_3)) : (openBlock(), createElementBlock("div", _hoisted_4, "No metadata available"))
}, null, 8, _hoisted_3$1)) : (openBlock(), createElementBlock("div", _hoisted_4, "No metadata available"))
], 512)
]);
};
@@ -14094,7 +14146,8 @@ function useAutocomplete(textareaRef, modelType = "loras", options = {}) {
};
}
const _hoisted_1 = { class: "autocomplete-text-widget" };
const _hoisted_2 = ["placeholder", "spellcheck"];
const _hoisted_2 = { class: "input-wrapper" };
const _hoisted_3 = ["placeholder", "spellcheck"];
const _sfc_main = /* @__PURE__ */ defineComponent({
__name: "AutocompleteTextWidget",
props: {
@@ -14113,46 +14166,111 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
isVueDomMode.value = customEvent.detail.isVueDomMode;
};
const textareaRef = ref(null);
const hasText = ref(false);
const showClearButton = computed(() => hasText.value);
useAutocomplete(
textareaRef,
props.modelType ?? "loras",
{ showPreview: props.showPreview ?? true }
);
const updateHasTextState = () => {
hasText.value = textareaRef.value ? textareaRef.value.value.length > 0 : false;
};
const onInput = () => {
updateHasTextState();
if (textareaRef.value && typeof props.widget.callback === "function") {
props.widget.callback(textareaRef.value.value);
}
};
const onExternalValueChange = (event) => {
updateHasTextState();
};
const setupWidgetOnSetValue = () => {
if (props.widget) {
props.widget.onSetValue = (value) => {
hasText.value = value.length > 0;
};
}
};
const clearText = () => {
if (textareaRef.value) {
textareaRef.value.value = "";
hasText.value = false;
textareaRef.value.focus();
if (typeof props.widget.callback === "function") {
props.widget.callback("");
}
textareaRef.value.dispatchEvent(new Event("input"));
}
};
onMounted(() => {
if (textareaRef.value) {
props.widget.inputEl = textareaRef.value;
hasText.value = textareaRef.value.value.length > 0;
textareaRef.value.addEventListener("lora-manager:autocomplete-value-changed", onExternalValueChange);
}
if (textareaRef.value && typeof props.widget.callback === "function") {
props.widget.callback(textareaRef.value.value);
}
setupWidgetOnSetValue();
document.addEventListener("lora-manager:vue-mode-change", onModeChange);
});
onUnmounted(() => {
if (props.widget.inputEl === textareaRef.value) {
props.widget.inputEl = void 0;
}
if (textareaRef.value) {
textareaRef.value.removeEventListener("lora-manager:autocomplete-value-changed", onExternalValueChange);
}
if (props.widget) {
props.widget.onSetValue = void 0;
}
document.removeEventListener("lora-manager:vue-mode-change", onModeChange);
});
return (_ctx, _cache) => {
return openBlock(), createElementBlock("div", _hoisted_1, [
createBaseVNode("textarea", {
ref_key: "textareaRef",
ref: textareaRef,
placeholder: __props.placeholder,
spellcheck: __props.spellcheck ?? false,
class: normalizeClass(["text-input", { "vue-dom-mode": isVueDomMode.value }]),
onInput
}, null, 42, _hoisted_2)
createBaseVNode("div", _hoisted_2, [
createBaseVNode("textarea", {
ref_key: "textareaRef",
ref: textareaRef,
placeholder: __props.placeholder,
spellcheck: __props.spellcheck ?? false,
class: normalizeClass(["text-input", { "vue-dom-mode": isVueDomMode.value }]),
onInput
}, null, 42, _hoisted_3),
showClearButton.value ? (openBlock(), createElementBlock("button", {
key: 0,
type: "button",
class: "clear-button",
title: "Clear text",
onClick: clearText
}, [..._cache[0] || (_cache[0] = [
createBaseVNode("svg", {
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
"stroke-width": "2"
}, [
createBaseVNode("line", {
x1: "18",
y1: "6",
x2: "6",
y2: "18"
}),
createBaseVNode("line", {
x1: "6",
y1: "6",
x2: "18",
y2: "18"
})
], -1)
])])) : createCommentVNode("", true)
])
]);
};
}
});
const AutocompleteTextWidget = /* @__PURE__ */ _export_sfc(_sfc_main, [["__scopeId", "data-v-f4679753"]]);
const AutocompleteTextWidget = /* @__PURE__ */ _export_sfc(_sfc_main, [["__scopeId", "data-v-2081708c"]]);
const LORA_PROVIDER_NODE_TYPES$1 = [
"Lora Stacker (LoraManager)",
"Lora Randomizer (LoraManager)",
@@ -14734,6 +14852,12 @@ function createAutocompleteTextWidgetFactory(node, widgetName, modelType, inputO
setValue(v2) {
if (widget.inputEl) {
widget.inputEl.value = v2 ?? "";
widget.inputEl.dispatchEvent(new CustomEvent("lora-manager:autocomplete-value-changed", {
detail: { value: v2 ?? "" }
}));
}
if (typeof widget.onSetValue === "function") {
widget.onSetValue(v2 ?? "");
}
},
serialize: true,

File diff suppressed because one or more lines are too long