feat(storybook): [DRAFT] enhance error tab stories for design feedback

This commit adds 7 comprehensive Storybook stories for the error tab to
collect design feedback.
This commit is contained in:
jaeone94
2026-02-20 01:30:29 +09:00
parent 864b14b302
commit 8c3bb91f1e
9 changed files with 2447 additions and 0 deletions

View File

@@ -320,5 +320,15 @@ export default defineConfig([
}
]
}
},
// Storybook-only mock components (__stories__/**/*.vue)
// These are not shipped to production and do not require i18n or strict Vue patterns.
{
files: ['**/__stories__/**/*.vue'],
rules: {
'@intlify/vue-i18n/no-raw-text': 'off',
'vue/no-unused-properties': 'off'
}
}
])

View File

@@ -0,0 +1,137 @@
/**
* @file TabErrors.stories.ts
*
* Error Tab Missing Node Packs UX Flow Stories (OSS environment)
*/
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import StoryOSSMissingNodePackFlow from './__stories__/StoryOSSMissingNodePackFlow.vue'
import MockOSSMissingNodePack from './__stories__/MockOSSMissingNodePack.vue'
import MockCloudMissingNodePack from './__stories__/MockCloudMissingNodePack.vue'
import MockCloudMissingModel from './__stories__/MockCloudMissingModel.vue'
import MockCloudMissingModelBasic from './__stories__/MockCloudMissingModelBasic.vue'
import MockOSSMissingModel from './__stories__/MockOSSMissingModel.vue'
// Storybook Meta
const meta = {
title: 'RightSidePanel/Errors/TabErrors',
parameters: {
layout: 'fullscreen',
docs: {
description: {
component: `
## Error Tab Missing Node Packs UX Flow (OSS environment)
### Right Panel Structure
- **Nav Item**: "Workflow Overview" + panel-right button
- **Tab bar**: Error (octagon-alert icon) | Inputs | Nodes | Global settings
- **Search bar**: 12px, #8a8a8a placeholder
- **Missing Node Packs section**: octagon-alert (red) + label + Install All + chevron
- **Each widget row** (72px): name (truncate) + info + locate | Install node pack ↓
> In Cloud environments, the Install button is not displayed.
`
}
}
}
} satisfies Meta
export default meta
type Story = StoryObj<typeof meta>
// Stories
/**
* **[Local OSS] Missing Node Packs**
*
* A standalone story for the Right Side Panel's Error Tab mockup.
* This allows testing the tab's interactions (install, locate, etc.) in isolation.
*/
export const OSS_ErrorTabOnly: Story = {
name: '[Local OSS] Missing Node Packs',
render: () => ({
components: { MockOSSMissingNodePack },
template: `
<div class="h-[800px] border border-[#494a50] rounded-lg overflow-hidden">
<MockOSSMissingNodePack @log="msg => console.log('Log:', msg)" />
</div>
`
})
}
/**
* **[Local OSS] UX Flow - Missing Node Pack**
*
* Full ComfyUI layout simulation:
*/
export const OSS_MissingNodePacksFullFlow: Story = {
name: '[Local OSS] UX Flow - Missing Node Pack',
render: () => ({
components: { StoryOSSMissingNodePackFlow },
template: `<div style="width:100vw;height:100vh;"><StoryOSSMissingNodePackFlow /></div>`
}),
parameters: {
layout: 'fullscreen'
}
}
/**
* **[Cloud] Missing Node Pack**
*/
export const Cloud_MissingNodePacks: Story = {
name: '[Cloud] Missing Node Pack',
render: () => ({
components: { MockCloudMissingNodePack },
template: `
<div class="h-[800px] border border-[#494a50] rounded-lg overflow-hidden">
<MockCloudMissingNodePack @log="msg => console.log('Log:', msg)" />
</div>
`
})
}
/**
* **[Local OSS] Missing Model**
*/
export const OSS_MissingModels: Story = {
name: '[Local OSS] Missing Model',
render: () => ({
components: { MockOSSMissingModel },
template: `
<div class="h-[800px] border border-[#494a50] rounded-lg overflow-hidden">
<MockOSSMissingModel @locate="name => console.log('Locate:', name)" />
</div>
`
})
}
/**
* **[Cloud] Missing Model**
*/
export const Cloud_MissingModels: Story = {
name: '[Cloud] Missing Model',
render: () => ({
components: { MockCloudMissingModelBasic },
template: `
<div class="h-[800px] border border-[#494a50] rounded-lg overflow-hidden">
<MockCloudMissingModelBasic @locate="name => console.log('Locate:', name)" />
</div>
`
})
}
/**
* **[Cloud] Missing Model - with model type selector**
*/
export const Cloud_MissingModelsWithSelector: Story = {
name: '[Cloud] Missing Model - with model type selector',
render: () => ({
components: { MockCloudMissingModel },
template: `
<div class="h-[800px] border border-[#494a50] rounded-lg overflow-hidden">
<MockCloudMissingModel @locate="name => console.log('Locate:', name)" />
</div>
`
})
}

View File

@@ -0,0 +1,521 @@
<script setup lang="ts">
import { ref, watch, computed } from 'vue'
import Button from '@/components/ui/button/Button.vue'
// Props / Emits
const emit = defineEmits<{
'locate': [name: string],
}>()
// Mock Data
interface MissingModel {
id: string
name: string
type: string
}
const INITIAL_MISSING_MODELS: Record<string, MissingModel[]> = {
'Lora': [
{ id: 'm1', name: 'Flat_color_anime.safetensors', type: 'Lora' },
{ id: 'm2', name: 'Bokeh_blur_xl.safetensors', type: 'Lora' },
{ id: 'm3', name: 'Skin_texture_realism.safetensors', type: 'Lora' }
],
'VAE': [
{ id: 'v1', name: 'vae-ft-mse-840000-ema-pruned.safetensors', type: 'VAE' },
{ id: 'v2', name: 'clear-vae-v1.safetensors', type: 'VAE' }
]
}
const MODEL_TYPES = [
'AnimateDiff Model',
'AnimateDiff Motion LoRA',
'Audio Encoders',
'Chatterbox/chatterbox',
'Chatterbox/chatterbox Multilingual',
'Chatterbox/chatterbox Turbo',
'Chatterbox/chatterbox Vc',
'Checkpoints',
'CLIP Vision'
]
const LIBRARY_MODELS = [
'v1-5-pruned-emaonly.safetensors',
'sd_xl_base_1.0.safetensors',
'dreamshaper_8.safetensors',
'realisticVisionV51_v51VAE.safetensors'
]
// State
const collapsedCategories = ref<Record<string, boolean>>({
'VAE': true
})
// Stores the URL input for each model
const modelInputs = ref<Record<string, string>>({})
// Tracks which models have finished their "revealing" delay
const revealingModels = ref<Record<string, boolean>>({})
const inputTimeouts = ref<Record<string, ReturnType<typeof setTimeout>>>({})
// Model Status: 'idle' | 'downloading' | 'downloaded' | 'using_library'
const importStatus = ref<Record<string, 'idle' | 'downloading' | 'downloaded' | 'using_library'>>({})
const downloadProgress = ref<Record<string, number>>({})
const downloadTimers = ref<Record<string, ReturnType<typeof setInterval>>>({})
const selectedLibraryModel = ref<Record<string, string>>({})
// Track hidden models (removed after clicking check button)
const removedModels = ref<Record<string, boolean>>({})
// Watch for input changes to trigger the 1s delay
watch(modelInputs, (newVal) => {
for (const id in newVal) {
const value = newVal[id]
if (value && !revealingModels.value[id] && !inputTimeouts.value[id]) {
inputTimeouts.value[id] = setTimeout(() => {
revealingModels.value[id] = true
delete inputTimeouts.value[id]
}, 1000)
} else if (!value) {
revealingModels.value[id] = false
if (inputTimeouts.value[id]) {
clearTimeout(inputTimeouts.value[id])
delete inputTimeouts.value[id]
}
}
}
}, { deep: true })
// Compute which categories have at least one visible model
const activeCategories = computed(() => {
const result: Record<string, boolean> = {}
for (const cat in INITIAL_MISSING_MODELS) {
result[cat] = INITIAL_MISSING_MODELS[cat].some(m => !removedModels.value[m.id])
}
return result
})
// Stores the selected type for each model
const selectedModelTypes = ref<Record<string, string>>({})
// Tracks which model's type dropdown is currently open
const activeDropdown = ref<string | null>(null)
// Tracks which model's library dropdown is currently open
const activeLibraryDropdown = ref<string | null>(null)
// Actions
function toggleDropdown(modelId: string) {
activeLibraryDropdown.value = null
if (activeDropdown.value === modelId) {
activeDropdown.value = null
} else {
activeDropdown.value = modelId
}
}
function toggleLibraryDropdown(modelId: string) {
activeDropdown.value = null
if (activeLibraryDropdown.value === modelId) {
activeLibraryDropdown.value = null
} else {
activeLibraryDropdown.value = modelId
}
}
function selectType(modelId: string, type: string) {
selectedModelTypes.value[modelId] = type
activeDropdown.value = null
}
function selectFromLibrary(modelId: string, fileName: string) {
selectedLibraryModel.value[modelId] = fileName
importStatus.value[modelId] = 'using_library'
activeLibraryDropdown.value = null
}
function startImport(modelId: string) {
if (downloadTimers.value[modelId]) {
clearInterval(downloadTimers.value[modelId])
}
importStatus.value[modelId] = 'downloading'
downloadProgress.value[modelId] = 0
const startTime = Date.now()
const duration = 5000
downloadTimers.value[modelId] = setInterval(() => {
const elapsed = Date.now() - startTime
const progress = Math.min((elapsed / duration) * 100, 100)
downloadProgress.value[modelId] = progress
if (progress >= 100) {
clearInterval(downloadTimers.value[modelId])
delete downloadTimers.value[modelId]
importStatus.value[modelId] = 'downloaded'
}
}, 50)
}
function handleCheckClick(modelId: string) {
if (importStatus.value[modelId] === 'downloaded' || importStatus.value[modelId] === 'using_library') {
// Update object with spread to guarantee reactivity trigger
removedModels.value = { ...removedModels.value, [modelId]: true }
}
}
function cancelImport(modelId: string) {
if (downloadTimers.value[modelId]) {
clearInterval(downloadTimers.value[modelId])
delete downloadTimers.value[modelId]
}
importStatus.value[modelId] = 'idle'
downloadProgress.value[modelId] = 0
modelInputs.value[modelId] = ''
selectedLibraryModel.value[modelId] = ''
}
function resetAll() {
// Clear all status records
modelInputs.value = {}
revealingModels.value = {}
// Clear any running timers
for (const id in downloadTimers.value) {
clearInterval(downloadTimers.value[id])
}
downloadTimers.value = {}
for (const id in inputTimeouts.value) {
clearTimeout(inputTimeouts.value[id])
}
inputTimeouts.value = {}
importStatus.value = {}
downloadProgress.value = {}
selectedLibraryModel.value = {}
removedModels.value = {}
activeDropdown.value = null
activeLibraryDropdown.value = null
}
// Helpers
function getElementStyle(el: HTMLElement) {
return {
height: el.style.height,
overflow: el.style.overflow,
paddingTop: el.style.paddingTop,
paddingBottom: el.style.paddingBottom,
marginTop: el.style.marginTop,
marginBottom: el.style.marginBottom
}
}
// Transitions
const DURATION = 150
function enterTransition(element: Element, done: () => void) {
const el = element as HTMLElement
const init = getElementStyle(el)
const { width } = getComputedStyle(el)
el.style.width = width
el.style.position = 'absolute'
el.style.visibility = 'hidden'
el.style.height = ''
const { height } = getComputedStyle(el)
el.style.position = ''
el.style.visibility = ''
el.style.height = '0px'
el.style.overflow = 'hidden'
const anim = el.animate(
[{ height: '0px', opacity: 0 }, { height, opacity: 1 }],
{ duration: DURATION, easing: 'ease-in-out' }
)
el.style.height = init.height
anim.onfinish = () => { el.style.overflow = init.overflow; done() }
}
function leaveTransition(element: Element, done: () => void) {
const el = element as HTMLElement
const init = getElementStyle(el)
const { height } = getComputedStyle(el)
el.style.height = height
el.style.overflow = 'hidden'
const anim = el.animate(
[{ height, opacity: 1 }, { height: '0px', opacity: 0 }],
{ duration: DURATION, easing: 'ease-in-out' }
)
el.style.height = init.height
anim.onfinish = () => { el.style.overflow = init.overflow; done() }
}
</script>
<template>
<div
class="w-[320px] h-full shrink-0 flex flex-col gap-4 py-1 bg-[#171718] border-l border-[#494a50] shadow-[1px_1px_8px_0px_rgba(0,0,0,0.4)]"
>
<!-- Nav Item -->
<div class="flex h-12 items-center overflow-hidden py-2 border-b border-[#55565e] shrink-0">
<div class="flex flex-1 gap-2 items-center min-w-0 pl-4 pr-3">
<p class="flex-1 min-w-0 font-bold text-sm text-white whitespace-pre-wrap">
Workflow Overview
</p>
<div class="flex h-8 items-center justify-center overflow-hidden p-2 rounded-lg bg-[#262729] shrink-0 cursor-pointer hover:bg-[#303133]">
<i class="icon-[lucide--panel-right] size-4 text-white" />
</div>
</div>
</div>
<!-- Node Header -->
<div class="flex flex-col gap-3 items-start px-4 shrink-0">
<div class="flex gap-2 items-center w-full overflow-x-auto no-scrollbar">
<div class="flex gap-1 h-8 items-center justify-center overflow-hidden px-2 rounded-lg shrink-0 bg-[#262729]">
<span class="text-sm text-white">Error</span>
<div class="flex items-center justify-center size-6 shrink-0">
<i class="icon-[lucide--octagon-alert] size-4 text-[#e04e48]" />
</div>
</div>
<div class="flex h-8 items-center justify-center overflow-hidden px-2 rounded-lg shrink-0">
<span class="text-sm text-[#8a8a8a]">Inputs</span>
</div>
<div class="flex h-8 items-center justify-center overflow-hidden px-2 rounded-lg shrink-0">
<span class="text-sm text-[#8a8a8a]">Nodes</span>
</div>
<div class="flex h-8 items-center justify-center overflow-hidden px-2 rounded-lg shrink-0">
<span class="text-sm whitespace-nowrap text-[#8a8a8a]">Global settings</span>
</div>
</div>
<div class="flex gap-2 h-8 items-center min-h-[32px] px-2 py-1.5 rounded-lg bg-[#262729] w-full">
<i class="icon-[lucide--search] size-4 text-[#8a8a8a] shrink-0" />
<p class="flex-1 text-xs text-[#8a8a8a] truncate leading-normal">
Search for nodes or inputs
</p>
</div>
</div>
<div class="h-px bg-[#55565e] shrink-0 w-full" />
<!-- Content: Missing Models -->
<div class="flex-1 overflow-y-auto min-w-0 no-scrollbar">
<template v-for="(models, category) in INITIAL_MISSING_MODELS" :key="category">
<div
v-if="activeCategories[category]"
class="px-4 mb-4"
>
<!-- Category Header -->
<div
class="flex h-8 items-center justify-center w-full group"
:class="category === 'VAE' ? 'cursor-default' : 'cursor-pointer'"
@click="category !== 'VAE' && (collapsedCategories[category] = !collapsedCategories[category])"
>
<div class="flex items-center justify-center size-6 shrink-0">
<i class="icon-[lucide--octagon-alert] size-4 text-[#e04e48]" />
</div>
<p class="flex-1 min-w-0 text-sm text-[#e04e48] whitespace-pre-wrap font-medium">
{{ category }} ({{ models.filter(m => !removedModels[m.id]).length }})
</p>
<div class="flex h-8 items-center justify-center overflow-hidden p-2 rounded-lg shrink-0">
<i
class="icon-[lucide--chevron-up] size-4 text-[#8a8a8a] transition-all"
:class="[
category !== 'VAE' ? 'group-hover:text-white' : '',
collapsedCategories[category] ? '-rotate-180' : ''
]"
/>
</div>
</div>
<!-- Model List -->
<Transition :css="false" @enter="enterTransition" @leave="leaveTransition">
<div v-if="!collapsedCategories[category]" class="pt-2">
<TransitionGroup :css="false" @enter="enterTransition" @leave="leaveTransition">
<div v-for="model in models" v-show="!removedModels[model.id]" :key="model.id" class="flex flex-col w-full mb-6 last:mb-4">
<!-- Model Header (Always visible) -->
<div class="flex h-8 items-center w-full gap-2 mb-1">
<i class="icon-[lucide--file-check] size-4 text-white shrink-0" />
<p class="flex-1 min-w-0 text-sm font-medium text-white overflow-hidden text-ellipsis whitespace-nowrap">
{{ model.name }}
</p>
<!-- Check Button (Highlights blue when downloaded or using from library) -->
<div
class="flex h-8 items-center justify-center overflow-hidden p-2 rounded-lg shrink-0 transition-colors"
:class="[
(importStatus[model.id] === 'downloaded' || importStatus[model.id] === 'using_library')
? 'cursor-pointer hover:bg-[#1e2d3d] bg-[#1e2d3d]'
: 'opacity-20 cursor-default'
]"
@click="handleCheckClick(model.id)"
>
<i
class="icon-[lucide--check] size-4"
:class="(importStatus[model.id] === 'downloaded' || importStatus[model.id] === 'using_library') ? 'text-[#3b82f6]' : 'text-white'"
/>
</div>
<!-- Locate Button -->
<div
class="flex h-8 items-center justify-center overflow-hidden p-2 rounded-lg shrink-0 cursor-pointer hover:bg-[#262729]"
@click="emit('locate', model.name)"
>
<i class="icon-[lucide--locate] size-4 text-white" />
</div>
</div>
<!-- Input or Progress Area -->
<div class="relative mt-1">
<!-- CARD (Download or Library Substitute) -->
<Transition :css="false" @enter="enterTransition" @leave="leaveTransition">
<div
v-if="importStatus[model.id] && importStatus[model.id] !== 'idle'"
class="relative bg-white/5 border border-[#55565e] rounded-lg overflow-hidden flex items-center p-2 gap-2"
>
<!-- Progress Filling (Only while downloading) -->
<div
v-if="importStatus[model.id] === 'downloading'"
class="absolute inset-y-0 left-0 bg-[#3b82f6]/10 transition-all duration-100 ease-linear pointer-events-none"
:style="{ width: downloadProgress[model.id] + '%' }"
/>
<!-- Left Icon -->
<div class="relative z-10 size-[32px] flex items-center justify-center shrink-0">
<i class="icon-[lucide--file-check] size-5 text-[#8a8a8a]" />
</div>
<!-- Center Text -->
<div class="relative z-10 flex-1 min-w-0 flex flex-col justify-center">
<span class="text-[12px] font-medium text-white truncate leading-tight">
{{ importStatus[model.id] === 'using_library' ? selectedLibraryModel[model.id] : model.name }}
</span>
<span class="text-[12px] text-[#8a8a8a] leading-tight mt-0.5">
<template v-if="importStatus[model.id] === 'downloading'">Importing ...</template>
<template v-else-if="importStatus[model.id] === 'downloaded'">Imported</template>
<template v-else-if="importStatus[model.id] === 'using_library'">Using from Library</template>
</span>
</div>
<!-- Cancel (X) Button (Always visible in this card) -->
<div
class="relative z-10 size-6 flex items-center justify-center text-[#55565e] hover:text-white cursor-pointer transition-colors shrink-0"
@click="cancelImport(model.id)"
>
<i class="icon-[lucide--circle-x] size-4" />
</div>
</div>
</Transition>
<!-- IDLE / INPUT AREA -->
<Transition :css="false" @enter="enterTransition" @leave="leaveTransition">
<div v-if="!importStatus[model.id] || importStatus[model.id] === 'idle'" class="flex flex-col gap-2">
<!-- URL Input Area -->
<Transition :css="false" @enter="enterTransition" @leave="leaveTransition">
<div v-if="!selectedLibraryModel[model.id]" class="flex flex-col gap-2">
<div class="h-8 bg-[#262729] rounded-lg flex items-center px-3 border border-transparent focus-within:border-[#494a50] transition-colors whitespace-nowrap overflow-hidden">
<input
v-model="modelInputs[model.id]"
type="text"
placeholder="Paste Model URL (Civitai or Hugging Face)"
class="bg-transparent border-none outline-none text-xs text-white w-full placeholder:text-[#8a8a8a]"
/>
</div>
<Transition :css="false" @enter="enterTransition" @leave="leaveTransition">
<div v-if="revealingModels[model.id]" class="flex flex-col gap-2">
<div class="px-0.5 pt-0.5 whitespace-nowrap overflow-hidden text-ellipsis">
<span class="text-xs font-bold text-white">something_model.safetensors</span>
</div>
<div class="flex items-center gap-1.5 px-0.5">
<span class="text-[11px] text-[#8a8a8a]">What type of model is this?</span>
<i class="icon-[lucide--help-circle] size-3 text-[#55565e]" />
<span class="text-[11px] text-[#55565e]">Not sure? Just leave this as is</span>
</div>
<div class="relative">
<div class="h-9 bg-[#262729] rounded-lg flex items-center px-3 border border-transparent cursor-pointer hover:bg-[#303133]" @click="toggleDropdown(model.id)">
<span class="flex-1 text-xs text-[#8a8a8a]">{{ selectedModelTypes[model.id] || 'Select model type' }}</span>
<i class="icon-[lucide--chevron-down] size-4 text-[#8a8a8a]" />
</div>
<div v-if="activeDropdown === model.id" class="absolute top-full left-0 w-full mt-1 bg-[#26272b] border border-[#3f4045] rounded-lg shadow-xl z-50 overflow-hidden py-1">
<div v-for="type in MODEL_TYPES" :key="type" class="px-3 py-2 text-xs text-[#e2e2e4] hover:bg-[#323338] cursor-pointer" @click="selectType(model.id, type)">
{{ type }}
</div>
</div>
</div>
<div class="pt-0.5">
<Button variant="primary" class="w-full h-9 justify-center gap-2 text-sm font-semibold" @click="startImport(model.id)">
<i class="icon-[lucide--download] size-4" /> Import
</Button>
</div>
</div>
</Transition>
<!-- OR / Library Dropdown -->
<Transition :css="false" @enter="enterTransition" @leave="leaveTransition">
<div v-if="!modelInputs[model.id]" class="flex flex-col gap-2">
<div class="flex items-center justify-center py-0.5 font-bold text-[10px] text-[#8a8a8a]">OR</div>
<div class="relative">
<div
class="h-8 bg-[#262729] rounded-lg flex items-center px-3 cursor-pointer group/lib hover:border-[#494a50] border border-transparent"
@click="toggleLibraryDropdown(model.id)"
>
<span class="flex-1 text-xs text-white truncate">Use from Library</span>
<i class="icon-[lucide--chevron-down] size-3.5 text-[#8a8a8a] group-hover/lib:text-white" />
</div>
<!-- Library Dropdown Menu -->
<div v-if="activeLibraryDropdown === model.id" class="absolute top-full left-0 w-full mt-1 bg-[#26272b] border border-[#3f4045] rounded-lg shadow-xl z-50 overflow-hidden py-1">
<div
v-for="libModel in LIBRARY_MODELS"
:key="libModel"
class="px-3 py-2 text-xs text-[#e2e2e4] hover:bg-[#323338] cursor-pointer flex items-center gap-2"
@click="selectFromLibrary(model.id, libModel)"
>
<i class="icon-[lucide--file-code] size-3.5 text-[#8a8a8a]" />
{{ libModel }}
</div>
</div>
</div>
</div>
</Transition>
</div>
</Transition>
</div>
</Transition>
</div>
</div>
</TransitionGroup>
</div>
</Transition>
<!-- Bottom Divider -->
<div class="-mx-4 mt-6 h-px bg-[#55565e]" />
</div>
</template>
<!-- Reset Button (Convenience for Storybook testing) -->
<div v-if="Object.keys(removedModels).length > 0" class="flex justify-center py-8">
<Button variant="muted-textonly" class="text-xs gap-2 hover:text-white" @click="resetAll">
<i class="icon-[lucide--rotate-ccw] size-3.5" />
Reset Storybook Flow
</Button>
</div>
</div>
<div class="h-px bg-[#55565e] shrink-0 w-full" />
</div>
</template>
<style scoped>
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
</style>

View File

@@ -0,0 +1,472 @@
<script setup lang="ts">
import { ref, watch, computed } from 'vue'
import Button from '@/components/ui/button/Button.vue'
// Props / Emits
const emit = defineEmits<{
'locate': [name: string],
}>()
// Mock Data
interface MissingModel {
id: string
name: string
type: string
}
const INITIAL_MISSING_MODELS: Record<string, MissingModel[]> = {
'Lora': [
{ id: 'm1', name: 'Flat_color_anime.safetensors', type: 'Lora' },
{ id: 'm2', name: 'Bokeh_blur_xl.safetensors', type: 'Lora' },
{ id: 'm3', name: 'Skin_texture_realism.safetensors', type: 'Lora' }
],
'VAE': [
{ id: 'v1', name: 'vae-ft-mse-840000-ema-pruned.safetensors', type: 'VAE' },
{ id: 'v2', name: 'clear-vae-v1.safetensors', type: 'VAE' }
]
}
const LIBRARY_MODELS = [
'v1-5-pruned-emaonly.safetensors',
'sd_xl_base_1.0.safetensors',
'dreamshaper_8.safetensors',
'realisticVisionV51_v51VAE.safetensors'
]
// State
const collapsedCategories = ref<Record<string, boolean>>({
'VAE': true
})
// Stores the URL input for each model
const modelInputs = ref<Record<string, string>>({})
// Tracks which models have finished their "revealing" delay
const revealingModels = ref<Record<string, boolean>>({})
const inputTimeouts = ref<Record<string, ReturnType<typeof setTimeout>>>({})
// Model Status: 'idle' | 'downloading' | 'downloaded' | 'using_library'
const importStatus = ref<Record<string, 'idle' | 'downloading' | 'downloaded' | 'using_library'>>({})
const downloadProgress = ref<Record<string, number>>({})
const downloadTimers = ref<Record<string, ReturnType<typeof setInterval>>>({})
const selectedLibraryModel = ref<Record<string, string>>({})
// Track hidden models (removed after clicking check button)
const removedModels = ref<Record<string, boolean>>({})
// Watch for input changes to trigger the 1s delay
watch(modelInputs, (newVal) => {
for (const id in newVal) {
const value = newVal[id]
if (value && !revealingModels.value[id] && !inputTimeouts.value[id]) {
inputTimeouts.value[id] = setTimeout(() => {
revealingModels.value[id] = true
delete inputTimeouts.value[id]
}, 1000)
} else if (!value) {
revealingModels.value[id] = false
if (inputTimeouts.value[id]) {
clearTimeout(inputTimeouts.value[id])
delete inputTimeouts.value[id]
}
}
}
}, { deep: true })
// Compute which categories have at least one visible model
const activeCategories = computed(() => {
const result: Record<string, boolean> = {}
for (const cat in INITIAL_MISSING_MODELS) {
result[cat] = INITIAL_MISSING_MODELS[cat].some(m => !removedModels.value[m.id])
}
return result
})
// Tracks which model's library dropdown is currently open
const activeLibraryDropdown = ref<string | null>(null)
// Actions
function toggleLibraryDropdown(modelId: string) {
if (activeLibraryDropdown.value === modelId) {
activeLibraryDropdown.value = null
} else {
activeLibraryDropdown.value = modelId
}
}
function selectFromLibrary(modelId: string, fileName: string) {
selectedLibraryModel.value[modelId] = fileName
importStatus.value[modelId] = 'using_library'
activeLibraryDropdown.value = null
}
function startImport(modelId: string) {
if (downloadTimers.value[modelId]) {
clearInterval(downloadTimers.value[modelId])
}
importStatus.value[modelId] = 'downloading'
downloadProgress.value[modelId] = 0
const startTime = Date.now()
const duration = 5000
downloadTimers.value[modelId] = setInterval(() => {
const elapsed = Date.now() - startTime
const progress = Math.min((elapsed / duration) * 100, 100)
downloadProgress.value[modelId] = progress
if (progress >= 100) {
clearInterval(downloadTimers.value[modelId])
delete downloadTimers.value[modelId]
importStatus.value[modelId] = 'downloaded'
}
}, 50)
}
function handleCheckClick(modelId: string) {
if (importStatus.value[modelId] === 'downloaded' || importStatus.value[modelId] === 'using_library') {
// Update object with spread to guarantee reactivity trigger
removedModels.value = { ...removedModels.value, [modelId]: true }
}
}
function cancelImport(modelId: string) {
if (downloadTimers.value[modelId]) {
clearInterval(downloadTimers.value[modelId])
delete downloadTimers.value[modelId]
}
importStatus.value[modelId] = 'idle'
downloadProgress.value[modelId] = 0
modelInputs.value[modelId] = ''
selectedLibraryModel.value[modelId] = ''
}
function resetAll() {
// Clear all status records
modelInputs.value = {}
revealingModels.value = {}
// Clear any running timers
for (const id in downloadTimers.value) {
clearInterval(downloadTimers.value[id])
}
downloadTimers.value = {}
for (const id in inputTimeouts.value) {
clearTimeout(inputTimeouts.value[id])
}
inputTimeouts.value = {}
importStatus.value = {}
downloadProgress.value = {}
selectedLibraryModel.value = {}
removedModels.value = {}
activeLibraryDropdown.value = null
}
// Helpers
function getElementStyle(el: HTMLElement) {
return {
height: el.style.height,
overflow: el.style.overflow,
paddingTop: el.style.paddingTop,
paddingBottom: el.style.paddingBottom,
marginTop: el.style.marginTop,
marginBottom: el.style.marginBottom
}
}
// Transitions
const DURATION = 150
function enterTransition(element: Element, done: () => void) {
const el = element as HTMLElement
const init = getElementStyle(el)
const { width } = getComputedStyle(el)
el.style.width = width
el.style.position = 'absolute'
el.style.visibility = 'hidden'
el.style.height = ''
const { height } = getComputedStyle(el)
el.style.position = ''
el.style.visibility = ''
el.style.height = '0px'
el.style.overflow = 'hidden'
const anim = el.animate(
[{ height: '0px', opacity: 0 }, { height, opacity: 1 }],
{ duration: DURATION, easing: 'ease-in-out' }
)
el.style.height = init.height
anim.onfinish = () => { el.style.overflow = init.overflow; done() }
}
function leaveTransition(element: Element, done: () => void) {
const el = element as HTMLElement
const init = getElementStyle(el)
const { height } = getComputedStyle(el)
el.style.height = height
el.style.overflow = 'hidden'
const anim = el.animate(
[{ height, opacity: 1 }, { height: '0px', opacity: 0 }],
{ duration: DURATION, easing: 'ease-in-out' }
)
el.style.height = init.height
anim.onfinish = () => { el.style.overflow = init.overflow; done() }
}
</script>
<template>
<div
class="w-[320px] h-full shrink-0 flex flex-col gap-4 py-1 bg-[#171718] border-l border-[#494a50] shadow-[1px_1px_8px_0px_rgba(0,0,0,0.4)]"
>
<!-- Nav Item -->
<div class="flex h-12 items-center overflow-hidden py-2 border-b border-[#55565e] shrink-0">
<div class="flex flex-1 gap-2 items-center min-w-0 pl-4 pr-3">
<p class="flex-1 min-w-0 font-bold text-sm text-white whitespace-pre-wrap">
Workflow Overview
</p>
<div class="flex h-8 items-center justify-center overflow-hidden p-2 rounded-lg bg-[#262729] shrink-0 cursor-pointer hover:bg-[#303133]">
<i class="icon-[lucide--panel-right] size-4 text-white" />
</div>
</div>
</div>
<!-- Node Header -->
<div class="flex flex-col gap-3 items-start px-4 shrink-0">
<div class="flex gap-2 items-center w-full overflow-x-auto no-scrollbar">
<div class="flex gap-1 h-8 items-center justify-center overflow-hidden px-2 rounded-lg shrink-0 bg-[#262729]">
<span class="text-sm text-white">Error</span>
<div class="flex items-center justify-center size-6 shrink-0">
<i class="icon-[lucide--octagon-alert] size-4 text-[#e04e48]" />
</div>
</div>
<div class="flex h-8 items-center justify-center overflow-hidden px-2 rounded-lg shrink-0">
<span class="text-sm text-[#8a8a8a]">Inputs</span>
</div>
<div class="flex h-8 items-center justify-center overflow-hidden px-2 rounded-lg shrink-0">
<span class="text-sm text-[#8a8a8a]">Nodes</span>
</div>
<div class="flex h-8 items-center justify-center overflow-hidden px-2 rounded-lg shrink-0">
<span class="text-sm whitespace-nowrap text-[#8a8a8a]">Global settings</span>
</div>
</div>
<div class="flex gap-2 h-8 items-center min-h-[32px] px-2 py-1.5 rounded-lg bg-[#262729] w-full">
<i class="icon-[lucide--search] size-4 text-[#8a8a8a] shrink-0" />
<p class="flex-1 text-xs text-[#8a8a8a] truncate leading-normal">
Search for nodes or inputs
</p>
</div>
</div>
<div class="h-px bg-[#55565e] shrink-0 w-full" />
<!-- Content: Missing Models -->
<div class="flex-1 overflow-y-auto min-w-0 no-scrollbar">
<template v-for="(models, category) in INITIAL_MISSING_MODELS" :key="category">
<div
v-if="activeCategories[category]"
class="px-4 mb-4"
>
<!-- Category Header -->
<div
class="flex h-8 items-center justify-center w-full group"
:class="category === 'VAE' ? 'cursor-default' : 'cursor-pointer'"
@click="category !== 'VAE' && (collapsedCategories[category] = !collapsedCategories[category])"
>
<div class="flex items-center justify-center size-6 shrink-0">
<i class="icon-[lucide--octagon-alert] size-4 text-[#e04e48]" />
</div>
<p class="flex-1 min-w-0 text-sm text-[#e04e48] whitespace-pre-wrap font-medium">
{{ category }} ({{ models.filter(m => !removedModels[m.id]).length }})
</p>
<div class="flex h-8 items-center justify-center overflow-hidden p-2 rounded-lg shrink-0">
<i
class="icon-[lucide--chevron-up] size-4 text-[#8a8a8a] transition-all"
:class="[
category !== 'VAE' ? 'group-hover:text-white' : '',
collapsedCategories[category] ? '-rotate-180' : ''
]"
/>
</div>
</div>
<!-- Model List -->
<Transition :css="false" @enter="enterTransition" @leave="leaveTransition">
<div v-if="!collapsedCategories[category]" class="pt-2">
<TransitionGroup :css="false" @enter="enterTransition" @leave="leaveTransition">
<div v-for="model in models" v-show="!removedModels[model.id]" :key="model.id" class="flex flex-col w-full mb-6 last:mb-4">
<!-- Model Header (Always visible) -->
<div class="flex h-8 items-center w-full gap-2 mb-1">
<i class="icon-[lucide--file-check] size-4 text-white shrink-0" />
<p class="flex-1 min-w-0 text-sm font-medium text-white overflow-hidden text-ellipsis whitespace-nowrap">
{{ model.name }}
</p>
<!-- Check Button (Highlights blue when downloaded or using from library) -->
<div
class="flex h-8 items-center justify-center overflow-hidden p-2 rounded-lg shrink-0 transition-colors"
:class="[
(importStatus[model.id] === 'downloaded' || importStatus[model.id] === 'using_library')
? 'cursor-pointer hover:bg-[#1e2d3d] bg-[#1e2d3d]'
: 'opacity-20 cursor-default'
]"
@click="handleCheckClick(model.id)"
>
<i
class="icon-[lucide--check] size-4"
:class="(importStatus[model.id] === 'downloaded' || importStatus[model.id] === 'using_library') ? 'text-[#3b82f6]' : 'text-white'"
/>
</div>
<!-- Locate Button -->
<div
class="flex h-8 items-center justify-center overflow-hidden p-2 rounded-lg shrink-0 cursor-pointer hover:bg-[#262729]"
@click="emit('locate', model.name)"
>
<i class="icon-[lucide--locate] size-4 text-white" />
</div>
</div>
<!-- Input or Progress Area -->
<div class="relative mt-1">
<!-- CARD (Download or Library Substitute) -->
<Transition :css="false" @enter="enterTransition" @leave="leaveTransition">
<div
v-if="importStatus[model.id] && importStatus[model.id] !== 'idle'"
class="relative bg-white/5 border border-[#55565e] rounded-lg overflow-hidden flex items-center p-2 gap-2"
>
<!-- Progress Filling (Only while downloading) -->
<div
v-if="importStatus[model.id] === 'downloading'"
class="absolute inset-y-0 left-0 bg-[#3b82f6]/10 transition-all duration-100 ease-linear pointer-events-none"
:style="{ width: downloadProgress[model.id] + '%' }"
/>
<!-- Left Icon -->
<div class="relative z-10 size-[32px] flex items-center justify-center shrink-0">
<i class="icon-[lucide--file-check] size-5 text-[#8a8a8a]" />
</div>
<!-- Center Text -->
<div class="relative z-10 flex-1 min-w-0 flex flex-col justify-center">
<span class="text-[12px] font-medium text-white truncate leading-tight">
{{ importStatus[model.id] === 'using_library' ? selectedLibraryModel[model.id] : model.name }}
</span>
<span class="text-[12px] text-[#8a8a8a] leading-tight mt-0.5">
<template v-if="importStatus[model.id] === 'downloading'">Importing ...</template>
<template v-else-if="importStatus[model.id] === 'downloaded'">Imported</template>
<template v-else-if="importStatus[model.id] === 'using_library'">Using from Library</template>
</span>
</div>
<!-- Cancel (X) Button (Always visible in this card) -->
<div
class="relative z-10 size-6 flex items-center justify-center text-[#55565e] hover:text-white cursor-pointer transition-colors shrink-0"
@click="cancelImport(model.id)"
>
<i class="icon-[lucide--circle-x] size-4" />
</div>
</div>
</Transition>
<!-- IDLE / INPUT AREA -->
<Transition :css="false" @enter="enterTransition" @leave="leaveTransition">
<div v-if="!importStatus[model.id] || importStatus[model.id] === 'idle'" class="flex flex-col gap-2">
<!-- URL Input Area -->
<Transition :css="false" @enter="enterTransition" @leave="leaveTransition">
<div v-if="!selectedLibraryModel[model.id]" class="flex flex-col gap-2">
<div class="h-8 bg-[#262729] rounded-lg flex items-center px-3 border border-transparent focus-within:border-[#494a50] transition-colors whitespace-nowrap overflow-hidden">
<input
v-model="modelInputs[model.id]"
type="text"
placeholder="Paste Model URL (Civitai or Hugging Face)"
class="bg-transparent border-none outline-none text-xs text-white w-full placeholder:text-[#8a8a8a]"
/>
</div>
<Transition :css="false" @enter="enterTransition" @leave="leaveTransition">
<div v-if="revealingModels[model.id]" class="flex flex-col gap-2">
<div class="px-0.5 pt-0.5 whitespace-nowrap overflow-hidden text-ellipsis">
<span class="text-xs font-bold text-white">something_model.safetensors</span>
</div>
<div class="pt-0.5">
<Button variant="primary" class="w-full h-9 justify-center gap-2 text-sm font-semibold" @click="startImport(model.id)">
<i class="icon-[lucide--download] size-4" /> Import
</Button>
</div>
</div>
</Transition>
<!-- OR / Library Dropdown -->
<Transition :css="false" @enter="enterTransition" @leave="leaveTransition">
<div v-if="!modelInputs[model.id]" class="flex flex-col gap-2">
<div class="flex items-center justify-center py-0.5 font-bold text-[10px] text-[#8a8a8a]">OR</div>
<div class="relative">
<div
class="h-8 bg-[#262729] rounded-lg flex items-center px-3 cursor-pointer group/lib hover:border-[#494a50] border border-transparent"
@click="toggleLibraryDropdown(model.id)"
>
<span class="flex-1 text-xs text-white truncate">Use from Library</span>
<i class="icon-[lucide--chevron-down] size-3.5 text-[#8a8a8a] group-hover/lib:text-white" />
</div>
<!-- Library Dropdown Menu -->
<div v-if="activeLibraryDropdown === model.id" class="absolute top-full left-0 w-full mt-1 bg-[#26272b] border border-[#3f4045] rounded-lg shadow-xl z-50 overflow-hidden py-1">
<div
v-for="libModel in LIBRARY_MODELS"
:key="libModel"
class="px-3 py-2 text-xs text-[#e2e2e4] hover:bg-[#323338] cursor-pointer flex items-center gap-2"
@click="selectFromLibrary(model.id, libModel)"
>
<i class="icon-[lucide--file-code] size-3.5 text-[#8a8a8a]" />
{{ libModel }}
</div>
</div>
</div>
</div>
</Transition>
</div>
</Transition>
</div>
</Transition>
</div>
</div>
</TransitionGroup>
</div>
</Transition>
<!-- Bottom Divider -->
<div class="-mx-4 mt-6 h-px bg-[#55565e]" />
</div>
</template>
<!-- Reset Button (Convenience for Storybook testing) -->
<div v-if="Object.keys(removedModels).length > 0" class="flex justify-center py-8">
<Button variant="muted-textonly" class="text-xs gap-2 hover:text-white" @click="resetAll">
<i class="icon-[lucide--rotate-ccw] size-3.5" />
Reset Storybook Flow
</Button>
</div>
</div>
<div class="h-px bg-[#55565e] shrink-0 w-full" />
</div>
</template>
<style scoped>
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
</style>

View File

@@ -0,0 +1,206 @@
<script setup lang="ts">
import { ref } from 'vue'
import type { MissingNodePack } from './MockManagerDialog.vue'
// Props / Emits
const emit = defineEmits<{
'locate': [pack: MissingNodePack]
}>()
// Mock Data
const MOCK_MISSING_PACKS: MissingNodePack[] = [
{
id: 'pack-1',
displayName: 'MeshGraphormerDepthMapPreprocessor_for_SEGS //Inspire',
packId: 'comfyui-inspire-pack',
description: 'Inspire Pack provides various creative and utility nodes for ComfyUI workflows.'
},
{
id: 'pack-2',
displayName: 'TilePreprocessor_Provider_for_SEGS',
packId: 'comfyui-controlnet-aux',
description: 'Auxiliary preprocessors for ControlNet including tile, depth, and pose processors.'
},
{
id: 'pack-3',
displayName: 'WD14Tagger | pysssss',
packId: 'comfyui-wdv14-tagger',
description: 'Automatic image tagging using WD14 model from pysssss.'
},
{
id: 'pack-4',
displayName: 'CR Simple Image Compare',
packId: 'comfyui-crystools',
description: 'Crystal Tools suite including image comparison and utility nodes.'
},
{
id: 'pack-5',
displayName: 'FaceDetailer | impact',
packId: 'comfyui-impact-pack',
description: 'Impact Pack provides face detailing, masking, and segmentation utilities.'
}
]
// State
const isSectionCollapsed = ref(false)
// Helpers
function getElementStyle(el: HTMLElement) {
return {
height: el.style.height,
overflow: el.style.overflow,
paddingTop: el.style.paddingTop,
paddingBottom: el.style.paddingBottom,
marginTop: el.style.marginTop,
marginBottom: el.style.marginBottom
}
}
// Transitions
const DURATION = 150
function enterTransition(element: Element, done: () => void) {
const el = element as HTMLElement
const init = getElementStyle(el)
const { width } = getComputedStyle(el)
el.style.width = width
el.style.position = 'absolute'
el.style.visibility = 'hidden'
el.style.height = ''
const { height } = getComputedStyle(el)
el.style.position = ''
el.style.visibility = ''
el.style.height = '0px'
el.style.overflow = 'hidden'
const anim = el.animate(
[{ height: '0px', opacity: 0 }, { height, opacity: 1 }],
{ duration: DURATION, easing: 'ease-in-out' }
)
el.style.height = init.height
anim.onfinish = () => { el.style.overflow = init.overflow; done() }
}
function leaveTransition(element: Element, done: () => void) {
const el = element as HTMLElement
const init = getElementStyle(el)
const { height } = getComputedStyle(el)
el.style.height = height
el.style.overflow = 'hidden'
const anim = el.animate(
[{ height, opacity: 1 }, { height: '0px', opacity: 0 }],
{ duration: DURATION, easing: 'ease-in-out' }
)
el.style.height = init.height
anim.onfinish = () => { el.style.overflow = init.overflow; done() }
}
</script>
<template>
<div
class="w-[320px] h-full shrink-0 flex flex-col gap-4 py-1 bg-[#171718] border-l border-[#494a50] shadow-[1px_1px_8px_0px_rgba(0,0,0,0.4)]"
>
<!-- Nav Item: "Workflow Overview" + panel-right button -->
<div class="flex h-12 items-center overflow-hidden py-2 border-b border-[#55565e] shrink-0">
<div class="flex flex-1 gap-2 items-center min-w-0 pl-4 pr-3">
<p class="flex-1 min-w-0 font-bold text-sm text-white whitespace-pre-wrap">
Workflow Overview
</p>
<div class="flex h-8 items-center justify-center overflow-hidden p-2 rounded-lg bg-[#262729] shrink-0 cursor-pointer hover:bg-[#303133]">
<i class="icon-[lucide--panel-right] size-4 text-white" />
</div>
</div>
</div>
<!-- Node Header: tab bar + search -->
<div class="flex flex-col gap-3 items-start px-4 shrink-0">
<!-- Tab bar -->
<div class="flex gap-2 items-center w-full overflow-x-auto no-scrollbar">
<!-- "Error" tab (active) -->
<div class="flex gap-1 h-8 items-center justify-center overflow-hidden px-2 rounded-lg shrink-0 bg-[#262729]">
<span class="text-sm text-white">Error</span>
<div class="flex items-center justify-center size-6 shrink-0">
<i class="icon-[lucide--octagon-alert] size-4 text-[#e04e48]" />
</div>
</div>
<!-- Other tabs -->
<div class="flex h-8 items-center justify-center overflow-hidden px-2 rounded-lg shrink-0">
<span class="text-sm text-[#8a8a8a]">Inputs</span>
</div>
<div class="flex h-8 items-center justify-center overflow-hidden px-2 rounded-lg shrink-0">
<span class="text-sm text-[#8a8a8a]">Nodes</span>
</div>
<div class="flex h-8 items-center justify-center overflow-hidden px-2 rounded-lg shrink-0">
<span class="text-sm whitespace-nowrap text-[#8a8a8a]">Global settings</span>
</div>
</div>
<!-- Search bar -->
<div class="flex gap-2 h-8 items-center min-h-[32px] px-2 py-1.5 rounded-lg bg-[#262729] w-full">
<i class="icon-[lucide--search] size-4 text-[#8a8a8a] shrink-0" />
<p class="flex-1 text-xs text-[#8a8a8a] truncate leading-normal">
Search for nodes or inputs
</p>
</div>
</div>
<div class="h-px bg-[#55565e] shrink-0 w-full" />
<!-- Content: Nodes (Cloud Version) -->
<div class="flex-1 overflow-y-auto min-w-0">
<div class="px-4">
<!-- Section Header: Unsupported Node Packs -->
<div class="flex h-8 items-center justify-center w-full">
<div class="flex items-center justify-center size-6 shrink-0">
<i class="icon-[lucide--octagon-alert] size-4 text-[#e04e48]" />
</div>
<p class="flex-1 min-w-0 text-sm text-[#e04e48] whitespace-pre-wrap font-medium">Unsupported Node Packs</p>
<div
class="group flex h-8 items-center justify-center overflow-hidden p-2 rounded-lg shrink-0 cursor-pointer"
@click="isSectionCollapsed = !isSectionCollapsed"
>
<i
class="icon-[lucide--chevron-up] size-4 text-[#8a8a8a] group-hover:text-white transition-all"
:class="isSectionCollapsed ? '-rotate-180' : ''"
/>
</div>
</div>
<!-- Cloud Warning Text -->
<div v-if="!isSectionCollapsed" class="mt-3 mb-5">
<p class="m-0 text-sm text-[#8a8a8a] leading-relaxed">
This workflow requires custom nodes not yet available on Comfy Cloud.
</p>
</div>
<div class="-mx-4 border-b border-[#55565e]">
<Transition :css="false" @enter="enterTransition" @leave="leaveTransition">
<div v-if="!isSectionCollapsed" class="px-4 pb-2">
<div v-for="pack in MOCK_MISSING_PACKS" :key="pack.id" class="flex flex-col w-full group/card mb-1">
<!-- Widget Header -->
<div class="flex h-8 items-center w-full">
<p class="flex-1 min-w-0 text-sm text-white overflow-hidden text-ellipsis whitespace-nowrap">
{{ pack.displayName }}
</p>
<div
class="flex h-8 items-center justify-center overflow-hidden p-2 rounded-lg shrink-0 cursor-pointer hover:bg-[#262729]"
@click="emit('locate', pack)"
>
<i class="icon-[lucide--locate] size-4 text-white" />
</div>
</div>
<!-- No Install button in Cloud version -->
</div>
</div>
</Transition>
</div>
</div>
</div>
<div class="h-px bg-[#55565e] shrink-0 w-full" />
</div>
</template>

View File

@@ -0,0 +1,288 @@
<script setup lang="ts">
import Button from '@/components/ui/button/Button.vue'
// Story-only type (Separated from actual production types)
export interface MissingNodePack {
id: string
displayName: string
packId: string
description: string
}
// Props / Emits
const props = withDefaults(
defineProps<{
selectedPack?: MissingNodePack | null
}>(),
{ selectedPack: null }
)
const emit = defineEmits<{ close: [] }>()
</script>
<template>
<!--
BaseModalLayout structure reproduction:
- Outer: rounded-2xl overflow-hidden
- Grid: 14rem(left) 1fr(content) 18rem(right)
- Left/Right panel bg: modal-panel-background = charcoal-600 = #262729
- Main bg: base-background = charcoal-800 = #171718
- Header height: h-18 (4.5rem / 72px)
- Border: charcoal-200 = #494a50
- NavItem selected: charcoal-300 = #3c3d42
- NavItem hovered: charcoal-400 = #313235
-->
<div
class="w-full h-full rounded-2xl overflow-hidden shadow-[1px_1px_8px_0px_rgba(0,0,0,0.4)]"
style="display:grid; grid-template-columns: 14rem 1fr 18rem;"
>
<!-- Left Panel: bg = modal-panel-background = #262729 -->
<nav class="h-full overflow-hidden flex flex-col" style="background:#262729;">
<!-- Header: h-18 = 72px -->
<header class="flex w-full shrink-0 gap-2 pl-6 pr-3 items-center" style="height:4.5rem;">
<i class="icon-[comfy--extensions-blocks] text-white" />
<h2 class="text-white text-base font-semibold m-0">Nodes Manager</h2>
</header>
<!-- NavItems: px-3 gap-1 flex-col -->
<div class="flex flex-col gap-1 px-3 pb-3 overflow-y-auto">
<!-- All Extensions -->
<div class="flex cursor-pointer select-none items-center gap-2 rounded-md px-4 py-3 text-sm text-white transition-colors hover:bg-[#313235]">
<i class="icon-[lucide--list] size-4 text-white/70 shrink-0" />
<span class="min-w-0 truncate">All Extensions</span>
</div>
<!-- Not Installed -->
<div class="flex cursor-pointer select-none items-center gap-2 rounded-md px-4 py-3 text-sm text-white transition-colors hover:bg-[#313235]">
<i class="icon-[lucide--globe] size-4 text-white/70 shrink-0" />
<span class="min-w-0 truncate">Not Installed</span>
</div>
<!-- Installed Group -->
<div class="flex flex-col gap-1 mt-2">
<p class="px-4 py-1 text-[10px] text-white/40 uppercase tracking-wider font-medium m-0">Installed</p>
<div class="flex cursor-pointer select-none items-center gap-2 rounded-md px-4 py-3 text-sm text-white transition-colors hover:bg-[#313235]">
<i class="icon-[lucide--download] size-4 text-white/70 shrink-0" />
<span class="min-w-0 truncate">All Installed</span>
</div>
<div class="flex cursor-pointer select-none items-center gap-2 rounded-md px-4 py-3 text-sm text-white transition-colors hover:bg-[#313235]">
<i class="icon-[lucide--refresh-cw] size-4 text-white/70 shrink-0" />
<span class="min-w-0 truncate">Updates Available</span>
</div>
<div class="flex cursor-pointer select-none items-center gap-2 rounded-md px-4 py-3 text-sm text-white transition-colors hover:bg-[#313235]">
<i class="icon-[lucide--triangle-alert] size-4 text-white/70 shrink-0" />
<span class="min-w-0 truncate">Conflicting</span>
</div>
</div>
<!-- In Workflow Group -->
<div class="flex flex-col gap-1 mt-2">
<p class="px-4 py-1 text-[10px] text-white/40 uppercase tracking-wider font-medium m-0">In Workflow</p>
<div class="flex cursor-pointer select-none items-center gap-2 rounded-md px-4 py-3 text-sm text-white transition-colors hover:bg-[#313235]">
<i class="icon-[lucide--share-2] size-4 text-white/70 shrink-0" />
<span class="min-w-0 truncate">In Workflow</span>
</div>
<!-- Missing Nodes: active selection = charcoal-300 = #3c3d42 -->
<div class="flex cursor-pointer select-none items-center gap-2 rounded-md px-4 py-3 text-sm text-white transition-colors bg-[#3c3d42]">
<i class="icon-[lucide--triangle-alert] size-4 text-[#fd9903] shrink-0" />
<span class="min-w-0 truncate">Missing Nodes</span>
</div>
</div>
</div>
</nav>
<!-- Main Content: bg = base-background = #171718 -->
<div class="flex flex-col overflow-hidden" style="background:#171718;">
<!-- Header row 1: Node Pack dropdown | Search | Install All -->
<header class="w-full px-6 flex items-center gap-3 shrink-0" style="height:4.5rem;">
<!-- Node Pack Dropdown -->
<div class="flex items-center gap-2 h-10 px-3 rounded-lg shrink-0 cursor-pointer text-sm text-white border border-[#494a50] hover:border-[#55565e]" style="background:#262729;">
<span>Node Pack</span>
<i class="icon-[lucide--chevron-down] size-4 text-[#8a8a8a]" />
</div>
<!-- Search bar (flex-1) -->
<div class="flex items-center h-10 rounded-lg px-4 gap-2 flex-1" style="background:#262729;">
<i class="pi pi-search text-xs text-[#8a8a8a] shrink-0" />
<span class="text-sm text-[#8a8a8a]">Search</span>
</div>
<!-- Install All Button (blue, right side) -->
<Button variant="primary" size="sm" class="shrink-0 gap-2 px-4 text-sm h-10 font-semibold rounded-xl">
<i class="icon-[lucide--download] size-4" />
Install All
</Button>
</header>
<!-- Header row 2: Downloads Sort Dropdown (right aligned) -->
<div class="flex justify-end px-6 py-3 shrink-0">
<div class="flex items-center h-10 gap-2 px-4 rounded-xl cursor-pointer text-sm text-white border border-[#494a50] hover:border-[#55565e]" style="background:#262729;">
<i class="icon-[lucide--arrow-up-down] size-4 text-[#8a8a8a]" />
<span>Downloads</span>
<i class="icon-[lucide--chevron-down] size-4 text-[#8a8a8a]" />
</div>
</div>
<!-- Pack Grid Content -->
<div class="flex-1 min-h-0 overflow-y-auto px-6 py-4">
<div class="grid gap-4" style="grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr));">
<div
v-for="(_, i) in 9"
:key="i"
:class="[
'rounded-xl border p-4 flex flex-col gap-3 cursor-pointer transition-colors',
i === 0 && selectedPack
? 'border-[#0b8ce9]/70 ring-1 ring-[#0b8ce9]/40'
: 'border-[#494a50] hover:border-[#55565e]'
]"
:style="i === 0 && selectedPack ? 'background:#172d3a;' : 'background:#262729;'"
>
<!-- Card Image Area -->
<div class="w-full h-20 rounded-lg flex items-center justify-center" style="background:#313235;">
<i class="icon-[lucide--package] size-6 text-[#8a8a8a]" />
</div>
<!-- Card Text Content -->
<div>
<p class="m-0 text-xs font-semibold text-white truncate">
{{ i === 0 && selectedPack ? selectedPack.packId : 'node-pack-' + (i + 1) }}
</p>
<p class="m-0 text-[11px] text-[#8a8a8a] truncate mt-0.5">by publisher</p>
</div>
</div>
</div>
</div>
</div>
<!-- Right Info Panel -->
<aside class="h-full flex flex-col overflow-hidden" style="background:#1c1d1f; border-left: 1px solid #494a50;">
<!-- Header: h-18 - Title + Panel icons + X close -->
<header class="flex h-[4.5rem] shrink-0 items-center px-5 gap-3 border-b border-[#494a50]">
<h2 class="flex-1 select-none text-base font-bold text-white m-0">Node Pack Info</h2>
<!-- Panel Collapse Icon -->
<button
class="flex items-center justify-center text-[#8a8a8a] hover:text-white p-1"
style="background:none;border:none;outline:none;cursor:pointer;"
>
<i class="icon-[lucide--panel-right] size-4" />
</button>
<!-- Close X Icon -->
<button
class="flex items-center justify-center text-[#8a8a8a] hover:text-white p-1"
style="background:none;border:none;outline:none;cursor:pointer;"
@click="emit('close')"
>
<i class="icon-[lucide--x] size-4" />
</button>
</header>
<!-- Panel Content Area -->
<div class="flex-1 min-h-0 overflow-y-auto">
<div v-if="props.selectedPack" class="flex flex-col divide-y divide-[#2e2f31]">
<!-- ACTIONS SECTION -->
<div class="flex flex-col gap-3 px-5 py-4">
<div class="flex items-center justify-between">
<span class="text-[11px] font-bold text-[#8a8a8a] uppercase tracking-widest">Actions</span>
<i class="icon-[lucide--chevron-up] size-4 text-[#8a8a8a]" />
</div>
<Button variant="primary" class="w-full justify-center gap-2 h-10 font-semibold rounded-xl">
<i class="icon-[lucide--download] size-4" />
Install
</Button>
</div>
<!-- BASIC INFO SECTION -->
<div class="flex flex-col gap-4 px-5 py-4">
<div class="flex items-center justify-between">
<span class="text-[11px] font-bold text-[#8a8a8a] uppercase tracking-widest">Basic Info</span>
<i class="icon-[lucide--chevron-up] size-4 text-[#8a8a8a]" />
</div>
<!-- Name -->
<div class="flex flex-col gap-0.5">
<span class="text-sm font-medium text-white">Name</span>
<span class="text-sm text-[#8a8a8a]">{{ props.selectedPack.packId }}</span>
</div>
<!-- Created By -->
<div class="flex flex-col gap-0.5">
<span class="text-sm font-medium text-white">Created By</span>
<span class="text-sm text-[#8a8a8a]">publisher</span>
</div>
<!-- Downloads -->
<div class="flex flex-col gap-0.5">
<span class="text-sm font-medium text-white">Downloads</span>
<span class="text-sm text-[#8a8a8a]">539,373</span>
</div>
<!-- Last Updated -->
<div class="flex flex-col gap-0.5">
<span class="text-sm font-medium text-white">Last Updated</span>
<span class="text-sm text-[#8a8a8a]">Jan 21, 2026</span>
</div>
<!-- Status -->
<div class="flex flex-col gap-1">
<span class="text-sm font-medium text-white">Status</span>
<span
class="inline-flex items-center gap-1.5 w-fit px-2.5 py-1 rounded-full text-xs text-white border border-[#494a50]"
style="background:#262729;"
>
<span class="size-2 rounded-full bg-[#8a8a8a] shrink-0" />
Unknown
</span>
</div>
<!-- Version -->
<div class="flex flex-col gap-1">
<span class="text-sm font-medium text-white">Version</span>
<span
class="inline-flex items-center gap-1 w-fit px-2.5 py-1 rounded-full text-xs text-white border border-[#494a50]"
style="background:#262729;"
>
1.8.0
<i class="icon-[lucide--chevron-right] size-3 text-[#8a8a8a]" />
</span>
</div>
</div>
<!-- DESCRIPTION SECTION -->
<div class="flex flex-col gap-4 px-5 py-4">
<div class="flex items-center justify-between">
<span class="text-[11px] font-bold text-[#8a8a8a] uppercase tracking-widest">Description</span>
<i class="icon-[lucide--chevron-up] size-4 text-[#8a8a8a]" />
</div>
<!-- Description -->
<div class="flex flex-col gap-0.5">
<span class="text-sm font-medium text-white">Description</span>
<p class="m-0 text-sm text-[#8a8a8a] leading-relaxed">{{ props.selectedPack.description }}</p>
</div>
<!-- Repository -->
<div class="flex flex-col gap-0.5">
<span class="text-sm font-medium text-white">Repository</span>
<div class="flex items-start gap-2">
<i class="icon-[lucide--github] size-4 text-[#8a8a8a] shrink-0 mt-0.5" />
<span class="text-sm text-[#8a8a8a] break-all flex-1">https://github.com/aria1th/{{ props.selectedPack.packId }}</span>
<i class="icon-[lucide--external-link] size-4 text-[#8a8a8a] shrink-0 mt-0.5" />
</div>
</div>
<!-- License -->
<div class="flex flex-col gap-0.5">
<span class="text-sm font-medium text-white">License</span>
<span class="text-sm text-[#8a8a8a]">MIT</span>
</div>
</div>
<!-- NODES SECTION -->
<div class="flex flex-col gap-3 px-5 py-4">
<div class="flex items-center justify-between">
<span class="text-[11px] font-bold text-[#8a8a8a] uppercase tracking-widest">Nodes</span>
<i class="icon-[lucide--chevron-up] size-4 text-[#8a8a8a]" />
</div>
</div>
</div>
<!-- No Selection State -->
<div v-else class="flex flex-col items-center justify-center h-full gap-3 px-6 opacity-40">
<i class="icon-[lucide--package] size-8 text-white" />
<p class="m-0 text-sm text-white text-center">Select a pack to view details</p>
</div>
</div>
</aside>
</div>
</template>

View File

@@ -0,0 +1,405 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import Button from '@/components/ui/button/Button.vue'
// Props / Emits
const emit = defineEmits<{
'locate': [name: string],
}>()
// Mock Data
interface MissingModel {
id: string
name: string
type: string
}
const INITIAL_MISSING_MODELS: Record<string, MissingModel[]> = {
'Lora': [
{ id: 'm1', name: 'Flat_color_anime.safetensors', type: 'Lora' },
{ id: 'm2', name: 'Bokeh_blur_xl.safetensors', type: 'Lora' },
{ id: 'm3', name: 'Skin_texture_realism.safetensors', type: 'Lora' }
],
'VAE': [
{ id: 'v1', name: 'vae-ft-mse-840000-ema-pruned.safetensors', type: 'VAE' },
{ id: 'v2', name: 'clear-vae-v1.safetensors', type: 'VAE' }
]
}
const LIBRARY_MODELS = [
'v1-5-pruned-emaonly.safetensors',
'sd_xl_base_1.0.safetensors',
'dreamshaper_8.safetensors',
'realisticVisionV51_v51VAE.safetensors'
]
// State
const collapsedCategories = ref<Record<string, boolean>>({
'VAE': true
})
// Model Status: 'idle' | 'downloading' | 'downloaded' | 'using_library'
const importStatus = ref<Record<string, 'idle' | 'downloading' | 'downloaded' | 'using_library'>>({})
const downloadProgress = ref<Record<string, number>>({})
const downloadTimers = ref<Record<string, ReturnType<typeof setInterval>>>({})
const selectedLibraryModel = ref<Record<string, string>>({})
// Track hidden models (removed after clicking check button)
const removedModels = ref<Record<string, boolean>>({})
// Compute which categories have at least one visible model
const activeCategories = computed(() => {
const result: Record<string, boolean> = {}
for (const cat in INITIAL_MISSING_MODELS) {
result[cat] = INITIAL_MISSING_MODELS[cat].some(m => !removedModels.value[m.id])
}
return result
})
// Tracks which model's library dropdown is currently open
const activeLibraryDropdown = ref<string | null>(null)
// Actions
function toggleLibraryDropdown(modelId: string) {
if (activeLibraryDropdown.value === modelId) {
activeLibraryDropdown.value = null
} else {
activeLibraryDropdown.value = modelId
}
}
function selectFromLibrary(modelId: string, fileName: string) {
selectedLibraryModel.value[modelId] = fileName
importStatus.value[modelId] = 'using_library'
activeLibraryDropdown.value = null
}
function startUpload(modelId: string) {
if (downloadTimers.value[modelId]) {
clearInterval(downloadTimers.value[modelId])
}
importStatus.value[modelId] = 'downloading'
downloadProgress.value[modelId] = 0
const startTime = Date.now()
const duration = 3000 // Speed up for OSS simulation
downloadTimers.value[modelId] = setInterval(() => {
const elapsed = Date.now() - startTime
const progress = Math.min((elapsed / duration) * 100, 100)
downloadProgress.value[modelId] = progress
if (progress >= 100) {
clearInterval(downloadTimers.value[modelId])
delete downloadTimers.value[modelId]
importStatus.value[modelId] = 'downloaded'
}
}, 50)
}
function handleCheckClick(modelId: string) {
if (importStatus.value[modelId] === 'downloaded' || importStatus.value[modelId] === 'using_library') {
removedModels.value = { ...removedModels.value, [modelId]: true }
}
}
function cancelImport(modelId: string) {
if (downloadTimers.value[modelId]) {
clearInterval(downloadTimers.value[modelId])
delete downloadTimers.value[modelId]
}
importStatus.value[modelId] = 'idle'
downloadProgress.value[modelId] = 0
selectedLibraryModel.value[modelId] = ''
}
function resetAll() {
for (const id in downloadTimers.value) {
clearInterval(downloadTimers.value[id])
}
downloadTimers.value = {}
importStatus.value = {}
downloadProgress.value = {}
selectedLibraryModel.value = {}
removedModels.value = {}
activeLibraryDropdown.value = null
}
// Helpers
function getElementStyle(el: HTMLElement) {
return {
height: el.style.height,
overflow: el.style.overflow,
paddingTop: el.style.paddingTop,
paddingBottom: el.style.paddingBottom,
marginTop: el.style.marginTop,
marginBottom: el.style.marginBottom
}
}
// Transitions
const DURATION = 150
function enterTransition(element: Element, done: () => void) {
const el = element as HTMLElement
const init = getElementStyle(el)
const { width } = getComputedStyle(el)
el.style.width = width
el.style.position = 'absolute'
el.style.visibility = 'hidden'
el.style.height = ''
const { height } = getComputedStyle(el)
el.style.position = ''
el.style.visibility = ''
el.style.height = '0px'
el.style.overflow = 'hidden'
const anim = el.animate(
[{ height: '0px', opacity: 0 }, { height, opacity: 1 }],
{ duration: DURATION, easing: 'ease-in-out' }
)
el.style.height = init.height
anim.onfinish = () => { el.style.overflow = init.overflow; done() }
}
function leaveTransition(element: Element, done: () => void) {
const el = element as HTMLElement
const init = getElementStyle(el)
const { height } = getComputedStyle(el)
el.style.height = height
el.style.overflow = 'hidden'
const anim = el.animate(
[{ height, opacity: 1 }, { height: '0px', opacity: 0 }],
{ duration: DURATION, easing: 'ease-in-out' }
)
el.style.height = init.height
anim.onfinish = () => { el.style.overflow = init.overflow; done() }
}
</script>
<template>
<div
class="w-[320px] h-full shrink-0 flex flex-col gap-4 py-1 bg-[#171718] border-l border-[#494a50] shadow-[1px_1px_8px_0px_rgba(0,0,0,0.4)]"
>
<!-- Nav Item -->
<div class="flex h-12 items-center overflow-hidden py-2 border-b border-[#55565e] shrink-0">
<div class="flex flex-1 gap-2 items-center min-w-0 pl-4 pr-3">
<p class="flex-1 min-w-0 font-bold text-sm text-white whitespace-pre-wrap">
Workflow Overview
</p>
<div class="flex h-8 items-center justify-center overflow-hidden p-2 rounded-lg bg-[#262729] shrink-0 cursor-pointer hover:bg-[#303133]">
<i class="icon-[lucide--panel-right] size-4 text-white" />
</div>
</div>
</div>
<!-- Node Header -->
<div class="flex flex-col gap-3 items-start px-4 shrink-0">
<div class="flex gap-2 items-center w-full overflow-x-auto no-scrollbar">
<div class="flex gap-1 h-8 items-center justify-center overflow-hidden px-2 rounded-lg shrink-0 bg-[#262729]">
<span class="text-sm text-white">Error</span>
<div class="flex items-center justify-center size-6 shrink-0">
<i class="icon-[lucide--octagon-alert] size-4 text-[#e04e48]" />
</div>
</div>
<div class="flex h-8 items-center justify-center overflow-hidden px-2 rounded-lg shrink-0">
<span class="text-sm text-[#8a8a8a]">Inputs</span>
</div>
<div class="flex h-8 items-center justify-center overflow-hidden px-2 rounded-lg shrink-0">
<span class="text-sm text-[#8a8a8a]">Nodes</span>
</div>
<div class="flex h-8 items-center justify-center overflow-hidden px-2 rounded-lg shrink-0">
<span class="text-sm whitespace-nowrap text-[#8a8a8a]">Global settings</span>
</div>
</div>
<div class="flex gap-2 h-8 items-center min-h-[32px] px-2 py-1.5 rounded-lg bg-[#262729] w-full">
<i class="icon-[lucide--search] size-4 text-[#8a8a8a] shrink-0" />
<p class="flex-1 text-xs text-[#8a8a8a] truncate leading-normal">
Search for nodes or inputs
</p>
</div>
</div>
<div class="h-px bg-[#55565e] shrink-0 w-full" />
<!-- Content: Missing Models -->
<div class="flex-1 overflow-y-auto min-w-0 no-scrollbar">
<template v-for="(models, category) in INITIAL_MISSING_MODELS" :key="category">
<div
v-if="activeCategories[category]"
class="px-4 mb-4"
>
<!-- Category Header -->
<div
class="flex h-8 items-center justify-center w-full group"
:class="category === 'VAE' ? 'cursor-default' : 'cursor-pointer'"
@click="category !== 'VAE' && (collapsedCategories[category] = !collapsedCategories[category])"
>
<div class="flex items-center justify-center size-6 shrink-0">
<i class="icon-[lucide--octagon-alert] size-4 text-[#e04e48]" />
</div>
<p class="flex-1 min-w-0 text-sm text-[#e04e48] whitespace-pre-wrap font-medium">
{{ category }} ({{ models.filter(m => !removedModels[m.id]).length }})
</p>
<div class="flex h-8 items-center justify-center overflow-hidden p-2 rounded-lg shrink-0">
<i
class="icon-[lucide--chevron-up] size-4 text-[#8a8a8a] transition-all"
:class="[
category !== 'VAE' ? 'group-hover:text-white' : '',
collapsedCategories[category] ? '-rotate-180' : ''
]"
/>
</div>
</div>
<!-- Model List -->
<Transition :css="false" @enter="enterTransition" @leave="leaveTransition">
<div v-if="!collapsedCategories[category]" class="pt-2">
<TransitionGroup :css="false" @enter="enterTransition" @leave="leaveTransition">
<div v-for="model in models" v-show="!removedModels[model.id]" :key="model.id" class="flex flex-col w-full mb-6 last:mb-4">
<!-- Model Header (Always visible) -->
<div class="flex h-8 items-center w-full gap-2 mb-1">
<i class="icon-[lucide--file-check] size-4 text-white shrink-0" />
<p class="flex-1 min-w-0 text-sm font-medium text-white overflow-hidden text-ellipsis whitespace-nowrap">
{{ model.name }}
</p>
<div
class="flex h-8 items-center justify-center overflow-hidden p-2 rounded-lg shrink-0 transition-colors"
:class="[
(importStatus[model.id] === 'downloaded' || importStatus[model.id] === 'using_library')
? 'cursor-pointer hover:bg-[#1e2d3d] bg-[#1e2d3d]'
: 'opacity-20 cursor-default'
]"
@click="handleCheckClick(model.id)"
>
<i
class="icon-[lucide--check] size-4"
:class="(importStatus[model.id] === 'downloaded' || importStatus[model.id] === 'using_library') ? 'text-[#3b82f6]' : 'text-white'"
/>
</div>
<div
class="flex h-8 items-center justify-center overflow-hidden p-2 rounded-lg shrink-0 cursor-pointer hover:bg-[#262729]"
@click="emit('locate', model.name)"
>
<i class="icon-[lucide--locate] size-4 text-white" />
</div>
</div>
<!-- Input or Progress Area -->
<div class="relative mt-1">
<Transition :css="false" @enter="enterTransition" @leave="leaveTransition">
<div
v-if="importStatus[model.id] && importStatus[model.id] !== 'idle'"
class="relative bg-white/5 border border-[#55565e] rounded-lg overflow-hidden flex items-center p-2 gap-2"
>
<div
v-if="importStatus[model.id] === 'downloading'"
class="absolute inset-y-0 left-0 bg-[#3b82f6]/10 transition-all duration-100 ease-linear pointer-events-none"
:style="{ width: downloadProgress[model.id] + '%' }"
/>
<div class="relative z-10 size-[32px] flex items-center justify-center shrink-0">
<i class="icon-[lucide--file-check] size-5 text-[#8a8a8a]" />
</div>
<div class="relative z-10 flex-1 min-w-0 flex flex-col justify-center">
<span class="text-[12px] font-medium text-white truncate leading-tight">
{{ importStatus[model.id] === 'using_library' ? selectedLibraryModel[model.id] : model.name }}
</span>
<span class="text-[12px] text-[#8a8a8a] leading-tight mt-0.5">
<template v-if="importStatus[model.id] === 'downloading'">Uploading ...</template>
<template v-else-if="importStatus[model.id] === 'downloaded'">Uploaded</template>
<template v-else-if="importStatus[model.id] === 'using_library'">Using from Library</template>
</span>
</div>
<div
class="relative z-10 size-6 flex items-center justify-center text-[#55565e] hover:text-white cursor-pointer transition-colors shrink-0"
@click="cancelImport(model.id)"
>
<i class="icon-[lucide--circle-x] size-4" />
</div>
</div>
</Transition>
<!-- IDLE / UPLOAD AREA -->
<Transition :css="false" @enter="enterTransition" @leave="leaveTransition">
<div v-if="!importStatus[model.id] || importStatus[model.id] === 'idle'" class="flex flex-col gap-2">
<Transition :css="false" @enter="enterTransition" @leave="leaveTransition">
<div v-if="!selectedLibraryModel[model.id]" class="flex flex-col gap-2">
<!-- Direct Upload Section -->
<div
class="h-8 rounded-lg flex items-center justify-center border border-dashed border-[#55565e] hover:border-white transition-colors cursor-pointer group"
@click="startUpload(model.id)"
>
<span class="text-xs text-[#8a8a8a] group-hover:text-white">Upload .safetensors or .ckpt</span>
</div>
<div class="flex flex-col gap-2">
<div class="flex items-center justify-center py-0.5 font-bold text-[10px] text-[#8a8a8a]">OR</div>
<div class="relative">
<div
class="h-8 bg-[#262729] rounded-lg flex items-center px-3 cursor-pointer group/lib hover:border-[#494a50] border border-transparent"
@click="toggleLibraryDropdown(model.id)"
>
<span class="flex-1 text-xs text-white truncate">Use from Library</span>
<i class="icon-[lucide--chevron-down] size-3.5 text-[#8a8a8a] group-hover/lib:text-white" />
</div>
<div v-if="activeLibraryDropdown === model.id" class="absolute top-full left-0 w-full mt-1 bg-[#26272b] border border-[#3f4045] rounded-lg shadow-xl z-50 overflow-hidden py-1">
<div
v-for="libModel in LIBRARY_MODELS"
:key="libModel"
class="px-3 py-2 text-xs text-[#e2e2e4] hover:bg-[#323338] cursor-pointer flex items-center gap-2"
@click="selectFromLibrary(model.id, libModel)"
>
<i class="icon-[lucide--file-code] size-3.5 text-[#8a8a8a]" />
{{ libModel }}
</div>
</div>
</div>
</div>
</div>
</Transition>
</div>
</Transition>
</div>
</div>
</TransitionGroup>
</div>
</Transition>
<div class="-mx-4 mt-6 h-px bg-[#55565e]" />
</div>
</template>
<div v-if="Object.keys(removedModels).length > 0" class="flex justify-center py-8">
<Button variant="muted-textonly" class="text-xs gap-2 hover:text-white" @click="resetAll">
<i class="icon-[lucide--rotate-ccw] size-3.5" />
Reset Storybook Flow
</Button>
</div>
</div>
<div class="h-px bg-[#55565e] shrink-0 w-full" />
</div>
</template>
<style scoped>
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
</style>

View File

@@ -0,0 +1,312 @@
<script setup lang="ts">
import { ref } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import type { MissingNodePack } from './MockManagerDialog.vue'
// Props / Emits
const emit = defineEmits<{
'open-manager': [pack: MissingNodePack],
'locate': [pack: MissingNodePack],
'log': [msg: string]
}>()
// Mock Data
const MOCK_MISSING_PACKS: MissingNodePack[] = [
{
id: 'pack-1',
displayName: 'MeshGraphormerDepthMapPreprocessor_for_SEGS //Inspire',
packId: 'comfyui-inspire-pack',
description: 'Inspire Pack provides various creative and utility nodes for ComfyUI workflows.'
},
{
id: 'pack-2',
displayName: 'TilePreprocessor_Provider_for_SEGS',
packId: 'comfyui-controlnet-aux',
description: 'Auxiliary preprocessors for ControlNet including tile, depth, and pose processors.'
},
{
id: 'pack-3',
displayName: 'WD14Tagger | pysssss',
packId: 'comfyui-wdv14-tagger',
description: 'Automatic image tagging using WD14 model from pysssss.'
},
{
id: 'pack-4',
displayName: 'CR Simple Image Compare',
packId: 'comfyui-crystools',
description: 'Crystal Tools suite including image comparison and utility nodes.'
},
{
id: 'pack-5',
displayName: 'FaceDetailer | impact',
packId: 'comfyui-impact-pack',
description: 'Impact Pack provides face detailing, masking, and segmentation utilities.'
}
]
// State
const isSectionCollapsed = ref(false)
const installStates = ref<Record<string, 'idle' | 'installing' | 'error'>>({})
const hasSuccessfulInstall = ref(false)
// Helpers
function getInstallState(packId: string) {
return installStates.value[packId] ?? 'idle'
}
function getElementStyle(el: HTMLElement) {
return {
height: el.style.height,
overflow: el.style.overflow,
paddingTop: el.style.paddingTop,
paddingBottom: el.style.paddingBottom,
marginTop: el.style.marginTop,
marginBottom: el.style.marginBottom
}
}
// Actions
function onInstall(pack: MissingNodePack) {
if (getInstallState(pack.id) !== 'idle') return
installStates.value[pack.id] = 'installing'
emit('log', `⤵ Installing: "${pack.packId}"`)
const isErrorPack = pack.id === 'pack-2'
const delay = isErrorPack ? 2000 : 3000
setTimeout(() => {
if (isErrorPack) {
installStates.value[pack.id] = 'error'
emit('log', `⚠️ Install failed: "${pack.packId}"`)
} else {
installStates.value[pack.id] = 'idle'
hasSuccessfulInstall.value = true
emit('log', `✓ Installed: "${pack.packId}"`)
}
}, delay)
}
function onInstallAll() {
const idlePacks = MOCK_MISSING_PACKS.filter(p => getInstallState(p.id) === 'idle')
if (!idlePacks.length) {
emit('log', 'No packs to install')
return
}
emit('log', `Install All → Starting sequential install of ${idlePacks.length} pack(s)`)
idlePacks.forEach((pack, i) => {
setTimeout(() => onInstall(pack), i * 1000)
})
}
function resetAll() {
installStates.value = {}
hasSuccessfulInstall.value = false
emit('log', '🔄 Reboot Server')
}
// Transitions
const DURATION = 150
function enterTransition(element: Element, done: () => void) {
const el = element as HTMLElement
const init = getElementStyle(el)
const { width } = getComputedStyle(el)
el.style.width = width
el.style.position = 'absolute'
el.style.visibility = 'hidden'
el.style.height = ''
const { height } = getComputedStyle(el)
el.style.position = ''
el.style.visibility = ''
el.style.height = '0px'
el.style.overflow = 'hidden'
const anim = el.animate(
[{ height: '0px', opacity: 0 }, { height, opacity: 1 }],
{ duration: DURATION, easing: 'ease-in-out' }
)
el.style.height = init.height
anim.onfinish = () => { el.style.overflow = init.overflow; done() }
}
function leaveTransition(element: Element, done: () => void) {
const el = element as HTMLElement
const init = getElementStyle(el)
const { height } = getComputedStyle(el)
el.style.height = height
el.style.overflow = 'hidden'
const anim = el.animate(
[{ height, opacity: 1 }, { height: '0px', opacity: 0 }],
{ duration: DURATION, easing: 'ease-in-out' }
)
el.style.height = init.height
anim.onfinish = () => { el.style.overflow = init.overflow; done() }
}
</script>
<template>
<div
class="w-[320px] h-full shrink-0 flex flex-col gap-4 py-1 bg-[#171718] border-l border-[#494a50] shadow-[1px_1px_8px_0px_rgba(0,0,0,0.4)]"
>
<!-- Nav Item: "Workflow Overview" + panel-right button -->
<div class="flex h-12 items-center overflow-hidden py-2 border-b border-[#55565e] shrink-0">
<div class="flex flex-1 gap-2 items-center min-w-0 pl-4 pr-3">
<p class="flex-1 min-w-0 font-bold text-sm text-white whitespace-pre-wrap">
Workflow Overview
</p>
<div class="flex h-8 items-center justify-center overflow-hidden p-2 rounded-lg bg-[#262729] shrink-0 cursor-pointer hover:bg-[#303133]">
<i class="icon-[lucide--panel-right] size-4 text-white" />
</div>
</div>
</div>
<!-- Node Header: tab bar + search -->
<div class="flex flex-col gap-3 items-start px-4 shrink-0">
<!-- Tab bar -->
<div class="flex gap-2 items-center w-full overflow-x-auto no-scrollbar">
<!-- "Error" tab (active) -->
<div class="flex gap-1 h-8 items-center justify-center overflow-hidden px-2 rounded-lg shrink-0 bg-[#262729]">
<span class="text-sm text-white">Error</span>
<div class="flex items-center justify-center size-6 shrink-0">
<i class="icon-[lucide--octagon-alert] size-4 text-[#e04e48]" />
</div>
</div>
<!-- Other tabs -->
<div class="flex h-8 items-center justify-center overflow-hidden px-2 rounded-lg shrink-0">
<span class="text-sm text-[#8a8a8a]">Inputs</span>
</div>
<div class="flex h-8 items-center justify-center overflow-hidden px-2 rounded-lg shrink-0">
<span class="text-sm text-[#8a8a8a]">Nodes</span>
</div>
<div class="flex h-8 items-center justify-center overflow-hidden px-2 rounded-lg shrink-0">
<span class="text-sm whitespace-nowrap text-[#8a8a8a]">Global settings</span>
</div>
</div>
<!-- Search bar -->
<div class="flex gap-2 h-8 items-center min-h-[32px] px-2 py-1.5 rounded-lg bg-[#262729] w-full">
<i class="icon-[lucide--search] size-4 text-[#8a8a8a] shrink-0" />
<p class="flex-1 text-xs text-[#8a8a8a] truncate leading-normal">
Search for nodes or inputs
</p>
</div>
</div>
<div class="h-px bg-[#55565e] shrink-0 w-full" />
<!-- Content: Nodes (Missing Node Packs) -->
<div class="flex-1 overflow-y-auto min-w-0">
<div class="px-4">
<!-- Section Header -->
<div class="flex h-8 items-center justify-center w-full">
<div class="flex items-center justify-center size-6 shrink-0">
<i class="icon-[lucide--octagon-alert] size-4 text-[#e04e48]" />
</div>
<p class="flex-1 min-w-0 text-sm text-[#e04e48] whitespace-pre-wrap">Missing Node Packs</p>
<div
class="flex h-8 items-center justify-center overflow-hidden p-2 rounded-lg bg-[#262729] shrink-0 cursor-pointer hover:bg-[#303133]"
@click="onInstallAll"
>
<span class="text-sm text-white">Install All</span>
</div>
<div
class="group flex h-8 items-center justify-center overflow-hidden p-2 rounded-lg shrink-0 cursor-pointer"
@click="isSectionCollapsed = !isSectionCollapsed"
>
<i
class="icon-[lucide--chevron-up] size-4 text-[#8a8a8a] group-hover:text-white transition-all"
:class="isSectionCollapsed ? '-rotate-180' : ''"
/>
</div>
</div>
<div class="h-2" />
<div class="-mx-4 border-b border-[#55565e]">
<Transition :css="false" @enter="enterTransition" @leave="leaveTransition">
<div v-if="!isSectionCollapsed" class="px-4 pb-2">
<div v-for="pack in MOCK_MISSING_PACKS" :key="pack.id" class="flex flex-col w-full group/card mb-1">
<!-- Widget Header -->
<div class="flex h-8 items-center w-full">
<p class="flex-1 min-w-0 text-sm text-white overflow-hidden text-ellipsis whitespace-nowrap">
{{ pack.displayName }}
</p>
<div
class="flex h-8 items-center justify-center overflow-hidden p-2 rounded-lg shrink-0 cursor-pointer hover:bg-[#262729]"
@click="emit('open-manager', pack)"
>
<i class="icon-[lucide--info] size-4 text-white" />
</div>
<div
class="flex h-8 items-center justify-center overflow-hidden p-2 rounded-lg shrink-0 cursor-pointer hover:bg-[#262729]"
@click="emit('locate', pack)"
>
<i class="icon-[lucide--locate] size-4 text-white" />
</div>
</div>
<!-- Install button -->
<div class="flex items-start w-full pt-1 pb-2">
<div
class="flex flex-1 h-8 items-center justify-center overflow-hidden p-2 rounded-lg min-w-0 transition-colors select-none"
:class="[
getInstallState(pack.id) === 'idle'
? 'bg-[#262729] cursor-pointer hover:bg-[#303133]'
: getInstallState(pack.id) === 'error'
? 'bg-[#3a2020] cursor-pointer hover:bg-[#4a2a2a]'
: 'bg-[#262729] opacity-60 cursor-default'
]"
@click="onInstall(pack)"
>
<svg
v-if="getInstallState(pack.id) === 'installing'"
class="animate-spin size-4 text-white shrink-0"
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
>
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
<i
v-else-if="getInstallState(pack.id) === 'error'"
class="icon-[lucide--triangle-alert] size-4 text-[#f59e0b] shrink-0"
/>
<i v-else class="icon-[lucide--download] size-4 text-white shrink-0" />
<span class="text-sm text-white ml-1.5 shrink-0">
{{ getInstallState(pack.id) === 'installing' ? 'Installing...' : 'Install node pack' }}
</span>
</div>
</div>
</div>
</div>
</Transition>
<Transition
enter-active-class="transition-opacity duration-300 ease-out"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition-opacity duration-200 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div v-if="hasSuccessfulInstall && !isSectionCollapsed" class="px-4 pb-4 pt-1">
<Button
variant="primary"
class="w-full h-9 justify-center gap-2 text-sm font-semibold"
@click="resetAll"
>
<i class="icon-[lucide--refresh-cw] size-4" />
Apply Changes
</Button>
</div>
</Transition>
</div>
</div>
</div>
<div class="h-px bg-[#55565e] shrink-0 w-full" />
</div>
</template>

View File

@@ -0,0 +1,96 @@
<script setup lang="ts">
import { ref } from 'vue'
import MockManagerDialog from './MockManagerDialog.vue'
import MockOSSMissingNodePack from './MockOSSMissingNodePack.vue'
import type { MissingNodePack } from './MockManagerDialog.vue'
// State
const isManagerOpen = ref(false)
const selectedPack = ref<MissingNodePack | null>(null)
const statusLog = ref<string>('')
// Actions
function log(msg: string) {
statusLog.value = msg
}
function openManager(pack: MissingNodePack) {
selectedPack.value = pack
isManagerOpen.value = true
log(`ⓘ Opening Manager: "${pack.displayName.split('//')[0].trim()}"`)
}
function closeManager() {
isManagerOpen.value = false
selectedPack.value = null
log('Manager closed')
}
function onLocate(pack: MissingNodePack) {
log(`◎ Locating on canvas: "${pack.displayName.split('//')[0].trim()}"`)
}
</script>
<template>
<!-- ComfyUI layout simulation: canvas + right side panel + manager overlay -->
<div class="relative w-full h-full flex overflow-hidden bg-[#0d0e10]">
<!-- Canvas area -->
<div class="flex-1 min-w-0 relative flex flex-col items-center justify-center gap-4 overflow-hidden">
<!-- Grid background -->
<div
class="absolute inset-0 opacity-15"
style="background-image: repeating-linear-gradient(#444 0 1px, transparent 1px 100%), repeating-linear-gradient(90deg, #444 0 1px, transparent 1px 100%); background-size: 32px 32px;"
/>
<div class="relative z-10 flex flex-col items-center gap-4">
<div class="text-[#8a8a8a]/30 text-sm select-none">ComfyUI Canvas</div>
<div class="flex gap-5 flex-wrap justify-center px-8">
<div v-for="i in 4" :key="i" class="w-[160px] h-[80px] rounded-lg border border-[#3a3b3d] bg-[#1a1b1d]/80 flex flex-col p-3 gap-2">
<div class="h-3 w-24 rounded bg-[#2a2b2d]" />
<div class="h-2 w-16 rounded bg-[#2a2b2d]" />
<div class="h-2 w-20 rounded bg-[#2a2b2d]" />
</div>
</div>
<div class="flex items-center justify-center min-h-[36px]">
<div
v-if="statusLog"
class="px-4 py-1.5 rounded-lg text-xs text-center bg-blue-950/70 border border-blue-500/40 text-blue-300"
>{{ statusLog }}</div>
<div v-else class="px-4 py-1.5 text-xs text-[#8a8a8a]/30 border border-dashed border-[#2a2b2d] rounded-lg">
Click the buttons in the right-side error tab
</div>
</div>
</div>
</div>
<!-- Right: MockOSSMissingNodePack (320px) -->
<MockOSSMissingNodePack
@open-manager="openManager"
@locate="onLocate"
@log="log"
/>
<!-- Manager dialog overlay (full screen including right panel) -->
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 scale-95"
enter-to-class="opacity-100 scale-100"
leave-active-class="transition-all duration-150 ease-in"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95"
>
<div
v-if="isManagerOpen"
class="absolute inset-0 z-20 flex items-center justify-center bg-black/60 backdrop-blur-sm"
@click.self="closeManager"
>
<div class="relative h-[80vh] w-[90vw] max-w-[1400px]">
<MockManagerDialog :selected-pack="selectedPack" @close="closeManager" />
</div>
</div>
</Transition>
</div>
</template>