feat(randomizer): add lora pool Vue widget

This commit is contained in:
Will Miao
2026-01-11 16:26:38 +08:00
parent 32249d1886
commit 3d348900ac
26 changed files with 4658 additions and 119 deletions

View File

@@ -0,0 +1,113 @@
<template>
<div class="lora-pool-widget">
<!-- Summary View -->
<LoraPoolSummaryView
:selected-base-models="state.selectedBaseModels.value"
:available-base-models="state.availableBaseModels.value"
:include-tags="state.includeTags.value"
:exclude-tags="state.excludeTags.value"
:include-folders="state.includeFolders.value"
:exclude-folders="state.excludeFolders.value"
:no-credit-required="state.noCreditRequired.value"
:allow-selling="state.allowSelling.value"
:preview-items="state.previewItems.value"
:match-count="state.matchCount.value"
:is-loading="state.isLoading.value"
@open-modal="openModal"
@update:include-folders="state.includeFolders.value = $event"
@update:exclude-folders="state.excludeFolders.value = $event"
@update:no-credit-required="state.noCreditRequired.value = $event"
@update:allow-selling="state.allowSelling.value = $event"
@refresh="state.refreshPreview"
/>
<!-- Modals -->
<BaseModelModal
:visible="modalState.isModalOpen('baseModels')"
:models="state.availableBaseModels.value"
:selected="state.selectedBaseModels.value"
@close="modalState.closeModal"
@update:selected="state.selectedBaseModels.value = $event"
/>
<TagsModal
:visible="modalState.isModalOpen('includeTags')"
:tags="state.availableTags.value"
:selected="state.includeTags.value"
variant="include"
@close="modalState.closeModal"
@update:selected="state.includeTags.value = $event"
/>
<TagsModal
:visible="modalState.isModalOpen('excludeTags')"
:tags="state.availableTags.value"
:selected="state.excludeTags.value"
variant="exclude"
@close="modalState.closeModal"
@update:selected="state.excludeTags.value = $event"
/>
</div>
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import LoraPoolSummaryView from './lora-pool/LoraPoolSummaryView.vue'
import BaseModelModal from './lora-pool/modals/BaseModelModal.vue'
import TagsModal from './lora-pool/modals/TagsModal.vue'
import { useLoraPoolState } from '../composables/useLoraPoolState'
import { useModalState, type ModalType } from '../composables/useModalState'
import type { ComponentWidget, LoraPoolConfig, LegacyLoraPoolConfig } from '../composables/types'
// Props
const props = defineProps<{
widget: ComponentWidget
node: { id: number }
}>()
// State management
const state = useLoraPoolState(props.widget)
const modalState = useModalState()
// Modal handling
const openModal = (modal: ModalType) => {
modalState.openModal(modal)
}
// Lifecycle
onMounted(async () => {
console.log('[LoraPoolWidget] Mounted, node ID:', props.node.id)
// Setup serialization
props.widget.serializeValue = async () => {
const config = state.buildConfig()
console.log('[LoraPoolWidget] Serializing config:', config)
return config
}
// Restore from saved value
if (props.widget.value) {
console.log('[LoraPoolWidget] Restoring from saved value:', props.widget.value)
state.restoreFromConfig(props.widget.value as LoraPoolConfig | LegacyLoraPoolConfig)
}
// Fetch filter options
await state.fetchFilterOptions()
// Initial preview
await state.refreshPreview()
})
</script>
<style scoped>
.lora-pool-widget {
padding: 12px;
background: var(--comfy-menu-bg, #1a1a1a);
border-radius: 4px;
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
box-sizing: border-box;
}
</style>

View File

@@ -0,0 +1,193 @@
<template>
<div class="preview">
<div class="preview__header">
<span class="preview__title">Matching LoRAs: {{ matchCount }}</span>
<button
type="button"
class="preview__refresh"
:class="{ 'preview__refresh--loading': isLoading }"
@click="$emit('refresh')"
:disabled="isLoading"
>
<svg class="preview__refresh-icon" viewBox="0 0 16 16" fill="currentColor">
<path d="M11.534 7h3.932a.25.25 0 0 1 .192.41l-1.966 2.36a.25.25 0 0 1-.384 0l-1.966-2.36a.25.25 0 0 1 .192-.41zm-11 2h3.932a.25.25 0 0 0 .192-.41L2.692 6.23a.25.25 0 0 0-.384 0L.342 8.59A.25.25 0 0 0 .534 9z"/>
<path fill-rule="evenodd" 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"/>
</svg>
</button>
</div>
<div v-if="items.length > 0" class="preview__list">
<div
v-for="item in items.slice(0, 5)"
:key="item.file_path"
class="preview__item"
>
<img
v-if="item.preview_url"
:src="item.preview_url"
class="preview__thumb"
@error="onImageError"
/>
<div v-else class="preview__thumb preview__thumb--placeholder">
<svg viewBox="0 0 16 16" fill="currentColor">
<path d="M6.002 5.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0z"/>
<path d="M2.002 1a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V3a2 2 0 0 0-2-2h-12zm12 1a1 1 0 0 1 1 1v6.5l-3.777-1.947a.5.5 0 0 0-.577.093l-3.71 3.71-2.66-1.772a.5.5 0 0 0-.63.062L1.002 12V3a1 1 0 0 1 1-1h12z"/>
</svg>
</div>
<span class="preview__name">{{ item.model_name || item.file_name }}</span>
</div>
<div v-if="matchCount > 5" class="preview__more">
+{{ matchCount - 5 }} more
</div>
</div>
<div v-else-if="!isLoading" class="preview__empty">
No matching LoRAs
</div>
<div v-else class="preview__loading">
Loading...
</div>
</div>
</template>
<script setup lang="ts">
import type { LoraItem } from '../../composables/types'
defineProps<{
items: LoraItem[]
matchCount: number
isLoading: boolean
}>()
defineEmits<{
refresh: []
}>()
const onImageError = (event: Event) => {
const img = event.target as HTMLImageElement
img.style.display = 'none'
}
</script>
<style scoped>
.preview {
padding-top: 12px;
border-top: 1px solid var(--border-color, #444);
}
.preview__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.preview__title {
font-size: 12px;
font-weight: 500;
color: var(--fg-color, #fff);
}
.preview__refresh {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
color: var(--fg-color, #fff);
cursor: pointer;
opacity: 0.6;
border-radius: 4px;
transition: all 0.15s;
}
.preview__refresh:hover {
opacity: 1;
background: var(--comfy-input-bg, #333);
}
.preview__refresh:disabled {
cursor: not-allowed;
}
.preview__refresh-icon {
width: 14px;
height: 14px;
}
.preview__refresh--loading .preview__refresh-icon {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.preview__list {
display: flex;
flex-direction: column;
gap: 4px;
}
.preview__item {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 6px;
background: var(--comfy-input-bg, #333);
border-radius: 4px;
}
.preview__thumb {
width: 28px;
height: 28px;
object-fit: cover;
border-radius: 3px;
flex-shrink: 0;
background: rgba(0, 0, 0, 0.2);
}
.preview__thumb--placeholder {
display: flex;
align-items: center;
justify-content: center;
color: var(--fg-color, #fff);
opacity: 0.2;
}
.preview__thumb--placeholder svg {
width: 14px;
height: 14px;
}
.preview__name {
flex: 1;
font-size: 11px;
color: var(--fg-color, #fff);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.preview__more {
font-size: 11px;
color: var(--fg-color, #fff);
opacity: 0.5;
text-align: center;
padding: 6px;
}
.preview__empty,
.preview__loading {
font-size: 11px;
color: var(--fg-color, #fff);
opacity: 0.4;
text-align: center;
padding: 16px;
}
</style>

View File

@@ -0,0 +1,131 @@
<template>
<div class="summary-view">
<!-- Header with filter count badge -->
<div class="summary-view__header">
<div class="summary-view__badge">
<svg class="summary-view__badge-icon" viewBox="0 0 16 16" fill="currentColor">
<path d="M1.5 1.5A.5.5 0 0 1 2 1h12a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-.128.334L10 8.692V13.5a.5.5 0 0 1-.342.474l-3 1A.5.5 0 0 1 6 14.5V8.692L1.628 3.834A.5.5 0 0 1 1.5 3.5v-2z"/>
</svg>
<span class="summary-view__count">{{ matchCount.toLocaleString() }}</span>
</div>
</div>
<!-- Filter sections -->
<div class="summary-view__filters">
<BaseModelSection
:selected="selectedBaseModels"
:models="availableBaseModels"
@edit="$emit('open-modal', 'baseModels')"
/>
<TagsSection
:include-tags="includeTags"
:exclude-tags="excludeTags"
@edit-include="$emit('open-modal', 'includeTags')"
@edit-exclude="$emit('open-modal', 'excludeTags')"
/>
<FoldersSection
:include-folders="includeFolders"
:exclude-folders="excludeFolders"
@update:include-folders="$emit('update:includeFolders', $event)"
@update:exclude-folders="$emit('update:excludeFolders', $event)"
/>
<LicenseSection
:no-credit-required="noCreditRequired"
:allow-selling="allowSelling"
@update:no-credit-required="$emit('update:noCreditRequired', $event)"
@update:allow-selling="$emit('update:allowSelling', $event)"
/>
</div>
<!-- Preview -->
<LoraPoolPreview
:items="previewItems"
:match-count="matchCount"
:is-loading="isLoading"
@refresh="$emit('refresh')"
/>
</div>
</template>
<script setup lang="ts">
import BaseModelSection from './sections/BaseModelSection.vue'
import TagsSection from './sections/TagsSection.vue'
import FoldersSection from './sections/FoldersSection.vue'
import LicenseSection from './sections/LicenseSection.vue'
import LoraPoolPreview from './LoraPoolPreview.vue'
import type { BaseModelOption, LoraItem } from '../../composables/types'
import type { ModalType } from '../../composables/useModalState'
defineProps<{
// Base models
selectedBaseModels: string[]
availableBaseModels: BaseModelOption[]
// Tags
includeTags: string[]
excludeTags: string[]
// Folders
includeFolders: string[]
excludeFolders: string[]
// License
noCreditRequired: boolean
allowSelling: boolean
// Preview
previewItems: LoraItem[]
matchCount: number
isLoading: boolean
}>()
defineEmits<{
'open-modal': [modal: ModalType]
'update:includeFolders': [value: string[]]
'update:excludeFolders': [value: string[]]
'update:noCreditRequired': [value: boolean]
'update:allowSelling': [value: boolean]
refresh: []
}>()
</script>
<style scoped>
.summary-view {
display: flex;
flex-direction: column;
height: 100%;
}
.summary-view__header {
display: flex;
justify-content: flex-end;
margin-bottom: 12px;
}
.summary-view__badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
background: rgba(59, 130, 246, 0.15);
border: 1px solid rgba(59, 130, 246, 0.4);
border-radius: 4px;
color: #3b82f6;
}
.summary-view__badge-icon {
width: 12px;
height: 12px;
}
.summary-view__count {
font-size: 12px;
font-weight: 600;
}
.summary-view__filters {
flex: 1;
overflow-y: auto;
padding-right: 4px;
margin-right: -4px;
}
</style>

View File

@@ -0,0 +1,201 @@
<template>
<ModalWrapper
:visible="visible"
title="Select Base Models"
subtitle="Choose which base models to include in your filter"
@close="$emit('close')"
>
<template #search>
<div class="search-container">
<svg class="search-icon" viewBox="0 0 16 16" fill="currentColor">
<path d="M11.742 10.344a6.5 6.5 0 1 0-1.397 1.398h-.001c.03.04.062.078.098.115l3.85 3.85a1 1 0 0 0 1.415-1.414l-3.85-3.85a1.007 1.007 0 0 0-.115-.1zM12 6.5a5.5 5.5 0 1 1-11 0 5.5 5.5 0 0 1 11 0z"/>
</svg>
<input
v-model="searchQuery"
type="text"
class="search-input"
placeholder="Search models..."
@input="onSearch"
/>
</div>
</template>
<div class="model-list">
<label
v-for="model in filteredModels"
:key="model.name"
class="model-item"
>
<input
type="checkbox"
:checked="isSelected(model.name)"
@change="toggleModel(model.name)"
class="model-checkbox"
/>
<span class="model-checkbox-visual">
<svg v-if="isSelected(model.name)" class="check-icon" viewBox="0 0 16 16" fill="currentColor">
<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"/>
</svg>
</span>
<span class="model-name">{{ model.name }}</span>
<span class="model-count">({{ model.count }})</span>
</label>
<div v-if="filteredModels.length === 0" class="no-results">
No models found
</div>
</div>
</ModalWrapper>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import ModalWrapper from './ModalWrapper.vue'
import type { BaseModelOption } from '../../../composables/types'
const props = defineProps<{
visible: boolean
models: BaseModelOption[]
selected: string[]
}>()
const emit = defineEmits<{
close: []
'update:selected': [value: string[]]
}>()
const searchQuery = ref('')
const filteredModels = computed(() => {
if (!searchQuery.value) {
return props.models
}
const query = searchQuery.value.toLowerCase()
return props.models.filter(m => m.name.toLowerCase().includes(query))
})
const isSelected = (name: string) => {
return props.selected.includes(name)
}
const toggleModel = (name: string) => {
const newSelected = isSelected(name)
? props.selected.filter(n => n !== name)
: [...props.selected, name]
emit('update:selected', newSelected)
}
const onSearch = () => {
// Debounce handled by v-model reactivity
}
</script>
<style scoped>
.search-container {
position: relative;
}
.search-icon {
position: absolute;
left: 10px;
top: 50%;
transform: translateY(-50%);
width: 14px;
height: 14px;
color: var(--fg-color, #fff);
opacity: 0.5;
}
.search-input {
width: 100%;
padding: 8px 12px 8px 32px;
background: var(--comfy-input-bg, #333);
border: 1px solid var(--border-color, #444);
border-radius: 6px;
color: var(--fg-color, #fff);
font-size: 13px;
outline: none;
}
.search-input:focus {
border-color: var(--fg-color, #fff);
}
.search-input::placeholder {
color: var(--fg-color, #fff);
opacity: 0.4;
}
.model-list {
display: flex;
flex-direction: column;
gap: 2px;
}
.model-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 8px;
border-radius: 4px;
cursor: pointer;
transition: background 0.15s;
}
.model-item:hover {
background: var(--comfy-input-bg, #333);
}
.model-checkbox {
position: absolute;
opacity: 0;
pointer-events: none;
}
.model-checkbox-visual {
display: flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
background: var(--comfy-input-bg, #333);
border: 1px solid var(--border-color, #555);
border-radius: 4px;
flex-shrink: 0;
transition: all 0.15s;
}
.model-item:hover .model-checkbox-visual {
border-color: var(--fg-color, #fff);
}
.model-checkbox:checked + .model-checkbox-visual {
background: var(--fg-color, #fff);
border-color: var(--fg-color, #fff);
}
.check-icon {
width: 12px;
height: 12px;
color: var(--comfy-menu-bg, #1a1a1a);
}
.model-name {
flex: 1;
font-size: 13px;
color: var(--fg-color, #fff);
}
.model-count {
font-size: 12px;
color: var(--fg-color, #fff);
opacity: 0.5;
}
.no-results {
padding: 20px;
text-align: center;
color: var(--fg-color, #fff);
opacity: 0.5;
font-size: 13px;
}
</style>

View File

@@ -0,0 +1,184 @@
<template>
<Teleport to="body">
<Transition name="modal">
<div
v-if="visible"
class="lora-pool-modal-backdrop"
@click.self="close"
@keydown.esc="close"
>
<div class="lora-pool-modal" :class="modalClass" role="dialog" aria-modal="true">
<div class="lora-pool-modal__header">
<div class="lora-pool-modal__title-container">
<h3 class="lora-pool-modal__title">{{ title }}</h3>
<p v-if="subtitle" class="lora-pool-modal__subtitle">{{ subtitle }}</p>
</div>
<button
class="lora-pool-modal__close"
@click="close"
type="button"
aria-label="Close"
>
&times;
</button>
</div>
<div v-if="$slots.search" class="lora-pool-modal__search">
<slot name="search"></slot>
</div>
<div class="lora-pool-modal__body">
<slot></slot>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { watch, onMounted, onUnmounted } from 'vue'
const props = defineProps<{
visible: boolean
title: string
subtitle?: string
modalClass?: string
}>()
const emit = defineEmits<{
close: []
}>()
const close = () => {
emit('close')
}
// Handle escape key globally
const handleKeydown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && props.visible) {
close()
}
}
onMounted(() => {
document.addEventListener('keydown', handleKeydown)
})
onUnmounted(() => {
document.removeEventListener('keydown', handleKeydown)
})
// Prevent body scroll when modal is open
watch(() => props.visible, (isVisible) => {
if (isVisible) {
document.body.style.overflow = 'hidden'
} else {
document.body.style.overflow = ''
}
})
</script>
<style scoped>
.lora-pool-modal-backdrop {
position: fixed;
inset: 0;
z-index: 10000;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(2px);
}
.lora-pool-modal {
background: var(--comfy-menu-bg, #1a1a1a);
border: 1px solid var(--border-color, #444);
border-radius: 8px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
max-width: 400px;
width: 90%;
max-height: 70vh;
display: flex;
flex-direction: column;
}
.lora-pool-modal__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: 16px;
border-bottom: 1px solid var(--border-color, #444);
}
.lora-pool-modal__title-container {
flex: 1;
}
.lora-pool-modal__title {
font-size: 16px;
font-weight: 600;
color: var(--fg-color, #fff);
margin: 0;
}
.lora-pool-modal__subtitle {
font-size: 12px;
color: var(--fg-color, #fff);
opacity: 0.6;
margin: 4px 0 0 0;
}
.lora-pool-modal__close {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
color: var(--fg-color, #fff);
font-size: 22px;
cursor: pointer;
opacity: 0.7;
border-radius: 4px;
line-height: 1;
padding: 0;
margin: -4px -4px 0 0;
}
.lora-pool-modal__close:hover {
opacity: 1;
background: var(--comfy-input-bg, #333);
}
.lora-pool-modal__search {
padding: 12px 16px;
border-bottom: 1px solid var(--border-color, #444);
}
.lora-pool-modal__body {
flex: 1;
overflow-y: auto;
padding: 12px 16px 16px;
}
/* Transitions */
.modal-enter-active,
.modal-leave-active {
transition: opacity 0.2s ease;
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
}
.modal-enter-active .lora-pool-modal,
.modal-leave-active .lora-pool-modal {
transition: transform 0.2s ease;
}
.modal-enter-from .lora-pool-modal,
.modal-leave-to .lora-pool-modal {
transform: scale(0.95);
}
</style>

View File

@@ -0,0 +1,170 @@
<template>
<ModalWrapper
:visible="visible"
:title="title"
:subtitle="subtitle"
:modal-class="variant === 'exclude' ? 'tags-modal--exclude' : 'tags-modal--include'"
@close="$emit('close')"
>
<template #search>
<div class="search-container">
<svg class="search-icon" viewBox="0 0 16 16" fill="currentColor">
<path d="M11.742 10.344a6.5 6.5 0 1 0-1.397 1.398h-.001c.03.04.062.078.098.115l3.85 3.85a1 1 0 0 0 1.415-1.414l-3.85-3.85a1.007 1.007 0 0 0-.115-.1zM12 6.5a5.5 5.5 0 1 1-11 0 5.5 5.5 0 0 1 11 0z"/>
</svg>
<input
v-model="searchQuery"
type="text"
class="search-input"
placeholder="Search tags..."
/>
</div>
</template>
<div class="tags-container">
<button
v-for="tag in filteredTags"
:key="tag.tag"
type="button"
class="tag-chip"
:class="{ 'tag-chip--selected': isSelected(tag.tag) }"
@click="toggleTag(tag.tag)"
>
{{ tag.tag }}
</button>
<div v-if="filteredTags.length === 0" class="no-results">
No tags found
</div>
</div>
</ModalWrapper>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import ModalWrapper from './ModalWrapper.vue'
import type { TagOption } from '../../../composables/types'
const props = defineProps<{
visible: boolean
tags: TagOption[]
selected: string[]
variant: 'include' | 'exclude'
}>()
const emit = defineEmits<{
close: []
'update:selected': [value: string[]]
}>()
const title = computed(() =>
props.variant === 'include' ? 'Include Tags' : 'Exclude Tags'
)
const subtitle = computed(() =>
props.variant === 'include'
? 'Select tags that items must have'
: 'Select tags that items must NOT have'
)
const searchQuery = ref('')
const filteredTags = computed(() => {
if (!searchQuery.value) {
return props.tags
}
const query = searchQuery.value.toLowerCase()
return props.tags.filter(t => t.tag.toLowerCase().includes(query))
})
const isSelected = (tag: string) => {
return props.selected.includes(tag)
}
const toggleTag = (tag: string) => {
const newSelected = isSelected(tag)
? props.selected.filter(t => t !== tag)
: [...props.selected, tag]
emit('update:selected', newSelected)
}
</script>
<style scoped>
.search-container {
position: relative;
}
.search-icon {
position: absolute;
left: 10px;
top: 50%;
transform: translateY(-50%);
width: 14px;
height: 14px;
color: var(--fg-color, #fff);
opacity: 0.5;
}
.search-input {
width: 100%;
padding: 8px 12px 8px 32px;
background: var(--comfy-input-bg, #333);
border: 1px solid var(--border-color, #444);
border-radius: 6px;
color: var(--fg-color, #fff);
font-size: 13px;
outline: none;
}
.search-input:focus {
border-color: var(--fg-color, #fff);
}
.search-input::placeholder {
color: var(--fg-color, #fff);
opacity: 0.4;
}
.tags-container {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.tag-chip {
padding: 6px 12px;
background: var(--comfy-input-bg, #333);
border: 1px solid var(--border-color, #555);
border-radius: 16px;
color: var(--fg-color, #fff);
font-size: 12px;
cursor: pointer;
transition: all 0.15s;
}
.tag-chip:hover {
border-color: var(--fg-color, #fff);
}
/* Include variant - green when selected */
.tags-modal--include .tag-chip--selected,
.tag-chip--selected {
background: rgba(34, 197, 94, 0.2);
border-color: rgba(34, 197, 94, 0.6);
color: #22c55e;
}
/* Exclude variant - red when selected */
.tags-modal--exclude .tag-chip--selected {
background: rgba(239, 68, 68, 0.2);
border-color: rgba(239, 68, 68, 0.6);
color: #ef4444;
}
.no-results {
width: 100%;
padding: 20px;
text-align: center;
color: var(--fg-color, #fff);
opacity: 0.5;
font-size: 13px;
}
</style>

View File

@@ -0,0 +1,87 @@
<template>
<div class="section">
<div class="section__header">
<span class="section__title">BASE MODEL</span>
<EditButton @click="$emit('edit')" />
</div>
<div class="section__content">
<div v-if="selected.length === 0" class="section__placeholder">
All models
</div>
<div v-else class="section__chips">
<FilterChip
v-for="name in selected"
:key="name"
:label="name"
:count="getCount(name)"
variant="neutral"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import FilterChip from '../shared/FilterChip.vue'
import EditButton from '../shared/EditButton.vue'
import type { BaseModelOption } from '../../../composables/types'
const props = defineProps<{
selected: string[]
models: BaseModelOption[]
}>()
defineEmits<{
edit: []
}>()
const getCount = (name: string) => {
const model = props.models.find(m => m.name === name)
return model?.count
}
</script>
<style scoped>
.section {
margin-bottom: 16px;
}
.section__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.section__title {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--fg-color, #fff);
opacity: 0.6;
}
.section__content {
min-height: 32px;
display: flex;
align-items: center;
}
.section__placeholder {
width: 100%;
padding: 8px 12px;
background: var(--comfy-input-bg, #333);
border-radius: 4px;
font-size: 12px;
color: var(--fg-color, #fff);
opacity: 0.5;
text-align: center;
}
.section__chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
</style>

View File

@@ -0,0 +1,234 @@
<template>
<div class="section">
<div class="section__header">
<span class="section__title">FOLDERS</span>
</div>
<div class="section__columns">
<!-- Include column -->
<div class="section__column">
<div class="section__column-header">
<span class="section__column-title section__column-title--include">INCLUDE</span>
</div>
<div class="section__input-row">
<input
v-model="includeInput"
type="text"
class="section__input"
placeholder="Path..."
@keydown.enter="addInclude"
/>
<button
type="button"
class="section__add-btn section__add-btn--include"
@click="addInclude"
>
+
</button>
</div>
<div v-if="includeFolders.length > 0" class="section__paths">
<FilterChip
v-for="path in includeFolders"
:key="path"
:label="truncatePath(path)"
variant="path"
removable
@remove="removeInclude(path)"
/>
</div>
</div>
<!-- Exclude column -->
<div class="section__column">
<div class="section__column-header">
<span class="section__column-title section__column-title--exclude">EXCLUDE</span>
</div>
<div class="section__input-row">
<input
v-model="excludeInput"
type="text"
class="section__input"
placeholder="Path..."
@keydown.enter="addExclude"
/>
<button
type="button"
class="section__add-btn section__add-btn--exclude"
@click="addExclude"
>
+
</button>
</div>
<div v-if="excludeFolders.length > 0" class="section__paths">
<FilterChip
v-for="path in excludeFolders"
:key="path"
:label="truncatePath(path)"
variant="path"
removable
@remove="removeExclude(path)"
/>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import FilterChip from '../shared/FilterChip.vue'
const props = defineProps<{
includeFolders: string[]
excludeFolders: string[]
}>()
const emit = defineEmits<{
'update:includeFolders': [value: string[]]
'update:excludeFolders': [value: string[]]
}>()
const includeInput = ref('')
const excludeInput = ref('')
const truncatePath = (path: string) => {
if (path.length <= 20) return path
return '...' + path.slice(-17)
}
const addInclude = () => {
const path = includeInput.value.trim()
if (path && !props.includeFolders.includes(path)) {
emit('update:includeFolders', [...props.includeFolders, path])
includeInput.value = ''
}
}
const removeInclude = (path: string) => {
emit('update:includeFolders', props.includeFolders.filter(p => p !== path))
}
const addExclude = () => {
const path = excludeInput.value.trim()
if (path && !props.excludeFolders.includes(path)) {
emit('update:excludeFolders', [...props.excludeFolders, path])
excludeInput.value = ''
}
}
const removeExclude = (path: string) => {
emit('update:excludeFolders', props.excludeFolders.filter(p => p !== path))
}
</script>
<style scoped>
.section {
margin-bottom: 16px;
}
.section__header {
margin-bottom: 8px;
}
.section__title {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--fg-color, #fff);
opacity: 0.6;
}
.section__columns {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.section__column {
min-width: 0;
}
.section__column-header {
margin-bottom: 6px;
}
.section__column-title {
font-size: 9px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.section__column-title--include {
color: #22c55e;
}
.section__column-title--exclude {
color: #ef4444;
}
.section__input-row {
display: flex;
gap: 4px;
}
.section__input {
flex: 1;
min-width: 0;
padding: 6px 8px;
background: var(--comfy-input-bg, #333);
border: 1px solid var(--border-color, #444);
border-radius: 4px;
color: var(--fg-color, #fff);
font-size: 11px;
outline: none;
}
.section__input:focus {
border-color: var(--fg-color, #fff);
}
.section__input::placeholder {
color: var(--fg-color, #fff);
opacity: 0.4;
}
.section__add-btn {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
background: var(--comfy-input-bg, #333);
border: 1px solid var(--border-color, #444);
border-radius: 4px;
color: var(--fg-color, #fff);
font-size: 16px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
}
.section__add-btn:hover {
border-color: var(--fg-color, #fff);
}
.section__add-btn--include:hover {
background: rgba(34, 197, 94, 0.2);
border-color: #22c55e;
color: #22c55e;
}
.section__add-btn--exclude:hover {
background: rgba(239, 68, 68, 0.2);
border-color: #ef4444;
color: #ef4444;
}
.section__paths {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 6px;
}
</style>

View File

@@ -0,0 +1,132 @@
<template>
<div class="section">
<div class="section__header">
<span class="section__title">LICENSE</span>
</div>
<div class="section__toggles">
<label class="toggle-item">
<span class="toggle-item__label">No Credit</span>
<button
type="button"
class="toggle-switch"
:class="{ 'toggle-switch--active': noCreditRequired }"
@click="$emit('update:noCreditRequired', !noCreditRequired)"
role="switch"
:aria-checked="noCreditRequired"
>
<span class="toggle-switch__track"></span>
<span class="toggle-switch__thumb"></span>
</button>
</label>
<label class="toggle-item">
<span class="toggle-item__label">Allow Selling</span>
<button
type="button"
class="toggle-switch"
:class="{ 'toggle-switch--active': allowSelling }"
@click="$emit('update:allowSelling', !allowSelling)"
role="switch"
:aria-checked="allowSelling"
>
<span class="toggle-switch__track"></span>
<span class="toggle-switch__thumb"></span>
</button>
</label>
</div>
</div>
</template>
<script setup lang="ts">
defineProps<{
noCreditRequired: boolean
allowSelling: boolean
}>()
defineEmits<{
'update:noCreditRequired': [value: boolean]
'update:allowSelling': [value: boolean]
}>()
</script>
<style scoped>
.section {
margin-bottom: 16px;
}
.section__header {
margin-bottom: 8px;
}
.section__title {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--fg-color, #fff);
opacity: 0.6;
}
.section__toggles {
display: flex;
gap: 16px;
}
.toggle-item {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.toggle-item__label {
font-size: 12px;
color: var(--fg-color, #fff);
}
.toggle-switch {
position: relative;
width: 36px;
height: 20px;
padding: 0;
background: transparent;
border: none;
cursor: pointer;
}
.toggle-switch__track {
position: absolute;
inset: 0;
background: var(--comfy-input-bg, #333);
border: 1px solid var(--border-color, #444);
border-radius: 10px;
transition: all 0.2s;
}
.toggle-switch--active .toggle-switch__track {
background: rgba(34, 197, 94, 0.3);
border-color: rgba(34, 197, 94, 0.6);
}
.toggle-switch__thumb {
position: absolute;
top: 2px;
left: 2px;
width: 14px;
height: 14px;
background: var(--fg-color, #fff);
border-radius: 50%;
transition: all 0.2s;
opacity: 0.6;
}
.toggle-switch--active .toggle-switch__thumb {
transform: translateX(16px);
background: #22c55e;
opacity: 1;
}
.toggle-switch:hover .toggle-switch__thumb {
opacity: 1;
}
</style>

View File

@@ -0,0 +1,133 @@
<template>
<div class="section">
<div class="section__header">
<span class="section__title">TAGS</span>
</div>
<div class="section__columns">
<!-- Include column -->
<div class="section__column">
<div class="section__column-header">
<span class="section__column-title section__column-title--include">INCLUDE</span>
<EditButton @click="$emit('edit-include')" />
</div>
<div class="section__column-content">
<div v-if="includeTags.length === 0" class="section__empty">
None
</div>
<div v-else class="section__chips">
<FilterChip
v-for="tag in includeTags"
:key="tag"
:label="tag"
variant="include"
/>
</div>
</div>
</div>
<!-- Exclude column -->
<div class="section__column">
<div class="section__column-header">
<span class="section__column-title section__column-title--exclude">EXCLUDE</span>
<EditButton @click="$emit('edit-exclude')" />
</div>
<div class="section__column-content">
<div v-if="excludeTags.length === 0" class="section__empty">
None
</div>
<div v-else class="section__chips">
<FilterChip
v-for="tag in excludeTags"
:key="tag"
:label="tag"
variant="exclude"
/>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import FilterChip from '../shared/FilterChip.vue'
import EditButton from '../shared/EditButton.vue'
defineProps<{
includeTags: string[]
excludeTags: string[]
}>()
defineEmits<{
'edit-include': []
'edit-exclude': []
}>()
</script>
<style scoped>
.section {
margin-bottom: 16px;
}
.section__header {
margin-bottom: 8px;
}
.section__title {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--fg-color, #fff);
opacity: 0.6;
}
.section__columns {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.section__column {
min-width: 0;
}
.section__column-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
}
.section__column-title {
font-size: 9px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.section__column-title--include {
color: #22c55e;
}
.section__column-title--exclude {
color: #ef4444;
}
.section__column-content {
min-height: 28px;
}
.section__empty {
padding: 6px 0;
font-size: 11px;
color: var(--fg-color, #fff);
opacity: 0.4;
}
.section__chips {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
</style>

View File

@@ -0,0 +1,45 @@
<template>
<button class="edit-button" type="button" @click="$emit('click')">
<svg class="edit-button__icon" viewBox="0 0 16 16" fill="currentColor">
<path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5L13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175l-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z"/>
</svg>
<span class="edit-button__text">Edit</span>
</button>
</template>
<script setup lang="ts">
defineEmits<{
click: []
}>()
</script>
<style scoped>
.edit-button {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 6px;
background: transparent;
border: none;
color: var(--fg-color);
font-size: 11px;
cursor: pointer;
opacity: 0.6;
transition: opacity 0.15s;
border-radius: 3px;
}
.edit-button:hover {
opacity: 1;
background: rgba(255, 255, 255, 0.05);
}
.edit-button__icon {
width: 10px;
height: 10px;
}
.edit-button__text {
font-weight: 400;
}
</style>

View File

@@ -0,0 +1,109 @@
<template>
<span class="filter-chip" :class="variantClass">
<span class="filter-chip__text">{{ label }}</span>
<span v-if="count !== undefined" class="filter-chip__count">({{ count }})</span>
<button
v-if="removable"
class="filter-chip__remove"
@click.stop="$emit('remove')"
type="button"
>
&times;
</button>
</span>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{
label: string
count?: number
variant?: 'include' | 'exclude' | 'neutral' | 'path'
removable?: boolean
}>()
defineEmits<{
remove: []
}>()
const variantClass = computed(() => {
return props.variant ? `filter-chip--${props.variant}` : ''
})
</script>
<style scoped>
.filter-chip {
display: inline-flex;
align-items: center;
gap: 2px;
padding: 3px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 500;
background: var(--comfy-input-bg);
border: 1px solid var(--border-color);
color: var(--fg-color);
white-space: nowrap;
}
.filter-chip__text {
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
}
.filter-chip__count {
opacity: 0.6;
font-size: 10px;
}
.filter-chip__remove {
display: flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
margin-left: 2px;
padding: 0;
background: transparent;
border: none;
color: inherit;
font-size: 14px;
line-height: 1;
cursor: pointer;
opacity: 0.6;
transition: opacity 0.15s;
}
.filter-chip__remove:hover {
opacity: 1;
}
/* Variants */
.filter-chip--include {
background: rgba(34, 197, 94, 0.15);
border-color: rgba(34, 197, 94, 0.4);
color: #22c55e;
}
.filter-chip--exclude {
background: rgba(239, 68, 68, 0.15);
border-color: rgba(239, 68, 68, 0.4);
color: #ef4444;
}
.filter-chip--neutral {
background: rgba(100, 100, 100, 0.3);
border-color: rgba(150, 150, 150, 0.4);
color: var(--fg-color);
}
.filter-chip--path {
background: rgba(30, 30, 30, 0.8);
border-color: rgba(255, 255, 255, 0.15);
color: var(--fg-color);
font-family: monospace;
font-size: 10px;
}
</style>