mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-24 14:42:11 -03:00
feat(randomizer): add lora pool Vue widget
This commit is contained in:
113
vue-widgets/src/components/LoraPoolWidget.vue
Normal file
113
vue-widgets/src/components/LoraPoolWidget.vue
Normal 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>
|
||||
193
vue-widgets/src/components/lora-pool/LoraPoolPreview.vue
Normal file
193
vue-widgets/src/components/lora-pool/LoraPoolPreview.vue
Normal 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>
|
||||
131
vue-widgets/src/components/lora-pool/LoraPoolSummaryView.vue
Normal file
131
vue-widgets/src/components/lora-pool/LoraPoolSummaryView.vue
Normal 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>
|
||||
201
vue-widgets/src/components/lora-pool/modals/BaseModelModal.vue
Normal file
201
vue-widgets/src/components/lora-pool/modals/BaseModelModal.vue
Normal 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>
|
||||
184
vue-widgets/src/components/lora-pool/modals/ModalWrapper.vue
Normal file
184
vue-widgets/src/components/lora-pool/modals/ModalWrapper.vue
Normal 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"
|
||||
>
|
||||
×
|
||||
</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>
|
||||
170
vue-widgets/src/components/lora-pool/modals/TagsModal.vue
Normal file
170
vue-widgets/src/components/lora-pool/modals/TagsModal.vue
Normal 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>
|
||||
@@ -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>
|
||||
234
vue-widgets/src/components/lora-pool/sections/FoldersSection.vue
Normal file
234
vue-widgets/src/components/lora-pool/sections/FoldersSection.vue
Normal 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>
|
||||
132
vue-widgets/src/components/lora-pool/sections/LicenseSection.vue
Normal file
132
vue-widgets/src/components/lora-pool/sections/LicenseSection.vue
Normal 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>
|
||||
133
vue-widgets/src/components/lora-pool/sections/TagsSection.vue
Normal file
133
vue-widgets/src/components/lora-pool/sections/TagsSection.vue
Normal 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>
|
||||
45
vue-widgets/src/components/lora-pool/shared/EditButton.vue
Normal file
45
vue-widgets/src/components/lora-pool/shared/EditButton.vue
Normal 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>
|
||||
109
vue-widgets/src/components/lora-pool/shared/FilterChip.vue
Normal file
109
vue-widgets/src/components/lora-pool/shared/FilterChip.vue
Normal 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"
|
||||
>
|
||||
×
|
||||
</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>
|
||||
Reference in New Issue
Block a user