mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-02 14:27:40 +00:00
feat(ui): Enhanced bottom bar modal with expandable view, sidebar, and standardized cards
- Add expand mode for bottom bar modal (half-page size) - Add collapsible left sidebar with category navigation - Standardize all cards to square aspect ratio across all tabs - Set canvas default zoom to 75% with auto-center on load - Add sidebar toggle button in modal header - Dynamic category lists per tab (Models, Workflows, Assets, Templates, Packages) - Unified card component styles with hover effects - Responsive grid columns (4 normal, 6 extended) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
31
ComfyUI_vibe/src/components.d.ts
vendored
31
ComfyUI_vibe/src/components.d.ts
vendored
@@ -7,25 +7,56 @@ export {}
|
||||
/* prettier-ignore */
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
AssetsTab: typeof import('./components/v2/workspace/AssetsTab.vue')['default']
|
||||
CanvasBottomBar: typeof import('./components/v2/canvas/CanvasBottomBar.vue')['default']
|
||||
CanvasLeftSidebar: typeof import('./components/v2/canvas/CanvasLeftSidebar.vue')['default']
|
||||
CanvasLogoMenu: typeof import('./components/v2/canvas/CanvasLogoMenu.vue')['default']
|
||||
CanvasTabBar: typeof import('./components/v2/canvas/CanvasTabBar.vue')['default']
|
||||
CanvasTabs: typeof import('./components/v2/canvas/CanvasTabs.vue')['default']
|
||||
CreateProjectDialog: typeof import('./components/v2/workspace/CreateProjectDialog.vue')['default']
|
||||
FlowNode: typeof import('./components/v2/nodes/FlowNode.vue')['default']
|
||||
FlowNodeMinimized: typeof import('./components/v2/nodes/FlowNodeMinimized.vue')['default']
|
||||
LibraryBrandKitSection: typeof import('./components/v1/sidebar/LibraryBrandKitSection.vue')['default']
|
||||
LibraryModelsSection: typeof import('./components/v1/sidebar/LibraryModelsSection.vue')['default']
|
||||
LibraryNodesSection: typeof import('./components/v1/sidebar/LibraryNodesSection.vue')['default']
|
||||
LibrarySidebar: typeof import('./components/v2/canvas/LibrarySidebar.vue')['default']
|
||||
LibraryWorkflowsSection: typeof import('./components/v1/sidebar/LibraryWorkflowsSection.vue')['default']
|
||||
ModelsTab: typeof import('./components/v2/workspace/ModelsTab.vue')['default']
|
||||
NodeHeader: typeof import('./components/v2/nodes/NodeHeader.vue')['default']
|
||||
NodePropertiesPanel: typeof import('./components/v2/canvas/NodePropertiesPanel.vue')['default']
|
||||
NodeSlots: typeof import('./components/v2/nodes/NodeSlots.vue')['default']
|
||||
NodeWidgets: typeof import('./components/v2/nodes/NodeWidgets.vue')['default']
|
||||
PackagesTab: typeof import('./components/v2/workspace/PackagesTab.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
SidebarGridCard: typeof import('./components/common/sidebar/SidebarGridCard.vue')['default']
|
||||
SidebarSearchBox: typeof import('./components/common/sidebar/SidebarSearchBox.vue')['default']
|
||||
SidebarTreeCategory: typeof import('./components/common/sidebar/SidebarTreeCategory.vue')['default']
|
||||
SidebarTreeItem: typeof import('./components/common/sidebar/SidebarTreeItem.vue')['default']
|
||||
SidebarViewToggle: typeof import('./components/common/sidebar/SidebarViewToggle.vue')['default']
|
||||
SlotDot: typeof import('./components/v2/nodes/SlotDot.vue')['default']
|
||||
V1SidebarAssetsTab: typeof import('./components/v1/sidebar/V1SidebarAssetsTab.vue')['default']
|
||||
V1SidebarIconBar: typeof import('./components/v1/sidebar/V1SidebarIconBar.vue')['default']
|
||||
V1SidebarModelsTab: typeof import('./components/v1/sidebar/V1SidebarModelsTab.vue')['default']
|
||||
V1SidebarNodesTab: typeof import('./components/v1/sidebar/V1SidebarNodesTab.vue')['default']
|
||||
V1SidebarPanel: typeof import('./components/v1/sidebar/V1SidebarPanel.vue')['default']
|
||||
V1SidebarTemplatesTab: typeof import('./components/v1/sidebar/V1SidebarTemplatesTab.vue')['default']
|
||||
V1SidebarWorkflowsTab: typeof import('./components/v1/sidebar/V1SidebarWorkflowsTab.vue')['default']
|
||||
V2NodePanel: typeof import('./components/v2/sidebar/V2NodePanel.vue')['default']
|
||||
WidgetColor: typeof import('./components/v2/nodes/widgets/WidgetColor.vue')['default']
|
||||
WidgetNumber: typeof import('./components/v2/nodes/widgets/WidgetNumber.vue')['default']
|
||||
WidgetSelect: typeof import('./components/v2/nodes/widgets/WidgetSelect.vue')['default']
|
||||
WidgetSlider: typeof import('./components/v2/nodes/widgets/WidgetSlider.vue')['default']
|
||||
WidgetText: typeof import('./components/v2/nodes/widgets/WidgetText.vue')['default']
|
||||
WidgetToggle: typeof import('./components/v2/nodes/widgets/WidgetToggle.vue')['default']
|
||||
WorkflowsTab: typeof import('./components/v2/workspace/WorkflowsTab.vue')['default']
|
||||
WorkspaceEmptyState: typeof import('./components/v2/workspace/WorkspaceEmptyState.vue')['default']
|
||||
WorkspaceLayout: typeof import('./components/v2/layout/WorkspaceLayout.vue')['default']
|
||||
WorkspaceSearchInput: typeof import('./components/v2/workspace/WorkspaceSearchInput.vue')['default']
|
||||
WorkspaceSidebar: typeof import('./components/v2/layout/WorkspaceSidebar.vue')['default']
|
||||
WorkspaceSortSelect: typeof import('./components/v2/workspace/WorkspaceSortSelect.vue')['default']
|
||||
WorkspaceViewHeader: typeof import('./components/v2/workspace/WorkspaceViewHeader.vue')['default']
|
||||
WorkspaceViewToggle: typeof import('./components/v2/workspace/WorkspaceViewToggle.vue')['default']
|
||||
}
|
||||
export interface ComponentCustomProperties {
|
||||
Tooltip: typeof import('primevue/tooltip')['default']
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
title: string
|
||||
subtitle?: string
|
||||
draggable?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
draggable: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
click: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="group cursor-pointer rounded-lg border border-zinc-800 bg-zinc-900 p-2 transition-all hover:border-zinc-700 hover:bg-zinc-800/50"
|
||||
:draggable="props.draggable"
|
||||
@click="emit('click')"
|
||||
>
|
||||
<!-- Header slot for icon/badge row -->
|
||||
<div class="mb-1 flex items-center justify-between">
|
||||
<slot name="header-left" />
|
||||
<slot name="header-right" />
|
||||
</div>
|
||||
|
||||
<!-- Title -->
|
||||
<div class="truncate text-xs text-zinc-400 group-hover:text-zinc-200">
|
||||
{{ props.title }}
|
||||
</div>
|
||||
|
||||
<!-- Subtitle -->
|
||||
<div v-if="props.subtitle" class="mt-0.5 truncate text-[10px] text-zinc-600">
|
||||
{{ props.subtitle }}
|
||||
</div>
|
||||
|
||||
<!-- Extra content slot -->
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,43 @@
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
placeholder?: string
|
||||
actionIcon?: string
|
||||
actionTooltip?: string
|
||||
showAction?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
placeholder: 'Search...',
|
||||
actionIcon: 'pi pi-plus',
|
||||
actionTooltip: 'Add',
|
||||
showAction: false,
|
||||
})
|
||||
|
||||
const model = defineModel<string>({ default: '' })
|
||||
|
||||
const emit = defineEmits<{
|
||||
action: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex flex-1 items-center rounded bg-zinc-800 px-2 py-1.5">
|
||||
<i class="pi pi-search text-xs text-zinc-500" />
|
||||
<input
|
||||
v-model="model"
|
||||
type="text"
|
||||
:placeholder="props.placeholder"
|
||||
class="ml-2 w-full bg-transparent text-xs text-zinc-300 outline-none placeholder:text-zinc-500"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
v-if="props.showAction"
|
||||
v-tooltip.top="{ value: props.actionTooltip, showDelay: 50 }"
|
||||
class="flex h-7 w-7 shrink-0 items-center justify-center rounded bg-zinc-800 text-zinc-400 transition-colors hover:bg-zinc-700 hover:text-zinc-200"
|
||||
@click="emit('action')"
|
||||
>
|
||||
<i :class="[props.actionIcon, 'text-xs']" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,40 @@
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
icon: string
|
||||
iconColor?: string
|
||||
label: string
|
||||
count?: number
|
||||
expanded?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
iconColor: 'text-zinc-400',
|
||||
expanded: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
toggle: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
class="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left transition-colors hover:bg-zinc-800"
|
||||
@click="emit('toggle')"
|
||||
>
|
||||
<i
|
||||
class="text-[10px] text-zinc-500 transition-transform"
|
||||
:class="props.expanded ? 'pi pi-chevron-down' : 'pi pi-chevron-right'"
|
||||
/>
|
||||
<i :class="[props.icon, 'text-xs', props.iconColor]" />
|
||||
<span class="flex-1 text-xs font-medium text-zinc-300">
|
||||
{{ props.label }}
|
||||
</span>
|
||||
<span
|
||||
v-if="props.count !== undefined"
|
||||
class="rounded bg-zinc-800 px-1.5 py-0.5 text-[10px] text-zinc-500"
|
||||
>
|
||||
{{ props.count }}
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
@@ -0,0 +1,64 @@
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
icon?: string
|
||||
iconType?: 'dot' | 'icon'
|
||||
label: string
|
||||
sublabel?: string
|
||||
badge?: string | number
|
||||
draggable?: boolean
|
||||
actionIcon?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
iconType: 'dot',
|
||||
draggable: false,
|
||||
actionIcon: 'pi pi-plus',
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
click: []
|
||||
action: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="group flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 transition-colors hover:bg-zinc-800"
|
||||
:draggable="props.draggable"
|
||||
@click="emit('click')"
|
||||
>
|
||||
<!-- Dot or Icon -->
|
||||
<i
|
||||
v-if="props.iconType === 'dot'"
|
||||
class="pi pi-circle-fill text-[5px] text-zinc-600 group-hover:text-zinc-400"
|
||||
/>
|
||||
<i
|
||||
v-else-if="props.icon"
|
||||
:class="[props.icon, 'text-[10px] text-zinc-600 group-hover:text-zinc-400']"
|
||||
/>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-xs text-zinc-400 group-hover:text-zinc-200">
|
||||
{{ props.label }}
|
||||
</div>
|
||||
<div v-if="props.sublabel" class="truncate text-[10px] text-zinc-600">
|
||||
{{ props.sublabel }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Badge -->
|
||||
<span
|
||||
v-if="props.badge !== undefined"
|
||||
class="rounded bg-zinc-800 px-1.5 py-0.5 text-[10px] text-zinc-600"
|
||||
>
|
||||
{{ props.badge }}
|
||||
</span>
|
||||
|
||||
<!-- Action Icon -->
|
||||
<i
|
||||
:class="[props.actionIcon, 'text-[10px] text-zinc-600 opacity-0 transition-opacity group-hover:opacity-100']"
|
||||
@click.stop="emit('action')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
type ViewMode = 'list' | 'grid'
|
||||
|
||||
const model = defineModel<ViewMode>({ required: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center rounded bg-zinc-800 p-0.5">
|
||||
<button
|
||||
v-tooltip.bottom="{ value: 'List View', showDelay: 50 }"
|
||||
class="flex h-6 w-6 items-center justify-center rounded transition-colors"
|
||||
:class="model === 'list' ? 'bg-zinc-700 text-zinc-200' : 'text-zinc-500 hover:text-zinc-300'"
|
||||
@click="model = 'list'"
|
||||
>
|
||||
<i class="pi pi-list text-[10px]" />
|
||||
</button>
|
||||
<button
|
||||
v-tooltip.bottom="{ value: 'Grid View', showDelay: 50 }"
|
||||
class="flex h-6 w-6 items-center justify-center rounded transition-colors"
|
||||
:class="model === 'grid' ? 'bg-zinc-700 text-zinc-200' : 'text-zinc-500 hover:text-zinc-300'"
|
||||
@click="model = 'grid'"
|
||||
>
|
||||
<i class="pi pi-th-large text-[10px]" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
5
ComfyUI_vibe/src/components/common/sidebar/index.ts
Normal file
5
ComfyUI_vibe/src/components/common/sidebar/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { default as SidebarTreeCategory } from './SidebarTreeCategory.vue'
|
||||
export { default as SidebarTreeItem } from './SidebarTreeItem.vue'
|
||||
export { default as SidebarGridCard } from './SidebarGridCard.vue'
|
||||
export { default as SidebarViewToggle } from './SidebarViewToggle.vue'
|
||||
export { default as SidebarSearchBox } from './SidebarSearchBox.vue'
|
||||
@@ -0,0 +1,106 @@
|
||||
<script setup lang="ts">
|
||||
import type { BrandAsset } from '@/data/sidebarMockData'
|
||||
|
||||
defineProps<{
|
||||
assets: BrandAsset[]
|
||||
viewMode: 'list' | 'grid'
|
||||
expanded: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
toggle: []
|
||||
}>()
|
||||
|
||||
function getAssetIcon(type: BrandAsset['type']): string {
|
||||
switch (type) {
|
||||
case 'logo': return 'pi pi-image'
|
||||
case 'color': return 'pi pi-circle-fill'
|
||||
case 'font': return 'pi pi-align-left'
|
||||
case 'template': return 'pi pi-clone'
|
||||
case 'guideline': return 'pi pi-book'
|
||||
default: return 'pi pi-file'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- List View -->
|
||||
<template v-if="viewMode === 'list'">
|
||||
<!-- Category Header -->
|
||||
<button
|
||||
class="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left transition-colors hover:bg-zinc-800"
|
||||
@click="emit('toggle')"
|
||||
>
|
||||
<i
|
||||
class="text-[10px] text-zinc-500 transition-transform"
|
||||
:class="expanded ? 'pi pi-chevron-down' : 'pi pi-chevron-right'"
|
||||
/>
|
||||
<i class="pi pi-palette text-xs text-amber-400" />
|
||||
<span class="flex-1 text-xs font-medium text-zinc-300">Brand Kit</span>
|
||||
<span class="rounded bg-zinc-800 px-1.5 py-0.5 text-[10px] text-zinc-500">
|
||||
{{ assets.length }}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<!-- Items -->
|
||||
<div v-if="expanded" class="ml-4 space-y-0.5 border-l border-zinc-800 pl-2">
|
||||
<!-- Colors Row -->
|
||||
<div class="px-2 py-1.5">
|
||||
<div class="mb-1 text-[10px] font-medium uppercase tracking-wider text-zinc-500">Colors</div>
|
||||
<div class="flex gap-2">
|
||||
<div
|
||||
v-for="asset in assets.filter(a => a.type === 'color')"
|
||||
:key="asset.id"
|
||||
v-tooltip.top="{ value: asset.name, showDelay: 50 }"
|
||||
class="group relative cursor-pointer"
|
||||
>
|
||||
<div
|
||||
class="h-6 w-6 rounded border border-zinc-700 transition-transform group-hover:scale-110"
|
||||
:style="{ backgroundColor: asset.value }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Other Assets -->
|
||||
<div
|
||||
v-for="asset in assets.filter(a => a.type !== 'color')"
|
||||
:key="asset.id"
|
||||
class="group flex cursor-pointer items-center gap-2 rounded px-2 py-1 transition-colors hover:bg-zinc-800"
|
||||
>
|
||||
<i class="pi pi-circle-fill text-[5px] text-zinc-600 group-hover:text-zinc-400" />
|
||||
<i :class="[getAssetIcon(asset.type), 'text-[10px] text-zinc-500']" />
|
||||
<span class="flex-1 truncate text-xs text-zinc-400 group-hover:text-zinc-200">{{ asset.name }}</span>
|
||||
<i class="pi pi-download text-[10px] text-zinc-600 opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Grid View -->
|
||||
<template v-else>
|
||||
<div class="mb-1.5 flex items-center gap-2 px-1">
|
||||
<i class="pi pi-palette text-xs text-amber-400" />
|
||||
<span class="text-[10px] font-medium uppercase tracking-wider text-zinc-500">Brand Kit</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-1.5">
|
||||
<div
|
||||
v-for="asset in assets.filter(a => a.type === 'color')"
|
||||
:key="asset.id"
|
||||
v-tooltip.top="{ value: asset.name, showDelay: 50 }"
|
||||
class="group cursor-pointer rounded-lg border border-zinc-800 bg-zinc-900 p-2 transition-all hover:border-zinc-700"
|
||||
>
|
||||
<div class="mb-1.5 h-8 w-full rounded" :style="{ backgroundColor: asset.value }" />
|
||||
<div class="truncate text-[10px] text-zinc-500">{{ asset.name }}</div>
|
||||
</div>
|
||||
<div
|
||||
v-for="asset in assets.filter(a => a.type !== 'color')"
|
||||
:key="asset.id"
|
||||
class="group cursor-pointer rounded-lg border border-zinc-800 bg-zinc-900 p-2 transition-all hover:border-zinc-700"
|
||||
>
|
||||
<div class="mb-1.5 flex h-8 items-center justify-center rounded bg-zinc-800">
|
||||
<i :class="[getAssetIcon(asset.type), 'text-base text-zinc-600']" />
|
||||
</div>
|
||||
<div class="truncate text-[10px] text-zinc-400">{{ asset.name }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
@@ -0,0 +1,98 @@
|
||||
<script setup lang="ts">
|
||||
import type { TeamModel } from '@/data/sidebarMockData'
|
||||
|
||||
defineProps<{
|
||||
models: TeamModel[]
|
||||
viewMode: 'list' | 'grid'
|
||||
expanded: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
toggle: []
|
||||
}>()
|
||||
|
||||
function getModelTypeLabel(type: TeamModel['type']): string {
|
||||
switch (type) {
|
||||
case 'checkpoint': return 'Checkpoint'
|
||||
case 'lora': return 'LoRA'
|
||||
case 'embedding': return 'Embedding'
|
||||
case 'controlnet': return 'ControlNet'
|
||||
default: return type
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- List View -->
|
||||
<template v-if="viewMode === 'list'">
|
||||
<!-- Category Header -->
|
||||
<button
|
||||
class="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left transition-colors hover:bg-zinc-800"
|
||||
@click="emit('toggle')"
|
||||
>
|
||||
<i
|
||||
class="text-[10px] text-zinc-500 transition-transform"
|
||||
:class="expanded ? 'pi pi-chevron-down' : 'pi pi-chevron-right'"
|
||||
/>
|
||||
<i class="pi pi-box text-xs text-green-400" />
|
||||
<span class="flex-1 text-xs font-medium text-zinc-300">Team Models</span>
|
||||
<span class="rounded bg-zinc-800 px-1.5 py-0.5 text-[10px] text-zinc-500">
|
||||
{{ models.length }}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<!-- Items -->
|
||||
<div v-if="expanded" class="ml-4 space-y-0.5 border-l border-zinc-800 pl-2">
|
||||
<div
|
||||
v-for="model in models"
|
||||
:key="model.id"
|
||||
class="group flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 transition-colors hover:bg-zinc-800"
|
||||
draggable="true"
|
||||
>
|
||||
<i class="pi pi-file text-[10px] text-zinc-600 group-hover:text-zinc-400" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="truncate text-xs text-zinc-400 group-hover:text-zinc-200">{{ model.name }}</span>
|
||||
<span class="rounded bg-zinc-800 px-1 py-0.5 text-[9px] text-zinc-500">
|
||||
{{ getModelTypeLabel(model.type) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-[10px] text-zinc-600">
|
||||
<span>{{ model.size }}</span>
|
||||
<span>{{ model.downloads }} downloads</span>
|
||||
</div>
|
||||
</div>
|
||||
<i class="pi pi-download text-[10px] text-zinc-600 opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Grid View -->
|
||||
<template v-else>
|
||||
<div class="mb-1.5 flex items-center gap-2 px-1">
|
||||
<i class="pi pi-box text-xs text-green-400" />
|
||||
<span class="text-[10px] font-medium uppercase tracking-wider text-zinc-500">Team Models</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-1.5">
|
||||
<div
|
||||
v-for="model in models"
|
||||
:key="model.id"
|
||||
class="group cursor-pointer rounded-lg border border-zinc-800 bg-zinc-900 p-2 transition-all hover:border-zinc-700 hover:bg-zinc-800/50"
|
||||
draggable="true"
|
||||
>
|
||||
<div class="mb-1 flex items-center justify-between">
|
||||
<span class="rounded bg-zinc-800 px-1 py-0.5 text-[9px] text-zinc-500">
|
||||
{{ getModelTypeLabel(model.type) }}
|
||||
</span>
|
||||
<span class="text-[9px] text-zinc-600">{{ model.size }}</span>
|
||||
</div>
|
||||
<div class="truncate text-xs text-zinc-400 group-hover:text-zinc-200">
|
||||
{{ model.name }}
|
||||
</div>
|
||||
<div class="mt-0.5 truncate text-[10px] text-zinc-600">
|
||||
{{ model.downloads }} downloads
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
103
ComfyUI_vibe/src/components/v1/sidebar/LibraryNodesSection.vue
Normal file
103
ComfyUI_vibe/src/components/v1/sidebar/LibraryNodesSection.vue
Normal file
@@ -0,0 +1,103 @@
|
||||
<script setup lang="ts">
|
||||
import type { NodePack } from '@/data/sidebarMockData'
|
||||
|
||||
defineProps<{
|
||||
packs: NodePack[]
|
||||
viewMode: 'list' | 'grid'
|
||||
expanded: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
toggle: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- List View -->
|
||||
<template v-if="viewMode === 'list'">
|
||||
<!-- Category Header -->
|
||||
<button
|
||||
class="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left transition-colors hover:bg-zinc-800"
|
||||
@click="emit('toggle')"
|
||||
>
|
||||
<i
|
||||
class="text-[10px] text-zinc-500 transition-transform"
|
||||
:class="expanded ? 'pi pi-chevron-down' : 'pi pi-chevron-right'"
|
||||
/>
|
||||
<i class="pi pi-code text-xs text-purple-400" />
|
||||
<span class="flex-1 text-xs font-medium text-zinc-300">Custom Nodes</span>
|
||||
<span class="rounded bg-zinc-800 px-1.5 py-0.5 text-[10px] text-zinc-500">
|
||||
{{ packs.length }}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<!-- Items -->
|
||||
<div v-if="expanded" class="ml-4 space-y-0.5 border-l border-zinc-800 pl-2">
|
||||
<div
|
||||
v-for="pack in packs"
|
||||
:key="pack.id"
|
||||
class="group flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 transition-colors hover:bg-zinc-800"
|
||||
>
|
||||
<i class="pi pi-circle-fill text-[5px] text-zinc-600 group-hover:text-zinc-400" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="truncate text-xs text-zinc-400 group-hover:text-zinc-200">{{ pack.name }}</span>
|
||||
<span class="rounded bg-zinc-800 px-1 py-0.5 text-[9px] text-zinc-500">
|
||||
v{{ pack.version }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-[10px] text-zinc-600">
|
||||
<span>{{ pack.nodes }} nodes</span>
|
||||
<span>{{ pack.author }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
:class="[
|
||||
'flex h-5 items-center gap-1 rounded px-1.5 text-[9px] font-medium transition-all',
|
||||
pack.installed
|
||||
? 'bg-green-500/20 text-green-400'
|
||||
: 'bg-blue-600 text-white hover:bg-blue-500'
|
||||
]"
|
||||
>
|
||||
<i :class="pack.installed ? 'pi pi-check' : 'pi pi-download'" class="text-[9px]" />
|
||||
{{ pack.installed ? 'Installed' : 'Install' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Grid View -->
|
||||
<template v-else>
|
||||
<div class="mb-1.5 flex items-center gap-2 px-1">
|
||||
<i class="pi pi-code text-xs text-purple-400" />
|
||||
<span class="text-[10px] font-medium uppercase tracking-wider text-zinc-500">Custom Nodes</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-1.5">
|
||||
<div
|
||||
v-for="pack in packs"
|
||||
:key="pack.id"
|
||||
class="group cursor-pointer rounded-lg border border-zinc-800 bg-zinc-900 p-2 transition-all hover:border-zinc-700 hover:bg-zinc-800/50"
|
||||
>
|
||||
<div class="mb-1 flex items-center justify-between">
|
||||
<span class="rounded bg-zinc-800 px-1 py-0.5 text-[9px] text-zinc-500">
|
||||
v{{ pack.version }}
|
||||
</span>
|
||||
<span
|
||||
:class="[
|
||||
'rounded px-1 py-0.5 text-[9px]',
|
||||
pack.installed ? 'bg-green-500/20 text-green-400' : 'bg-zinc-800 text-zinc-500'
|
||||
]"
|
||||
>
|
||||
{{ pack.installed ? 'Installed' : pack.nodes + ' nodes' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="truncate text-xs text-zinc-400 group-hover:text-zinc-200">
|
||||
{{ pack.name }}
|
||||
</div>
|
||||
<div class="mt-0.5 truncate text-[10px] text-zinc-600">
|
||||
{{ pack.author }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
@@ -0,0 +1,87 @@
|
||||
<script setup lang="ts">
|
||||
import type { SharedWorkflow } from '@/data/sidebarMockData'
|
||||
|
||||
defineProps<{
|
||||
workflows: SharedWorkflow[]
|
||||
viewMode: 'list' | 'grid'
|
||||
expanded: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
toggle: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- List View -->
|
||||
<template v-if="viewMode === 'list'">
|
||||
<!-- Category Header -->
|
||||
<button
|
||||
class="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left transition-colors hover:bg-zinc-800"
|
||||
@click="emit('toggle')"
|
||||
>
|
||||
<i
|
||||
class="text-[10px] text-zinc-500 transition-transform"
|
||||
:class="expanded ? 'pi pi-chevron-down' : 'pi pi-chevron-right'"
|
||||
/>
|
||||
<i class="pi pi-sitemap text-xs text-blue-400" />
|
||||
<span class="flex-1 text-xs font-medium text-zinc-300">Shared Workflows</span>
|
||||
<span class="rounded bg-zinc-800 px-1.5 py-0.5 text-[10px] text-zinc-500">
|
||||
{{ workflows.length }}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<!-- Items -->
|
||||
<div v-if="expanded" class="ml-4 space-y-0.5 border-l border-zinc-800 pl-2">
|
||||
<div
|
||||
v-for="workflow in workflows"
|
||||
:key="workflow.id"
|
||||
class="group flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 transition-colors hover:bg-zinc-800"
|
||||
draggable="true"
|
||||
>
|
||||
<i class="pi pi-circle-fill text-[5px] text-zinc-600 group-hover:text-zinc-400" />
|
||||
<i v-if="workflow.starred" class="pi pi-star-fill text-[10px] text-amber-400" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-xs text-zinc-400 group-hover:text-zinc-200">
|
||||
{{ workflow.name }}
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-[10px] text-zinc-600">
|
||||
<span>{{ workflow.nodes }} nodes</span>
|
||||
<span>{{ workflow.updatedAt }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<i class="pi pi-plus text-[10px] text-zinc-600 opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Grid View -->
|
||||
<template v-else>
|
||||
<div class="mb-1.5 flex items-center gap-2 px-1">
|
||||
<i class="pi pi-sitemap text-xs text-blue-400" />
|
||||
<span class="text-[10px] font-medium uppercase tracking-wider text-zinc-500">Shared Workflows</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-1.5">
|
||||
<div
|
||||
v-for="workflow in workflows"
|
||||
:key="workflow.id"
|
||||
class="group cursor-pointer rounded-lg border border-zinc-800 bg-zinc-900 p-2 transition-all hover:border-zinc-700 hover:bg-zinc-800/50"
|
||||
draggable="true"
|
||||
>
|
||||
<div class="mb-1 flex items-center justify-between">
|
||||
<i v-if="workflow.starred" class="pi pi-star-fill text-[10px] text-amber-400" />
|
||||
<i v-else class="pi pi-sitemap text-[10px] text-zinc-600" />
|
||||
<span class="rounded bg-zinc-800 px-1 py-0.5 text-[9px] text-zinc-600">
|
||||
{{ workflow.nodes }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="truncate text-xs text-zinc-400 group-hover:text-zinc-200">
|
||||
{{ workflow.name }}
|
||||
</div>
|
||||
<div class="mt-0.5 truncate text-[10px] text-zinc-600">
|
||||
{{ workflow.updatedAt }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import { ASSETS_DATA } from '@/data/sidebarMockData'
|
||||
|
||||
defineProps<{
|
||||
viewMode: 'list' | 'grid'
|
||||
}>()
|
||||
|
||||
const mockAssets = ASSETS_DATA
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-1">
|
||||
<div
|
||||
v-for="asset in mockAssets"
|
||||
:key="asset.name"
|
||||
class="cursor-pointer rounded px-2 py-1.5 text-xs transition-colors hover:bg-zinc-800"
|
||||
>
|
||||
<i class="pi pi-image mr-2 text-zinc-500" />
|
||||
<span class="text-zinc-300">{{ asset.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
55
ComfyUI_vibe/src/components/v1/sidebar/V1SidebarIconBar.vue
Normal file
55
ComfyUI_vibe/src/components/v1/sidebar/V1SidebarIconBar.vue
Normal file
@@ -0,0 +1,55 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useUiStore, SIDEBAR_TABS, type SidebarTabId } from '@/stores/uiStore'
|
||||
|
||||
const uiStore = useUiStore()
|
||||
const activeSidebarTab = computed(() => uiStore.activeSidebarTab)
|
||||
|
||||
function handleTabClick(tabId: Exclude<SidebarTabId, null>): void {
|
||||
uiStore.toggleSidebarTab(tabId)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav class="flex w-12 flex-col items-center border-r border-zinc-800 bg-zinc-900 py-2">
|
||||
<!-- Tab buttons -->
|
||||
<div class="flex flex-col gap-1">
|
||||
<button
|
||||
v-for="tab in SIDEBAR_TABS"
|
||||
:key="tab.id"
|
||||
v-tooltip.right="{ value: tab.tooltip, showDelay: 50 }"
|
||||
class="flex h-8 w-8 items-center justify-center rounded-md transition-colors"
|
||||
:class="[
|
||||
activeSidebarTab === tab.id
|
||||
? 'bg-zinc-700 text-zinc-100'
|
||||
: 'text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200'
|
||||
]"
|
||||
@click="handleTabClick(tab.id)"
|
||||
>
|
||||
<i :class="[tab.icon, 'text-sm']" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Bottom section -->
|
||||
<div class="mt-auto flex flex-col gap-1">
|
||||
<button
|
||||
v-tooltip.right="{ value: 'Console', showDelay: 50 }"
|
||||
class="flex h-8 w-8 items-center justify-center rounded-md text-zinc-400 transition-colors hover:bg-zinc-800 hover:text-zinc-200"
|
||||
>
|
||||
<i class="pi pi-code text-sm" />
|
||||
</button>
|
||||
<button
|
||||
v-tooltip.right="{ value: 'Settings', showDelay: 50 }"
|
||||
class="flex h-8 w-8 items-center justify-center rounded-md text-zinc-400 transition-colors hover:bg-zinc-800 hover:text-zinc-200"
|
||||
>
|
||||
<i class="pi pi-cog text-sm" />
|
||||
</button>
|
||||
<button
|
||||
v-tooltip.right="{ value: 'Help', showDelay: 50 }"
|
||||
class="flex h-8 w-8 items-center justify-center rounded-md text-zinc-400 transition-colors hover:bg-zinc-800 hover:text-zinc-200"
|
||||
>
|
||||
<i class="pi pi-question-circle text-sm" />
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
@@ -0,0 +1,81 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { SidebarTreeCategory, SidebarGridCard } from '@/components/common/sidebar'
|
||||
import { MODEL_CATEGORIES_DATA, type ModelCategory } from '@/data/sidebarMockData'
|
||||
|
||||
defineProps<{
|
||||
viewMode: 'list' | 'grid'
|
||||
}>()
|
||||
|
||||
const modelCategories = ref<ModelCategory[]>(
|
||||
MODEL_CATEGORIES_DATA.map(c => ({ ...c }))
|
||||
)
|
||||
|
||||
function toggleCategory(categoryId: string): void {
|
||||
const category = modelCategories.value.find(c => c.id === categoryId)
|
||||
if (category) {
|
||||
category.expanded = !category.expanded
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- List View -->
|
||||
<div v-if="viewMode === 'list'" class="space-y-0.5">
|
||||
<div
|
||||
v-for="category in modelCategories"
|
||||
:key="category.id"
|
||||
class="select-none"
|
||||
>
|
||||
<SidebarTreeCategory
|
||||
:icon="category.icon"
|
||||
:label="category.label"
|
||||
:count="category.models.length"
|
||||
:expanded="category.expanded"
|
||||
@toggle="toggleCategory(category.id)"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="category.expanded"
|
||||
class="ml-4 space-y-0.5 border-l border-zinc-800 pl-2"
|
||||
>
|
||||
<div
|
||||
v-for="model in category.models"
|
||||
:key="model.name"
|
||||
class="group flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 transition-colors hover:bg-zinc-800"
|
||||
draggable="true"
|
||||
>
|
||||
<i class="pi pi-file text-[10px] text-zinc-600 group-hover:text-zinc-400" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-xs text-zinc-400 group-hover:text-zinc-200">
|
||||
{{ model.display }}
|
||||
</div>
|
||||
<div class="text-[10px] text-zinc-600">{{ model.size }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Grid View -->
|
||||
<div v-else class="grid grid-cols-2 gap-1.5">
|
||||
<template v-for="category in modelCategories" :key="category.id">
|
||||
<SidebarGridCard
|
||||
v-for="model in category.models"
|
||||
:key="model.name"
|
||||
:title="model.display"
|
||||
:subtitle="category.label"
|
||||
:draggable="true"
|
||||
>
|
||||
<template #header-left>
|
||||
<i :class="[category.icon, 'text-xs text-zinc-500']" />
|
||||
</template>
|
||||
<template #header-right>
|
||||
<span class="rounded bg-zinc-800 px-1 py-0.5 text-[9px] text-zinc-600">
|
||||
{{ model.size }}
|
||||
</span>
|
||||
</template>
|
||||
</SidebarGridCard>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
71
ComfyUI_vibe/src/components/v1/sidebar/V1SidebarNodesTab.vue
Normal file
71
ComfyUI_vibe/src/components/v1/sidebar/V1SidebarNodesTab.vue
Normal file
@@ -0,0 +1,71 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { SidebarTreeCategory, SidebarTreeItem, SidebarGridCard } from '@/components/common/sidebar'
|
||||
import { NODE_CATEGORIES_DATA, type NodeCategory } from '@/data/sidebarMockData'
|
||||
|
||||
defineProps<{
|
||||
viewMode: 'list' | 'grid'
|
||||
}>()
|
||||
|
||||
const nodeCategories = ref<NodeCategory[]>(
|
||||
NODE_CATEGORIES_DATA.map(c => ({ ...c }))
|
||||
)
|
||||
|
||||
function toggleCategory(categoryId: string): void {
|
||||
const category = nodeCategories.value.find(c => c.id === categoryId)
|
||||
if (category) {
|
||||
category.expanded = !category.expanded
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- List View -->
|
||||
<div v-if="viewMode === 'list'" class="space-y-0.5">
|
||||
<div
|
||||
v-for="category in nodeCategories"
|
||||
:key="category.id"
|
||||
class="select-none"
|
||||
>
|
||||
<SidebarTreeCategory
|
||||
:icon="category.icon"
|
||||
:label="category.label"
|
||||
:count="category.nodes.length"
|
||||
:expanded="category.expanded"
|
||||
@toggle="toggleCategory(category.id)"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="category.expanded"
|
||||
class="ml-4 space-y-0.5 border-l border-zinc-800 pl-2"
|
||||
>
|
||||
<SidebarTreeItem
|
||||
v-for="node in category.nodes"
|
||||
:key="node.name"
|
||||
:label="node.display"
|
||||
:draggable="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Grid View -->
|
||||
<div v-else class="grid grid-cols-2 gap-1.5">
|
||||
<template v-for="category in nodeCategories" :key="category.id">
|
||||
<SidebarGridCard
|
||||
v-for="node in category.nodes"
|
||||
:key="node.name"
|
||||
:title="node.display"
|
||||
:subtitle="category.label"
|
||||
:draggable="true"
|
||||
>
|
||||
<template #header-left>
|
||||
<i :class="[category.icon, 'text-xs text-zinc-500']" />
|
||||
</template>
|
||||
<template #header-right>
|
||||
<i class="pi pi-plus text-[10px] text-zinc-600 opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
</template>
|
||||
</SidebarGridCard>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
192
ComfyUI_vibe/src/components/v1/sidebar/V1SidebarPanel.vue
Normal file
192
ComfyUI_vibe/src/components/v1/sidebar/V1SidebarPanel.vue
Normal file
@@ -0,0 +1,192 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import Button from 'primevue/button'
|
||||
import { useUiStore, SIDEBAR_TABS } from '@/stores/uiStore'
|
||||
import { SidebarSearchBox, SidebarViewToggle } from '@/components/common/sidebar'
|
||||
import V1SidebarNodesTab from './V1SidebarNodesTab.vue'
|
||||
import V1SidebarModelsTab from './V1SidebarModelsTab.vue'
|
||||
import V1SidebarWorkflowsTab from './V1SidebarWorkflowsTab.vue'
|
||||
import V1SidebarAssetsTab from './V1SidebarAssetsTab.vue'
|
||||
import V1SidebarTemplatesTab from './V1SidebarTemplatesTab.vue'
|
||||
import LibrarySidebar from '@/components/v2/canvas/LibrarySidebar.vue'
|
||||
|
||||
const uiStore = useUiStore()
|
||||
|
||||
const activeSidebarTab = computed(() => uiStore.activeSidebarTab)
|
||||
const sidebarPanelExpanded = computed(() => uiStore.sidebarPanelExpanded)
|
||||
const searchQuery = ref('')
|
||||
const viewMode = ref<'list' | 'grid'>('list')
|
||||
|
||||
// Sort/filter state
|
||||
const sortBy = ref('name')
|
||||
const showFilterMenu = ref(false)
|
||||
const showSortMenu = ref(false)
|
||||
const activeFilter = ref('All')
|
||||
|
||||
const sortOptions = computed(() => {
|
||||
switch (activeSidebarTab.value) {
|
||||
case 'nodes':
|
||||
return [
|
||||
{ label: 'Name', value: 'name' },
|
||||
{ label: 'Category', value: 'category' },
|
||||
{ label: 'Recently Used', value: 'recent' },
|
||||
]
|
||||
case 'models':
|
||||
return [
|
||||
{ label: 'Name', value: 'name' },
|
||||
{ label: 'Type', value: 'type' },
|
||||
{ label: 'Size', value: 'size' },
|
||||
{ label: 'Date Added', value: 'date' },
|
||||
]
|
||||
case 'workflows':
|
||||
return [
|
||||
{ label: 'Name', value: 'name' },
|
||||
{ label: 'Date Modified', value: 'date' },
|
||||
{ label: 'Node Count', value: 'nodes' },
|
||||
]
|
||||
case 'assets':
|
||||
return [
|
||||
{ label: 'Name', value: 'name' },
|
||||
{ label: 'Type', value: 'type' },
|
||||
{ label: 'Date Added', value: 'date' },
|
||||
]
|
||||
default:
|
||||
return [{ label: 'Name', value: 'name' }]
|
||||
}
|
||||
})
|
||||
|
||||
const filterOptions = computed(() => {
|
||||
switch (activeSidebarTab.value) {
|
||||
case 'nodes':
|
||||
return ['All', 'Core', 'Custom', 'Favorites']
|
||||
case 'models':
|
||||
return ['All', 'Checkpoints', 'LoRAs', 'VAE', 'ControlNet', 'Embeddings']
|
||||
case 'workflows':
|
||||
return ['All', 'Recent', 'Favorites', 'Shared']
|
||||
case 'assets':
|
||||
return ['All', 'Images', 'Masks', 'Videos']
|
||||
default:
|
||||
return ['All']
|
||||
}
|
||||
})
|
||||
|
||||
function setSort(value: string): void {
|
||||
sortBy.value = value
|
||||
showSortMenu.value = false
|
||||
}
|
||||
|
||||
function setFilter(value: string): void {
|
||||
activeFilter.value = value
|
||||
showFilterMenu.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside
|
||||
class="border-r border-zinc-800 bg-zinc-900/95 transition-all duration-200"
|
||||
:class="sidebarPanelExpanded ? 'w-80' : 'w-0 overflow-hidden'"
|
||||
>
|
||||
<!-- Library Tab - Full custom layout -->
|
||||
<LibrarySidebar
|
||||
v-if="sidebarPanelExpanded && activeSidebarTab === 'library'"
|
||||
@close="uiStore.closeSidebarPanel()"
|
||||
/>
|
||||
|
||||
<!-- Other Tabs - Standard layout -->
|
||||
<div v-else-if="sidebarPanelExpanded" class="flex h-full w-80 flex-col">
|
||||
<!-- Panel Header -->
|
||||
<div class="flex items-center justify-between border-b border-zinc-800 px-3 py-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-wide text-zinc-400">
|
||||
{{ SIDEBAR_TABS.find(t => t.id === activeSidebarTab)?.label }}
|
||||
</span>
|
||||
<Button
|
||||
icon="pi pi-times"
|
||||
text
|
||||
severity="secondary"
|
||||
size="small"
|
||||
class="!h-6 !w-6"
|
||||
@click="uiStore.closeSidebarPanel()"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Search & Controls -->
|
||||
<div class="border-b border-zinc-800 p-2">
|
||||
<SidebarSearchBox
|
||||
v-model="searchQuery"
|
||||
:placeholder="`Search ${SIDEBAR_TABS.find(t => t.id === activeSidebarTab)?.label?.toLowerCase()}...`"
|
||||
:show-action="activeSidebarTab === 'workflows'"
|
||||
action-tooltip="Import Workflow"
|
||||
/>
|
||||
|
||||
<!-- View Controls -->
|
||||
<div class="mt-2 flex items-center justify-between">
|
||||
<SidebarViewToggle v-model="viewMode" />
|
||||
|
||||
<!-- Filter & Sort -->
|
||||
<div class="flex items-center gap-1">
|
||||
<!-- Filter Dropdown -->
|
||||
<div class="relative">
|
||||
<button
|
||||
class="flex h-6 items-center gap-1 rounded bg-zinc-800 px-2 text-[10px] text-zinc-400 transition-colors hover:bg-zinc-700 hover:text-zinc-200"
|
||||
@click="showFilterMenu = !showFilterMenu"
|
||||
>
|
||||
<i class="pi pi-filter text-[10px]" />
|
||||
<span>{{ activeFilter }}</span>
|
||||
<i class="pi pi-chevron-down text-[8px]" />
|
||||
</button>
|
||||
<div
|
||||
v-if="showFilterMenu"
|
||||
class="absolute left-0 top-full z-50 mt-1 min-w-[120px] rounded-lg border border-zinc-700 bg-zinc-900 py-1 shadow-xl"
|
||||
>
|
||||
<button
|
||||
v-for="option in filterOptions"
|
||||
:key="option"
|
||||
class="flex w-full items-center px-3 py-1.5 text-left text-xs transition-colors"
|
||||
:class="activeFilter === option ? 'bg-zinc-800 text-zinc-200' : 'text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200'"
|
||||
@click="setFilter(option)"
|
||||
>
|
||||
{{ option }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sort Dropdown -->
|
||||
<div class="relative">
|
||||
<button
|
||||
class="flex h-6 items-center gap-1 rounded bg-zinc-800 px-2 text-[10px] text-zinc-400 transition-colors hover:bg-zinc-700 hover:text-zinc-200"
|
||||
@click="showSortMenu = !showSortMenu"
|
||||
>
|
||||
<i class="pi pi-sort-alt text-[10px]" />
|
||||
<span>{{ sortOptions.find(o => o.value === sortBy)?.label }}</span>
|
||||
<i class="pi pi-chevron-down text-[8px]" />
|
||||
</button>
|
||||
<div
|
||||
v-if="showSortMenu"
|
||||
class="absolute right-0 top-full z-50 mt-1 min-w-[120px] rounded-lg border border-zinc-700 bg-zinc-900 py-1 shadow-xl"
|
||||
>
|
||||
<button
|
||||
v-for="option in sortOptions"
|
||||
:key="option.value"
|
||||
class="flex w-full items-center px-3 py-1.5 text-left text-xs transition-colors"
|
||||
:class="sortBy === option.value ? 'bg-zinc-800 text-zinc-200' : 'text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200'"
|
||||
@click="setSort(option.value)"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Panel Content -->
|
||||
<div class="flex-1 overflow-y-auto p-2">
|
||||
<V1SidebarNodesTab v-if="activeSidebarTab === 'nodes'" :view-mode="viewMode" />
|
||||
<V1SidebarModelsTab v-else-if="activeSidebarTab === 'models'" :view-mode="viewMode" />
|
||||
<V1SidebarWorkflowsTab v-else-if="activeSidebarTab === 'workflows'" :view-mode="viewMode" />
|
||||
<V1SidebarAssetsTab v-else-if="activeSidebarTab === 'assets'" :view-mode="viewMode" />
|
||||
<V1SidebarTemplatesTab v-else-if="activeSidebarTab === 'templates'" :view-mode="viewMode" />
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
@@ -0,0 +1,85 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { SidebarTreeCategory, SidebarGridCard } from '@/components/common/sidebar'
|
||||
import { TEMPLATE_CATEGORIES_DATA, type TemplateCategory } from '@/data/sidebarMockData'
|
||||
|
||||
defineProps<{
|
||||
viewMode: 'list' | 'grid'
|
||||
}>()
|
||||
|
||||
const templateCategories = ref<TemplateCategory[]>(
|
||||
TEMPLATE_CATEGORIES_DATA.map(c => ({ ...c }))
|
||||
)
|
||||
|
||||
function toggleCategory(categoryId: string): void {
|
||||
const category = templateCategories.value.find(c => c.id === categoryId)
|
||||
if (category) {
|
||||
category.expanded = !category.expanded
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- List View -->
|
||||
<div v-if="viewMode === 'list'" class="space-y-0.5">
|
||||
<div
|
||||
v-for="category in templateCategories"
|
||||
:key="category.id"
|
||||
class="select-none"
|
||||
>
|
||||
<SidebarTreeCategory
|
||||
:icon="category.icon"
|
||||
:label="category.label"
|
||||
:count="category.templates.length"
|
||||
:expanded="category.expanded"
|
||||
@toggle="toggleCategory(category.id)"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="category.expanded"
|
||||
class="ml-4 space-y-0.5 border-l border-zinc-800 pl-2"
|
||||
>
|
||||
<div
|
||||
v-for="template in category.templates"
|
||||
:key="template.name"
|
||||
class="group flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 transition-colors hover:bg-zinc-800"
|
||||
draggable="true"
|
||||
>
|
||||
<i class="pi pi-clone text-[10px] text-zinc-600 group-hover:text-zinc-400" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-xs text-zinc-400 group-hover:text-zinc-200">
|
||||
{{ template.display }}
|
||||
</div>
|
||||
<div class="truncate text-[10px] text-zinc-600">{{ template.description }}</div>
|
||||
</div>
|
||||
<span class="rounded bg-zinc-800 px-1.5 py-0.5 text-[10px] text-zinc-600">
|
||||
{{ template.nodes }}
|
||||
</span>
|
||||
<i class="pi pi-plus text-[10px] text-zinc-600 opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Grid View -->
|
||||
<div v-else class="grid grid-cols-2 gap-1.5">
|
||||
<template v-for="category in templateCategories" :key="category.id">
|
||||
<SidebarGridCard
|
||||
v-for="template in category.templates"
|
||||
:key="template.name"
|
||||
:title="template.display"
|
||||
:subtitle="template.description"
|
||||
:draggable="true"
|
||||
>
|
||||
<template #header-left>
|
||||
<i :class="[category.icon, 'text-xs text-zinc-500']" />
|
||||
</template>
|
||||
<template #header-right>
|
||||
<span class="rounded bg-zinc-800 px-1 py-0.5 text-[9px] text-zinc-600">
|
||||
{{ template.nodes }} nodes
|
||||
</span>
|
||||
</template>
|
||||
</SidebarGridCard>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,57 @@
|
||||
<script setup lang="ts">
|
||||
import { WORKFLOWS_DATA } from '@/data/sidebarMockData'
|
||||
|
||||
defineProps<{
|
||||
viewMode: 'list' | 'grid'
|
||||
}>()
|
||||
|
||||
const mockWorkflows = WORKFLOWS_DATA
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
v-for="workflow in mockWorkflows"
|
||||
:key="workflow.name"
|
||||
class="group cursor-pointer overflow-hidden rounded-lg border border-zinc-800 bg-zinc-900 transition-all hover:border-zinc-700 hover:bg-zinc-800/50"
|
||||
>
|
||||
<!-- Thumbnail (16:9) -->
|
||||
<div class="relative aspect-video bg-zinc-950">
|
||||
<div
|
||||
class="absolute inset-0 flex items-center justify-center"
|
||||
:class="{
|
||||
'bg-gradient-to-br from-blue-900/30 to-purple-900/30': workflow.thumbnail === 'txt2img',
|
||||
'bg-gradient-to-br from-green-900/30 to-teal-900/30': workflow.thumbnail === 'img2img',
|
||||
'bg-gradient-to-br from-orange-900/30 to-red-900/30': workflow.thumbnail === 'controlnet',
|
||||
'bg-gradient-to-br from-violet-900/30 to-pink-900/30': workflow.thumbnail === 'sdxl',
|
||||
'bg-gradient-to-br from-cyan-900/30 to-blue-900/30': workflow.thumbnail === 'inpaint',
|
||||
}"
|
||||
>
|
||||
<i class="pi pi-sitemap text-2xl text-zinc-700" />
|
||||
</div>
|
||||
<button
|
||||
v-tooltip.left="{ value: 'Share', showDelay: 50 }"
|
||||
class="absolute right-1.5 top-1.5 flex h-6 w-6 items-center justify-center rounded bg-zinc-800/90 text-zinc-400 transition-colors hover:bg-zinc-700 hover:text-zinc-200"
|
||||
>
|
||||
<i class="pi pi-share-alt text-[10px]" />
|
||||
</button>
|
||||
<div class="absolute bottom-1.5 left-1.5 rounded bg-zinc-900/80 px-1.5 py-0.5 text-[10px] text-zinc-400">
|
||||
{{ workflow.nodes }} nodes
|
||||
</div>
|
||||
</div>
|
||||
<!-- Info -->
|
||||
<div class="flex items-center justify-between px-2.5 py-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-xs font-medium text-zinc-300">{{ workflow.name }}</div>
|
||||
<div class="mt-0.5 text-[10px] text-zinc-500">{{ workflow.date }}</div>
|
||||
</div>
|
||||
<button
|
||||
v-tooltip.left="{ value: 'Add to Canvas', showDelay: 50 }"
|
||||
class="ml-2 flex h-6 w-6 shrink-0 items-center justify-center rounded bg-blue-600 text-white transition-all hover:bg-blue-500"
|
||||
>
|
||||
<i class="pi pi-plus text-[10px]" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
7
ComfyUI_vibe/src/components/v1/sidebar/index.ts
Normal file
7
ComfyUI_vibe/src/components/v1/sidebar/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export { default as V1SidebarPanel } from './V1SidebarPanel.vue'
|
||||
export { default as V1SidebarIconBar } from './V1SidebarIconBar.vue'
|
||||
export { default as V1SidebarNodesTab } from './V1SidebarNodesTab.vue'
|
||||
export { default as V1SidebarModelsTab } from './V1SidebarModelsTab.vue'
|
||||
export { default as V1SidebarWorkflowsTab } from './V1SidebarWorkflowsTab.vue'
|
||||
export { default as V1SidebarAssetsTab } from './V1SidebarAssetsTab.vue'
|
||||
export { default as V1SidebarTemplatesTab } from './V1SidebarTemplatesTab.vue'
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { ref, computed } from 'vue'
|
||||
import Button from 'primevue/button'
|
||||
import { useUiStore, BOTTOM_BAR_TABS, type SidebarTabId } from '@/stores/uiStore'
|
||||
|
||||
@@ -8,37 +8,136 @@ const uiStore = useUiStore()
|
||||
const activeBottomTab = computed(() => uiStore.activeBottomTab)
|
||||
const bottomPanelExpanded = computed(() => uiStore.bottomPanelExpanded)
|
||||
|
||||
// Panel state
|
||||
const searchQuery = ref('')
|
||||
const activeFilter = ref('all')
|
||||
const isExtended = ref(false)
|
||||
const showSidebar = ref(true)
|
||||
|
||||
function handleTabClick(tabId: Exclude<SidebarTabId, null>): void {
|
||||
uiStore.toggleBottomTab(tabId)
|
||||
}
|
||||
|
||||
// Filter tabs based on active tab
|
||||
const filterTabs = computed(() => {
|
||||
switch (activeBottomTab.value) {
|
||||
case 'models':
|
||||
return ['All', 'Checkpoints', 'LoRA', 'VAE', 'Embeddings']
|
||||
case 'workflows':
|
||||
return ['All', 'Recent', 'Favorites', 'Shared']
|
||||
case 'assets':
|
||||
return ['All', 'Images', 'Masks', 'Videos']
|
||||
case 'templates':
|
||||
return ['All', 'Official', 'SDXL', 'ControlNet', 'Video', 'Community']
|
||||
case 'packages':
|
||||
return ['All', 'Installed', 'Updates', 'Popular']
|
||||
default:
|
||||
return ['All']
|
||||
}
|
||||
})
|
||||
|
||||
// Sidebar categories based on active tab
|
||||
const sidebarCategories = computed(() => {
|
||||
switch (activeBottomTab.value) {
|
||||
case 'models':
|
||||
return [
|
||||
{ id: 'checkpoints', label: 'Checkpoints', icon: 'pi-box', count: 12 },
|
||||
{ id: 'lora', label: 'LoRA', icon: 'pi-bolt', count: 24 },
|
||||
{ id: 'vae', label: 'VAE', icon: 'pi-sliders-h', count: 3 },
|
||||
{ id: 'embeddings', label: 'Embeddings', icon: 'pi-tag', count: 8 },
|
||||
{ id: 'controlnet', label: 'ControlNet', icon: 'pi-sitemap', count: 6 },
|
||||
{ id: 'upscalers', label: 'Upscalers', icon: 'pi-arrow-up-right', count: 4 },
|
||||
]
|
||||
case 'workflows':
|
||||
return [
|
||||
{ id: 'recent', label: 'Recent', icon: 'pi-clock', count: 8 },
|
||||
{ id: 'favorites', label: 'Favorites', icon: 'pi-star', count: 5 },
|
||||
{ id: 'shared', label: 'Shared with me', icon: 'pi-users', count: 3 },
|
||||
{ id: 'templates', label: 'From Templates', icon: 'pi-copy', count: 12 },
|
||||
]
|
||||
case 'assets':
|
||||
return [
|
||||
{ id: 'images', label: 'Images', icon: 'pi-image', count: 45 },
|
||||
{ id: 'masks', label: 'Masks', icon: 'pi-circle', count: 8 },
|
||||
{ id: 'videos', label: 'Videos', icon: 'pi-video', count: 3 },
|
||||
{ id: 'audio', label: 'Audio', icon: 'pi-volume-up', count: 2 },
|
||||
]
|
||||
case 'templates':
|
||||
return [
|
||||
{ id: 'official', label: 'Official', icon: 'pi-verified', count: 15 },
|
||||
{ id: 'sdxl', label: 'SDXL', icon: 'pi-star', count: 8 },
|
||||
{ id: 'controlnet', label: 'ControlNet', icon: 'pi-sitemap', count: 12 },
|
||||
{ id: 'video', label: 'Video', icon: 'pi-video', count: 6 },
|
||||
{ id: 'community', label: 'Community', icon: 'pi-users', count: 42 },
|
||||
]
|
||||
case 'packages':
|
||||
return [
|
||||
{ id: 'installed', label: 'Installed', icon: 'pi-check-circle', count: 8 },
|
||||
{ id: 'updates', label: 'Updates', icon: 'pi-refresh', count: 2 },
|
||||
{ id: 'popular', label: 'Popular', icon: 'pi-chart-line', count: 50 },
|
||||
{ id: 'new', label: 'New', icon: 'pi-sparkles', count: 12 },
|
||||
]
|
||||
default:
|
||||
return []
|
||||
}
|
||||
})
|
||||
|
||||
// Mock data for panel content
|
||||
const mockModels = [
|
||||
{ name: 'SD 1.5', type: 'Checkpoint' },
|
||||
{ name: 'SDXL Base', type: 'Checkpoint' },
|
||||
{ name: 'Realistic Vision', type: 'Checkpoint' },
|
||||
{ name: 'DreamShaper', type: 'LoRA' },
|
||||
{ name: 'SD 1.5', type: 'Checkpoint', size: '4.2 GB', updated: '2 days ago' },
|
||||
{ name: 'SDXL Base', type: 'Checkpoint', size: '6.9 GB', updated: '1 week ago' },
|
||||
{ name: 'Realistic Vision v5.1', type: 'Checkpoint', size: '4.1 GB', updated: '3 days ago' },
|
||||
{ name: 'DreamShaper v8', type: 'Checkpoint', size: '4.2 GB', updated: '5 days ago' },
|
||||
{ name: 'Detail Tweaker LoRA', type: 'LoRA', size: '144 MB', updated: '1 day ago' },
|
||||
{ name: 'epiNoiseoffset', type: 'LoRA', size: '151 MB', updated: '4 days ago' },
|
||||
{ name: 'Juggernaut XL', type: 'Checkpoint', size: '6.5 GB', updated: '1 day ago' },
|
||||
{ name: 'Flat2D Anime', type: 'LoRA', size: '220 MB', updated: '3 days ago' },
|
||||
]
|
||||
|
||||
const mockWorkflows = [
|
||||
{ name: 'Basic txt2img', date: '2024-01-15' },
|
||||
{ name: 'Img2Img Pipeline', date: '2024-01-14' },
|
||||
{ name: 'ControlNet Setup', date: '2024-01-13' },
|
||||
{ name: 'Basic txt2img', date: '2024-01-15', nodes: 6, author: 'You' },
|
||||
{ name: 'Img2Img Pipeline', date: '2024-01-14', nodes: 8, author: 'You' },
|
||||
{ name: 'ControlNet Setup', date: '2024-01-13', nodes: 12, author: 'You' },
|
||||
{ name: 'Upscale Workflow', date: '2024-01-12', nodes: 5, author: 'You' },
|
||||
{ name: 'AnimateDiff Motion', date: '2024-01-11', nodes: 18, author: 'You' },
|
||||
{ name: 'Inpainting Pro', date: '2024-01-10', nodes: 10, author: 'You' },
|
||||
]
|
||||
|
||||
const mockAssets = [
|
||||
{ name: 'reference_01.png', type: 'image' },
|
||||
{ name: 'mask_template.png', type: 'image' },
|
||||
{ name: 'init_image.jpg', type: 'image' },
|
||||
{ name: 'reference_01.png', type: 'image', size: '2.4 MB' },
|
||||
{ name: 'mask_template.png', type: 'image', size: '156 KB' },
|
||||
{ name: 'init_image.jpg', type: 'image', size: '1.8 MB' },
|
||||
{ name: 'depth_map.png', type: 'image', size: '512 KB' },
|
||||
{ name: 'controlnet_pose.png', type: 'image', size: '890 KB' },
|
||||
{ name: 'background.jpg', type: 'image', size: '3.2 MB' },
|
||||
{ name: 'style_ref.png', type: 'image', size: '1.1 MB' },
|
||||
{ name: 'sketch_input.png', type: 'image', size: '420 KB' },
|
||||
]
|
||||
|
||||
const mockTemplates = [
|
||||
{ name: 'Text to Image (Basic)', category: 'Official', nodes: 6, color: '#64B5F6' },
|
||||
{ name: 'Image to Image', category: 'Official', nodes: 8, color: '#64B5F6' },
|
||||
{ name: 'SDXL + Refiner', category: 'SDXL', nodes: 14, color: '#B39DDB' },
|
||||
{ name: 'SDXL Lightning', category: 'SDXL', nodes: 9, color: '#B39DDB' },
|
||||
{ name: 'Canny Edge', category: 'ControlNet', nodes: 12, color: '#FFAB40' },
|
||||
{ name: 'Depth Map', category: 'ControlNet', nodes: 12, color: '#FFAB40' },
|
||||
{ name: 'Text to Image (Basic)', category: 'Official', nodes: 6, color: '#64B5F6', desc: 'Simple text-to-image workflow' },
|
||||
{ name: 'Image to Image', category: 'Official', nodes: 8, color: '#64B5F6', desc: 'Transform existing images' },
|
||||
{ name: 'SDXL + Refiner', category: 'SDXL', nodes: 14, color: '#B39DDB', desc: 'High-quality SDXL generation' },
|
||||
{ name: 'SDXL Lightning', category: 'SDXL', nodes: 9, color: '#B39DDB', desc: 'Fast SDXL generation' },
|
||||
{ name: 'Canny Edge', category: 'ControlNet', nodes: 12, color: '#FFAB40', desc: 'Edge-guided generation' },
|
||||
{ name: 'Depth Map', category: 'ControlNet', nodes: 12, color: '#FFAB40', desc: 'Depth-guided generation' },
|
||||
{ name: 'AnimateDiff Basic', category: 'Video', nodes: 18, color: '#81C784', desc: 'Simple animation workflow' },
|
||||
{ name: 'Community Upscaler', category: 'Community', nodes: 7, color: '#F48FB1', desc: 'Enhanced upscaling' },
|
||||
{ name: 'Face Restore', category: 'Official', nodes: 5, color: '#64B5F6', desc: 'Restore faces in images' },
|
||||
]
|
||||
|
||||
const mockPackages = [
|
||||
{ name: 'ComfyUI-Manager', author: 'ltdrdata', version: '2.1.0', nodes: 15, installed: true, desc: 'Package manager for ComfyUI' },
|
||||
{ name: 'ComfyUI-Impact-Pack', author: 'ltdrdata', version: '4.5.2', nodes: 45, installed: true, desc: 'Detection and segmentation' },
|
||||
{ name: 'ComfyUI-Controlnet-Aux', author: 'Fannovel16', version: '1.2.0', nodes: 28, installed: true, desc: 'ControlNet preprocessors' },
|
||||
{ name: 'ComfyUI-AnimateDiff', author: 'Kosinkadink', version: '0.9.1', nodes: 12, installed: false, desc: 'Animation generation' },
|
||||
{ name: 'ComfyUI-VideoHelperSuite', author: 'Kosinkadink', version: '1.0.0', nodes: 8, installed: false, desc: 'Video processing tools' },
|
||||
]
|
||||
|
||||
const mockRecents = [
|
||||
{ name: 'Upscale 4x', type: 'action', icon: 'pi-arrow-up-right' },
|
||||
{ name: 'SDXL Base', type: 'model', icon: 'pi-box' },
|
||||
{ name: 'Basic txt2img', type: 'workflow', icon: 'pi-share-alt' },
|
||||
]
|
||||
</script>
|
||||
|
||||
@@ -47,116 +146,298 @@ const mockTemplates = [
|
||||
<!-- Expandable Panel (above tabs) -->
|
||||
<div
|
||||
v-if="bottomPanelExpanded"
|
||||
class="bottom-panel w-[600px] rounded-xl border border-zinc-800 bg-zinc-900/95 shadow-2xl backdrop-blur"
|
||||
class="bottom-panel flex overflow-hidden rounded-xl border border-zinc-800 bg-zinc-900/95 shadow-2xl backdrop-blur transition-all duration-300"
|
||||
:style="{
|
||||
width: isExtended ? 'calc(100vw - 100px)' : '720px',
|
||||
maxWidth: isExtended ? '1400px' : '720px',
|
||||
height: isExtended ? 'calc(100vh - 200px)' : 'auto',
|
||||
maxHeight: isExtended ? '800px' : 'none'
|
||||
}"
|
||||
>
|
||||
<!-- Panel Header -->
|
||||
<div class="flex items-center justify-between border-b border-zinc-800 px-4 py-2">
|
||||
<span class="text-sm font-medium text-zinc-300">
|
||||
{{ BOTTOM_BAR_TABS.find(t => t.id === activeBottomTab)?.label }}
|
||||
</span>
|
||||
<Button
|
||||
icon="pi pi-times"
|
||||
text
|
||||
severity="secondary"
|
||||
size="small"
|
||||
class="!h-6 !w-6"
|
||||
@click="uiStore.closeBottomPanel()"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Search Box -->
|
||||
<div class="border-b border-zinc-800 p-3">
|
||||
<div class="flex items-center rounded-lg bg-zinc-800 px-3 py-2">
|
||||
<i class="pi pi-search text-sm text-zinc-500" />
|
||||
<input
|
||||
type="text"
|
||||
:placeholder="`Search ${BOTTOM_BAR_TABS.find(t => t.id === activeBottomTab)?.label?.toLowerCase()}...`"
|
||||
class="ml-2 w-full bg-transparent text-sm text-zinc-300 outline-none placeholder:text-zinc-500"
|
||||
/>
|
||||
<!-- Left Sidebar -->
|
||||
<div
|
||||
v-if="showSidebar"
|
||||
class="flex w-48 shrink-0 flex-col border-r border-zinc-800 bg-zinc-900/50"
|
||||
>
|
||||
<div class="p-3">
|
||||
<div class="text-[10px] font-semibold uppercase tracking-wider text-zinc-500">Categories</div>
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto px-2 pb-3">
|
||||
<button
|
||||
v-for="cat in sidebarCategories"
|
||||
:key="cat.id"
|
||||
class="flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-xs transition-colors hover:bg-zinc-800"
|
||||
:class="activeFilter === cat.id ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-400'"
|
||||
@click="activeFilter = cat.id"
|
||||
>
|
||||
<i :class="['pi', cat.icon, 'text-[11px]']" />
|
||||
<span class="flex-1 truncate">{{ cat.label }}</span>
|
||||
<span class="rounded bg-zinc-700/50 px-1.5 py-0.5 text-[10px] text-zinc-500">{{ cat.count }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Panel Content -->
|
||||
<div class="max-h-64 overflow-y-auto p-3">
|
||||
<!-- Models Tab -->
|
||||
<div v-if="activeBottomTab === 'models'" class="grid grid-cols-2 gap-2">
|
||||
<div
|
||||
v-for="model in mockModels"
|
||||
:key="model.name"
|
||||
class="cursor-pointer rounded-lg border border-zinc-800 bg-zinc-800/50 p-3 transition-colors hover:border-zinc-700 hover:bg-zinc-800"
|
||||
>
|
||||
<div class="text-sm text-zinc-200">{{ model.name }}</div>
|
||||
<div class="text-xs text-zinc-500">{{ model.type }}</div>
|
||||
<!-- Main Content Area -->
|
||||
<div class="flex flex-1 flex-col min-w-0">
|
||||
<!-- Panel Header -->
|
||||
<div class="flex items-center justify-between border-b border-zinc-800 px-4 py-2.5">
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Sidebar toggle -->
|
||||
<button
|
||||
class="flex h-7 w-7 items-center justify-center rounded-md transition-colors"
|
||||
:class="showSidebar ? 'bg-zinc-700 text-zinc-100' : 'text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200'"
|
||||
v-tooltip.top="showSidebar ? 'Hide sidebar' : 'Show sidebar'"
|
||||
@click="showSidebar = !showSidebar"
|
||||
>
|
||||
<i class="pi pi-bars text-xs" />
|
||||
</button>
|
||||
<div class="h-4 w-px bg-zinc-700" />
|
||||
<span class="text-sm font-medium text-zinc-200">
|
||||
{{ BOTTOM_BAR_TABS.find(t => t.id === activeBottomTab)?.label }}
|
||||
</span>
|
||||
<!-- Scan/Refresh action -->
|
||||
<button
|
||||
v-if="activeBottomTab === 'models' || activeBottomTab === 'packages'"
|
||||
class="flex items-center gap-1.5 rounded-md bg-zinc-800 px-2 py-1 text-[11px] text-zinc-400 transition-colors hover:bg-zinc-700 hover:text-zinc-200"
|
||||
>
|
||||
<i class="pi pi-sync text-[10px]" />
|
||||
Scan
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<!-- View toggle -->
|
||||
<button
|
||||
class="flex h-7 w-7 items-center justify-center rounded-md text-zinc-400 transition-colors hover:bg-zinc-800 hover:text-zinc-200"
|
||||
v-tooltip.top="'Grid view'"
|
||||
>
|
||||
<i class="pi pi-th-large text-xs" />
|
||||
</button>
|
||||
<button
|
||||
class="flex h-7 w-7 items-center justify-center rounded-md text-zinc-400 transition-colors hover:bg-zinc-800 hover:text-zinc-200"
|
||||
v-tooltip.top="'List view'"
|
||||
>
|
||||
<i class="pi pi-list text-xs" />
|
||||
</button>
|
||||
<div class="mx-1 h-4 w-px bg-zinc-700" />
|
||||
<button
|
||||
class="flex h-7 w-7 items-center justify-center rounded-md transition-colors"
|
||||
:class="isExtended ? 'bg-zinc-700 text-zinc-100' : 'text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200'"
|
||||
v-tooltip.top="isExtended ? 'Collapse' : 'Expand'"
|
||||
@click="isExtended = !isExtended"
|
||||
>
|
||||
<i :class="['pi text-xs', isExtended ? 'pi-window-minimize' : 'pi-window-maximize']" />
|
||||
</button>
|
||||
<Button
|
||||
icon="pi pi-times"
|
||||
text
|
||||
severity="secondary"
|
||||
size="small"
|
||||
class="!h-7 !w-7"
|
||||
@click="uiStore.closeBottomPanel()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Workflows Tab -->
|
||||
<div v-else-if="activeBottomTab === 'workflows'" class="grid grid-cols-2 gap-2">
|
||||
<div
|
||||
v-for="workflow in mockWorkflows"
|
||||
:key="workflow.name"
|
||||
class="cursor-pointer rounded-lg border border-zinc-800 bg-zinc-800/50 p-3 transition-colors hover:border-zinc-700 hover:bg-zinc-800"
|
||||
>
|
||||
<div class="text-sm text-zinc-200">{{ workflow.name }}</div>
|
||||
<div class="text-xs text-zinc-500">{{ workflow.date }}</div>
|
||||
<!-- Search & Filter Bar -->
|
||||
<div class="flex items-center gap-3 border-b border-zinc-800 px-4 py-2.5">
|
||||
<!-- Search -->
|
||||
<div class="flex flex-1 items-center rounded-lg bg-zinc-800 px-3 py-2">
|
||||
<i class="pi pi-search text-sm text-zinc-500" />
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
:placeholder="`Search ${BOTTOM_BAR_TABS.find(t => t.id === activeBottomTab)?.label?.toLowerCase()}...`"
|
||||
class="ml-2 w-full bg-transparent text-sm text-zinc-300 outline-none placeholder:text-zinc-500"
|
||||
/>
|
||||
<kbd v-if="!searchQuery" class="rounded bg-zinc-700 px-1.5 py-0.5 text-[10px] text-zinc-500">⌘K</kbd>
|
||||
</div>
|
||||
|
||||
<!-- Sort dropdown -->
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-[11px] text-zinc-500">Sort:</span>
|
||||
<button class="flex items-center gap-1 rounded-md bg-zinc-800 px-2 py-1.5 text-[11px] text-zinc-300 transition-colors hover:bg-zinc-700">
|
||||
Recent
|
||||
<i class="pi pi-chevron-down text-[10px] text-zinc-500" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Assets Tab -->
|
||||
<div v-else-if="activeBottomTab === 'assets'" class="grid grid-cols-3 gap-2">
|
||||
<div
|
||||
v-for="asset in mockAssets"
|
||||
:key="asset.name"
|
||||
class="cursor-pointer rounded-lg border border-zinc-800 bg-zinc-800/50 p-3 transition-colors hover:border-zinc-700 hover:bg-zinc-800"
|
||||
<!-- Filter Tabs (only show if sidebar is hidden) -->
|
||||
<div v-if="!showSidebar" class="flex items-center gap-1 border-b border-zinc-800 px-4 py-2">
|
||||
<button
|
||||
v-for="filter in filterTabs"
|
||||
:key="filter"
|
||||
class="rounded-md px-2.5 py-1 text-[11px] font-medium transition-colors"
|
||||
:class="[
|
||||
activeFilter === filter.toLowerCase()
|
||||
? 'bg-zinc-700 text-zinc-100'
|
||||
: 'text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200'
|
||||
]"
|
||||
@click="activeFilter = filter.toLowerCase()"
|
||||
>
|
||||
<div class="mb-2 flex h-16 items-center justify-center rounded bg-zinc-700">
|
||||
<i class="pi pi-image text-2xl text-zinc-500" />
|
||||
</div>
|
||||
<div class="truncate text-xs text-zinc-300">{{ asset.name }}</div>
|
||||
{{ filter }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Recents Row (for some tabs) -->
|
||||
<div v-if="(activeBottomTab === 'models' || activeBottomTab === 'workflows') && !isExtended" class="border-b border-zinc-800 px-4 py-2.5">
|
||||
<div class="mb-2 text-[11px] font-medium uppercase tracking-wide text-zinc-500">Recents</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
v-for="recent in mockRecents"
|
||||
:key="recent.name"
|
||||
class="flex items-center gap-2 rounded-lg bg-zinc-800/50 px-3 py-1.5 text-xs text-zinc-300 transition-colors hover:bg-zinc-800"
|
||||
>
|
||||
<i :class="['pi', recent.icon, 'text-[10px] text-zinc-500']" />
|
||||
{{ recent.name }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Templates Tab -->
|
||||
<div v-else-if="activeBottomTab === 'templates'" class="grid grid-cols-2 gap-2">
|
||||
<!-- Panel Content -->
|
||||
<div
|
||||
class="flex-1 overflow-y-auto p-4"
|
||||
:style="{ maxHeight: isExtended ? 'none' : '400px' }"
|
||||
>
|
||||
<!-- Unified Grid for all content tabs -->
|
||||
<div
|
||||
v-for="template in mockTemplates"
|
||||
:key="template.name"
|
||||
class="group cursor-pointer rounded-lg border border-zinc-800 bg-zinc-800/50 p-3 transition-colors hover:border-zinc-700 hover:bg-zinc-800"
|
||||
v-if="activeBottomTab !== 'library'"
|
||||
class="grid gap-3"
|
||||
:class="isExtended ? 'grid-cols-6' : 'grid-cols-4'"
|
||||
>
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<span
|
||||
class="rounded px-1.5 py-0.5 text-[10px] font-medium"
|
||||
:style="{ backgroundColor: template.color + '20', color: template.color }"
|
||||
<!-- Models Cards -->
|
||||
<template v-if="activeBottomTab === 'models'">
|
||||
<div
|
||||
v-for="model in mockModels"
|
||||
:key="model.name"
|
||||
class="card-item group"
|
||||
>
|
||||
{{ template.category }}
|
||||
</span>
|
||||
<span class="text-[10px] text-zinc-500">{{ template.nodes }} nodes</span>
|
||||
</div>
|
||||
<div class="text-sm text-zinc-200 group-hover:text-white">{{ template.name }}</div>
|
||||
<div class="mt-2 flex justify-end">
|
||||
<button class="flex h-6 w-6 items-center justify-center rounded bg-zinc-700 text-zinc-400 transition-colors hover:bg-blue-600 hover:text-white">
|
||||
<i class="pi pi-plus text-[10px]" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-preview">
|
||||
<i class="pi pi-box text-2xl text-zinc-500" />
|
||||
<button class="card-menu">
|
||||
<i class="pi pi-ellipsis-v text-[10px]" />
|
||||
</button>
|
||||
<span class="card-badge">{{ model.type }}</span>
|
||||
</div>
|
||||
<div class="card-info">
|
||||
<div class="card-title">{{ model.name }}</div>
|
||||
<div class="card-meta">{{ model.size }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Library Tab -->
|
||||
<div v-else-if="activeBottomTab === 'library'" class="flex flex-col items-center justify-center py-8 text-zinc-500">
|
||||
<i class="pi pi-bookmark mb-2 text-3xl" />
|
||||
<span class="text-sm">Bookmarked items will appear here</span>
|
||||
<!-- Workflows Cards -->
|
||||
<template v-else-if="activeBottomTab === 'workflows'">
|
||||
<div
|
||||
v-for="workflow in mockWorkflows"
|
||||
:key="workflow.name"
|
||||
class="card-item group"
|
||||
>
|
||||
<div class="card-preview">
|
||||
<i class="pi pi-share-alt text-2xl text-zinc-500" />
|
||||
<button class="card-menu">
|
||||
<i class="pi pi-ellipsis-v text-[10px]" />
|
||||
</button>
|
||||
<span class="card-badge">{{ workflow.nodes }} nodes</span>
|
||||
</div>
|
||||
<div class="card-info">
|
||||
<div class="card-title">{{ workflow.name }}</div>
|
||||
<div class="card-meta">{{ workflow.date }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Assets Cards -->
|
||||
<template v-else-if="activeBottomTab === 'assets'">
|
||||
<div
|
||||
v-for="asset in mockAssets"
|
||||
:key="asset.name"
|
||||
class="card-item group"
|
||||
>
|
||||
<div class="card-preview">
|
||||
<i class="pi pi-image text-2xl text-zinc-500" />
|
||||
<button class="card-menu">
|
||||
<i class="pi pi-ellipsis-v text-[10px]" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-info">
|
||||
<div class="card-title">{{ asset.name }}</div>
|
||||
<div class="card-meta">{{ asset.size }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Templates Cards -->
|
||||
<template v-else-if="activeBottomTab === 'templates'">
|
||||
<div
|
||||
v-for="template in mockTemplates"
|
||||
:key="template.name"
|
||||
class="card-item group"
|
||||
>
|
||||
<div class="card-preview">
|
||||
<i class="pi pi-copy text-2xl text-zinc-500" />
|
||||
<button class="card-menu">
|
||||
<i class="pi pi-ellipsis-v text-[10px]" />
|
||||
</button>
|
||||
<span
|
||||
class="card-badge"
|
||||
:style="{ backgroundColor: template.color + '30', color: template.color }"
|
||||
>
|
||||
{{ template.category }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="card-info">
|
||||
<div class="card-title">{{ template.name }}</div>
|
||||
<div class="card-meta">{{ template.nodes }} nodes</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Packages Cards -->
|
||||
<template v-else-if="activeBottomTab === 'packages'">
|
||||
<div
|
||||
v-for="pkg in mockPackages"
|
||||
:key="pkg.name"
|
||||
class="card-item group"
|
||||
>
|
||||
<div class="card-preview">
|
||||
<i class="pi pi-th-large text-2xl text-zinc-500" />
|
||||
<button class="card-menu">
|
||||
<i class="pi pi-ellipsis-v text-[10px]" />
|
||||
</button>
|
||||
<span
|
||||
v-if="pkg.installed"
|
||||
class="card-badge bg-green-500/20 text-green-400"
|
||||
>
|
||||
Installed
|
||||
</span>
|
||||
</div>
|
||||
<div class="card-info">
|
||||
<div class="card-title">{{ pkg.name }}</div>
|
||||
<div class="card-meta">{{ pkg.author }} · v{{ pkg.version }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Library Tab - Empty State -->
|
||||
<div v-else class="flex flex-col items-center justify-center py-12 text-zinc-500">
|
||||
<i class="pi pi-bookmark mb-3 text-4xl" />
|
||||
<span class="text-sm font-medium">No bookmarks yet</span>
|
||||
<span class="mt-1 text-xs text-zinc-600">Bookmarked items will appear here</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom Tab Bar -->
|
||||
<div class="flex items-center gap-1 rounded-full border border-zinc-800 bg-zinc-900/90 px-2 py-1.5 backdrop-blur">
|
||||
<div class="flex items-center gap-1 rounded-lg border border-zinc-800 bg-zinc-900/90 px-2 py-1.5 backdrop-blur">
|
||||
<!-- Tab buttons -->
|
||||
<button
|
||||
v-for="tab in BOTTOM_BAR_TABS"
|
||||
:key="tab.id"
|
||||
v-tooltip.top="{ value: tab.tooltip, showDelay: 300 }"
|
||||
class="flex h-8 w-8 items-center justify-center rounded-full transition-colors"
|
||||
class="flex h-8 w-8 items-center justify-center rounded-md transition-colors"
|
||||
:class="[
|
||||
activeBottomTab === tab.id
|
||||
? 'bg-zinc-700 text-zinc-100'
|
||||
@@ -185,4 +466,38 @@ const mockTemplates = [
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Standardized Card Styles */
|
||||
.card-item {
|
||||
@apply cursor-pointer rounded-lg border border-zinc-800 bg-zinc-800/30 p-2 transition-all;
|
||||
@apply hover:border-zinc-600 hover:bg-zinc-800/70;
|
||||
}
|
||||
|
||||
.card-preview {
|
||||
@apply relative flex aspect-square items-center justify-center rounded-md bg-zinc-700/50;
|
||||
@apply transition-all group-hover:bg-zinc-700;
|
||||
}
|
||||
|
||||
.card-menu {
|
||||
@apply absolute right-1.5 top-1.5 flex h-6 w-6 items-center justify-center rounded-md;
|
||||
@apply bg-zinc-900/70 text-zinc-400 opacity-0 transition-all;
|
||||
@apply hover:bg-zinc-900 hover:text-zinc-200 group-hover:opacity-100;
|
||||
}
|
||||
|
||||
.card-badge {
|
||||
@apply absolute bottom-1.5 left-1.5 rounded px-1.5 py-0.5;
|
||||
@apply bg-zinc-900/70 text-[10px] font-medium text-zinc-300;
|
||||
}
|
||||
|
||||
.card-info {
|
||||
@apply mt-2 min-w-0;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
@apply truncate text-xs font-medium text-zinc-200;
|
||||
}
|
||||
|
||||
.card-meta {
|
||||
@apply truncate text-[10px] text-zinc-500;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,912 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import Button from 'primevue/button'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import {
|
||||
useUiStore,
|
||||
NODE_CATEGORIES,
|
||||
SIDEBAR_TABS,
|
||||
type NodeCategoryId,
|
||||
type SidebarTabId
|
||||
} from '@/stores/uiStore'
|
||||
import { computed } from 'vue'
|
||||
import { useUiStore } from '@/stores/uiStore'
|
||||
|
||||
// V1 Components
|
||||
import { V1SidebarIconBar } from '@/components/v1/sidebar'
|
||||
import V1SidebarPanel from '@/components/v1/sidebar/V1SidebarPanel.vue'
|
||||
|
||||
// V2 Components
|
||||
import V2NodePanel from '@/components/v2/sidebar/V2NodePanel.vue'
|
||||
|
||||
const uiStore = useUiStore()
|
||||
|
||||
// Interface version
|
||||
const isV2 = computed(() => uiStore.interfaceVersion === 'v2')
|
||||
|
||||
// V2: Node category state
|
||||
const activeNodeCategory = computed(() => uiStore.activeNodeCategory)
|
||||
const activeNodeCategoryData = computed(() => uiStore.activeNodeCategoryData)
|
||||
const nodePanelExpanded = computed(() => uiStore.nodePanelExpanded)
|
||||
|
||||
// V1: Legacy sidebar tab state
|
||||
const activeSidebarTab = computed(() => uiStore.activeSidebarTab)
|
||||
const sidebarPanelExpanded = computed(() => uiStore.sidebarPanelExpanded)
|
||||
|
||||
const searchQuery = ref('')
|
||||
|
||||
// V1: View controls
|
||||
const viewMode = ref<'list' | 'grid'>('list')
|
||||
const sortBy = ref('name')
|
||||
const showFilterMenu = ref(false)
|
||||
const showSortMenu = ref(false)
|
||||
|
||||
// Sort options per tab
|
||||
const sortOptions = computed(() => {
|
||||
switch (activeSidebarTab.value) {
|
||||
case 'nodes':
|
||||
return [
|
||||
{ label: 'Name', value: 'name' },
|
||||
{ label: 'Category', value: 'category' },
|
||||
{ label: 'Recently Used', value: 'recent' },
|
||||
]
|
||||
case 'models':
|
||||
return [
|
||||
{ label: 'Name', value: 'name' },
|
||||
{ label: 'Type', value: 'type' },
|
||||
{ label: 'Size', value: 'size' },
|
||||
{ label: 'Date Added', value: 'date' },
|
||||
]
|
||||
case 'workflows':
|
||||
return [
|
||||
{ label: 'Name', value: 'name' },
|
||||
{ label: 'Date Modified', value: 'date' },
|
||||
{ label: 'Node Count', value: 'nodes' },
|
||||
]
|
||||
case 'assets':
|
||||
return [
|
||||
{ label: 'Name', value: 'name' },
|
||||
{ label: 'Type', value: 'type' },
|
||||
{ label: 'Date Added', value: 'date' },
|
||||
]
|
||||
default:
|
||||
return [{ label: 'Name', value: 'name' }]
|
||||
}
|
||||
})
|
||||
|
||||
// Filter options per tab
|
||||
const filterOptions = computed(() => {
|
||||
switch (activeSidebarTab.value) {
|
||||
case 'nodes':
|
||||
return ['All', 'Core', 'Custom', 'Favorites']
|
||||
case 'models':
|
||||
return ['All', 'Checkpoints', 'LoRAs', 'VAE', 'ControlNet', 'Embeddings']
|
||||
case 'workflows':
|
||||
return ['All', 'Recent', 'Favorites', 'Shared']
|
||||
case 'assets':
|
||||
return ['All', 'Images', 'Masks', 'Videos']
|
||||
default:
|
||||
return ['All']
|
||||
}
|
||||
})
|
||||
|
||||
const activeFilter = ref('All')
|
||||
|
||||
function setSort(value: string): void {
|
||||
sortBy.value = value
|
||||
showSortMenu.value = false
|
||||
}
|
||||
|
||||
function setFilter(value: string): void {
|
||||
activeFilter.value = value
|
||||
showFilterMenu.value = false
|
||||
}
|
||||
|
||||
// V2: Node preview on hover
|
||||
const hoveredNode = ref<string | null>(null)
|
||||
const previewPosition = ref({ top: 0 })
|
||||
|
||||
function handleNodeHover(nodeName: string, event: MouseEvent): void {
|
||||
hoveredNode.value = nodeName
|
||||
const target = event.currentTarget as HTMLElement
|
||||
const rect = target.getBoundingClientRect()
|
||||
previewPosition.value = { top: rect.top }
|
||||
}
|
||||
|
||||
function handleNodeLeave(): void {
|
||||
hoveredNode.value = null
|
||||
}
|
||||
|
||||
// V2: Node category handlers
|
||||
function handleCategoryClick(categoryId: Exclude<NodeCategoryId, null>): void {
|
||||
uiStore.toggleNodeCategory(categoryId)
|
||||
}
|
||||
|
||||
// V1: Legacy tab handlers
|
||||
function handleTabClick(tabId: Exclude<SidebarTabId, null>): void {
|
||||
uiStore.toggleSidebarTab(tabId)
|
||||
}
|
||||
|
||||
// Mock data for legacy tabs - organized by categories like original ComfyUI
|
||||
const nodeCategories = ref([
|
||||
{
|
||||
id: 'loaders',
|
||||
label: 'Loaders',
|
||||
icon: 'pi pi-download',
|
||||
expanded: true,
|
||||
nodes: [
|
||||
{ name: 'CheckpointLoaderSimple', display: 'Load Checkpoint' },
|
||||
{ name: 'VAELoader', display: 'Load VAE' },
|
||||
{ name: 'LoraLoader', display: 'Load LoRA' },
|
||||
{ name: 'CLIPLoader', display: 'Load CLIP' },
|
||||
{ name: 'ControlNetLoader', display: 'Load ControlNet Model' },
|
||||
{ name: 'UNETLoader', display: 'Load Diffusion Model' },
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'conditioning',
|
||||
label: 'Conditioning',
|
||||
icon: 'pi pi-sliders-h',
|
||||
expanded: false,
|
||||
nodes: [
|
||||
{ name: 'CLIPTextEncode', display: 'CLIP Text Encode (Prompt)' },
|
||||
{ name: 'ConditioningCombine', display: 'Conditioning (Combine)' },
|
||||
{ name: 'ConditioningSetArea', display: 'Conditioning (Set Area)' },
|
||||
{ name: 'ControlNetApply', display: 'Apply ControlNet' },
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'sampling',
|
||||
label: 'Sampling',
|
||||
icon: 'pi pi-box',
|
||||
expanded: false,
|
||||
nodes: [
|
||||
{ name: 'KSampler', display: 'KSampler' },
|
||||
{ name: 'KSamplerAdvanced', display: 'KSampler (Advanced)' },
|
||||
{ name: 'SamplerCustom', display: 'SamplerCustom' },
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'latent',
|
||||
label: 'Latent',
|
||||
icon: 'pi pi-th-large',
|
||||
expanded: false,
|
||||
nodes: [
|
||||
{ name: 'EmptyLatentImage', display: 'Empty Latent Image' },
|
||||
{ name: 'LatentUpscale', display: 'Upscale Latent' },
|
||||
{ name: 'LatentComposite', display: 'Latent Composite' },
|
||||
{ name: 'VAEDecode', display: 'VAE Decode' },
|
||||
{ name: 'VAEEncode', display: 'VAE Encode' },
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'image',
|
||||
label: 'Image',
|
||||
icon: 'pi pi-image',
|
||||
expanded: false,
|
||||
nodes: [
|
||||
{ name: 'LoadImage', display: 'Load Image' },
|
||||
{ name: 'SaveImage', display: 'Save Image' },
|
||||
{ name: 'PreviewImage', display: 'Preview Image' },
|
||||
{ name: 'ImageScale', display: 'Upscale Image' },
|
||||
{ name: 'ImageInvert', display: 'Invert Image' },
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'masking',
|
||||
label: 'Masking',
|
||||
icon: 'pi pi-clone',
|
||||
expanded: false,
|
||||
nodes: [
|
||||
{ name: 'LoadImageMask', display: 'Load Image (as Mask)' },
|
||||
{ name: 'MaskComposite', display: 'Mask Composite' },
|
||||
{ name: 'ImageToMask', display: 'Convert Image to Mask' },
|
||||
]
|
||||
},
|
||||
])
|
||||
|
||||
function toggleCategory(categoryId: string): void {
|
||||
const category = nodeCategories.value.find(c => c.id === categoryId)
|
||||
if (category) {
|
||||
category.expanded = !category.expanded
|
||||
}
|
||||
}
|
||||
|
||||
const modelCategories = ref([
|
||||
{
|
||||
id: 'checkpoints',
|
||||
label: 'Checkpoints',
|
||||
icon: 'pi pi-box',
|
||||
expanded: true,
|
||||
models: [
|
||||
{ name: 'sd_v1-5', display: 'SD 1.5', size: '4.27 GB' },
|
||||
{ name: 'sd_xl_base_1.0', display: 'SDXL Base 1.0', size: '6.94 GB' },
|
||||
{ name: 'realistic_vision_v5', display: 'Realistic Vision V5', size: '2.13 GB' },
|
||||
{ name: 'dreamshaper_8', display: 'DreamShaper 8', size: '2.13 GB' },
|
||||
{ name: 'deliberate_v3', display: 'Deliberate V3', size: '2.13 GB' },
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'loras',
|
||||
label: 'LoRAs',
|
||||
icon: 'pi pi-link',
|
||||
expanded: false,
|
||||
models: [
|
||||
{ name: 'add_detail', display: 'Add Detail', size: '144 MB' },
|
||||
{ name: 'epi_noiseoffset', display: 'Epi Noise Offset', size: '36 MB' },
|
||||
{ name: 'film_grain', display: 'Film Grain', size: '72 MB' },
|
||||
{ name: 'lcm_lora_sdxl', display: 'LCM LoRA SDXL', size: '393 MB' },
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'vae',
|
||||
label: 'VAE',
|
||||
icon: 'pi pi-sitemap',
|
||||
expanded: false,
|
||||
models: [
|
||||
{ name: 'vae-ft-mse-840000', display: 'VAE ft MSE', size: '335 MB' },
|
||||
{ name: 'sdxl_vae', display: 'SDXL VAE', size: '335 MB' },
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'controlnet',
|
||||
label: 'ControlNet',
|
||||
icon: 'pi pi-sliders-v',
|
||||
expanded: false,
|
||||
models: [
|
||||
{ name: 'control_v11p_sd15_canny', display: 'Canny (SD1.5)', size: '1.45 GB' },
|
||||
{ name: 'control_v11p_sd15_openpose', display: 'OpenPose (SD1.5)', size: '1.45 GB' },
|
||||
{ name: 'control_v11f1p_sd15_depth', display: 'Depth (SD1.5)', size: '1.45 GB' },
|
||||
{ name: 'controlnet_sdxl_canny', display: 'Canny (SDXL)', size: '2.5 GB' },
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'embeddings',
|
||||
label: 'Embeddings',
|
||||
icon: 'pi pi-tag',
|
||||
expanded: false,
|
||||
models: [
|
||||
{ name: 'easynegative', display: 'EasyNegative', size: '24 KB' },
|
||||
{ name: 'bad_prompt_v2', display: 'Bad Prompt V2', size: '24 KB' },
|
||||
{ name: 'ng_deepnegative', display: 'NG DeepNegative', size: '24 KB' },
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'upscale',
|
||||
label: 'Upscale Models',
|
||||
icon: 'pi pi-expand',
|
||||
expanded: false,
|
||||
models: [
|
||||
{ name: '4x_ultrasharp', display: '4x UltraSharp', size: '67 MB' },
|
||||
{ name: 'realesrgan_x4plus', display: 'RealESRGAN x4+', size: '64 MB' },
|
||||
{ name: '4x_nmkd_superscale', display: '4x NMKD Superscale', size: '67 MB' },
|
||||
]
|
||||
},
|
||||
])
|
||||
|
||||
function toggleModelCategory(categoryId: string): void {
|
||||
const category = modelCategories.value.find(c => c.id === categoryId)
|
||||
if (category) {
|
||||
category.expanded = !category.expanded
|
||||
}
|
||||
}
|
||||
|
||||
const mockWorkflows = [
|
||||
{ name: 'Basic txt2img', date: '2024-01-15', nodes: 8, thumbnail: 'txt2img' },
|
||||
{ name: 'Img2Img Pipeline', date: '2024-01-14', nodes: 12, thumbnail: 'img2img' },
|
||||
{ name: 'ControlNet Canny', date: '2024-01-13', nodes: 15, thumbnail: 'controlnet' },
|
||||
{ name: 'SDXL with Refiner', date: '2024-01-12', nodes: 18, thumbnail: 'sdxl' },
|
||||
{ name: 'Inpainting Setup', date: '2024-01-10', nodes: 10, thumbnail: 'inpaint' },
|
||||
]
|
||||
|
||||
const mockAssets = [
|
||||
{ name: 'reference_01.png', type: 'image' },
|
||||
{ name: 'mask_template.png', type: 'image' },
|
||||
{ name: 'init_image.jpg', type: 'image' },
|
||||
]
|
||||
|
||||
const templateCategories = ref([
|
||||
{
|
||||
id: 'official',
|
||||
label: 'Official',
|
||||
icon: 'pi pi-verified',
|
||||
expanded: true,
|
||||
templates: [
|
||||
{ name: 'txt2img-basic', display: 'Text to Image (Basic)', description: 'Simple text-to-image generation', nodes: 6 },
|
||||
{ name: 'img2img-basic', display: 'Image to Image', description: 'Transform existing images', nodes: 8 },
|
||||
{ name: 'inpainting', display: 'Inpainting', description: 'Fill masked regions', nodes: 10 },
|
||||
{ name: 'upscaling', display: 'Upscaling', description: '2x-4x image upscaling', nodes: 5 },
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'sdxl',
|
||||
label: 'SDXL',
|
||||
icon: 'pi pi-star',
|
||||
expanded: false,
|
||||
templates: [
|
||||
{ name: 'sdxl-txt2img', display: 'SDXL Text to Image', description: 'SDXL base workflow', nodes: 8 },
|
||||
{ name: 'sdxl-refiner', display: 'SDXL + Refiner', description: 'Base with refiner', nodes: 14 },
|
||||
{ name: 'sdxl-lightning', display: 'SDXL Lightning', description: '4-step fast generation', nodes: 9 },
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'controlnet',
|
||||
label: 'ControlNet',
|
||||
icon: 'pi pi-sliders-v',
|
||||
expanded: false,
|
||||
templates: [
|
||||
{ name: 'cn-canny', display: 'Canny Edge', description: 'Edge detection control', nodes: 12 },
|
||||
{ name: 'cn-depth', display: 'Depth Map', description: 'Depth-based control', nodes: 12 },
|
||||
{ name: 'cn-openpose', display: 'OpenPose', description: 'Pose control', nodes: 14 },
|
||||
{ name: 'cn-lineart', display: 'Line Art', description: 'Sketch to image', nodes: 11 },
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'video',
|
||||
label: 'Video',
|
||||
icon: 'pi pi-video',
|
||||
expanded: false,
|
||||
templates: [
|
||||
{ name: 'svd-basic', display: 'SVD Image to Video', description: 'Stable Video Diffusion', nodes: 10 },
|
||||
{ name: 'animatediff', display: 'AnimateDiff', description: 'Animation generation', nodes: 16 },
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'community',
|
||||
label: 'Community',
|
||||
icon: 'pi pi-users',
|
||||
expanded: false,
|
||||
templates: [
|
||||
{ name: 'portrait-enhance', display: 'Portrait Enhancer', description: 'Face restoration workflow', nodes: 12 },
|
||||
{ name: 'style-transfer', display: 'Style Transfer', description: 'Apply art styles', nodes: 14 },
|
||||
{ name: 'batch-process', display: 'Batch Processing', description: 'Process multiple images', nodes: 18 },
|
||||
]
|
||||
},
|
||||
])
|
||||
|
||||
function toggleTemplateCategory(categoryId: string): void {
|
||||
const category = templateCategories.value.find(c => c.id === categoryId)
|
||||
if (category) {
|
||||
category.expanded = !category.expanded
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full">
|
||||
<!-- ================================================================== -->
|
||||
<!-- V2 INTERFACE: TouchDesigner/Houdini-style Node Categories -->
|
||||
<!-- ================================================================== -->
|
||||
<!-- V2 Interface: TouchDesigner/Houdini-style Node Categories -->
|
||||
<template v-if="isV2">
|
||||
<!-- Level 1: Category Icon Bar -->
|
||||
<nav class="flex w-12 flex-col items-center border-r border-zinc-800 bg-zinc-900 py-2">
|
||||
<!-- Category buttons with colors -->
|
||||
<div class="flex flex-1 flex-col gap-0.5 overflow-y-auto scrollbar-hide">
|
||||
<button
|
||||
v-for="category in NODE_CATEGORIES"
|
||||
:key="category.id"
|
||||
v-tooltip.right="{ value: category.label, showDelay: 50 }"
|
||||
class="flex h-8 w-8 items-center justify-center rounded-md transition-all"
|
||||
:class="[
|
||||
activeNodeCategory === category.id
|
||||
? 'text-white'
|
||||
: 'text-zinc-500 hover:text-zinc-200'
|
||||
]"
|
||||
:style="{
|
||||
backgroundColor: activeNodeCategory === category.id ? category.color + '15' : 'transparent',
|
||||
}"
|
||||
@click="handleCategoryClick(category.id)"
|
||||
>
|
||||
<i :class="[category.icon, 'text-base']" :style="{ color: activeNodeCategory === category.id ? category.color : undefined }" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Bottom section -->
|
||||
<div class="mt-auto flex flex-col gap-1 pt-2">
|
||||
<button
|
||||
v-tooltip.right="{ value: 'Settings', showDelay: 50 }"
|
||||
class="flex h-8 w-8 items-center justify-center rounded-md text-zinc-500 transition-colors hover:bg-zinc-800 hover:text-zinc-200"
|
||||
>
|
||||
<i class="pi pi-cog text-xs" />
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Level 2: Subcategory Panel -->
|
||||
<aside
|
||||
class="border-r border-zinc-800 bg-zinc-900/98 transition-all duration-200 ease-out"
|
||||
:class="nodePanelExpanded ? 'w-72' : 'w-0 overflow-hidden'"
|
||||
>
|
||||
<div v-if="nodePanelExpanded && activeNodeCategoryData" class="flex h-full w-72 flex-col">
|
||||
<!-- Panel Header with category color -->
|
||||
<div
|
||||
class="flex items-center justify-between border-b border-zinc-800 px-3 py-2"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<i
|
||||
:class="activeNodeCategoryData.icon"
|
||||
class="text-sm"
|
||||
:style="{ color: activeNodeCategoryData.color }"
|
||||
/>
|
||||
<span
|
||||
class="text-sm font-semibold"
|
||||
:style="{ color: activeNodeCategoryData.color }"
|
||||
>
|
||||
{{ activeNodeCategoryData.label }}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
icon="pi pi-times"
|
||||
text
|
||||
severity="secondary"
|
||||
size="small"
|
||||
class="!h-6 !w-6"
|
||||
@click="uiStore.closeNodePanel()"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Search Box -->
|
||||
<div class="border-b border-zinc-800/50 p-2">
|
||||
<div class="relative">
|
||||
<i class="pi pi-search absolute left-2.5 top-1/2 -translate-y-1/2 text-xs text-zinc-500" />
|
||||
<InputText
|
||||
v-model="searchQuery"
|
||||
:placeholder="`Search ${activeNodeCategoryData.label.toLowerCase()}...`"
|
||||
class="!h-8 w-full !rounded !border-zinc-700 !bg-zinc-800/50 !pl-8 !text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Nodes List (flat, no dropdowns) -->
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<div class="p-2 space-y-3">
|
||||
<div
|
||||
v-for="subcategory in activeNodeCategoryData.subcategories"
|
||||
:key="subcategory.id"
|
||||
>
|
||||
<!-- Subcategory Label -->
|
||||
<div class="mb-1 flex h-5 items-center rounded bg-zinc-950/70 px-2">
|
||||
<span class="text-[10px] font-medium uppercase tracking-wider text-zinc-500">
|
||||
{{ subcategory.label }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Nodes -->
|
||||
<div class="space-y-0.5">
|
||||
<div
|
||||
v-for="nodeName in subcategory.nodes"
|
||||
:key="nodeName"
|
||||
class="group flex cursor-pointer items-center rounded px-2 py-1.5 transition-colors hover:bg-zinc-800"
|
||||
draggable="true"
|
||||
@mouseenter="handleNodeHover(nodeName, $event)"
|
||||
@mouseleave="handleNodeLeave"
|
||||
>
|
||||
<span class="flex-1 truncate text-xs text-zinc-400 group-hover:text-zinc-200">
|
||||
{{ nodeName }}
|
||||
</span>
|
||||
<i class="pi pi-plus text-[10px] text-zinc-600 opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer with node count -->
|
||||
<div class="border-t border-zinc-800/50 px-3 py-2">
|
||||
<div class="text-[10px] text-zinc-500">
|
||||
{{ activeNodeCategoryData.subcategories.reduce((acc, sub) => acc + sub.nodes.length, 0) }} nodes
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Node Preview Popup -->
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="hoveredNode && nodePanelExpanded"
|
||||
class="pointer-events-none fixed z-50 ml-2 w-64 rounded-lg border border-zinc-700 bg-zinc-900 p-3 shadow-xl"
|
||||
:style="{ top: `${previewPosition.top}px`, left: 'calc(48px + 288px + 8px)' }"
|
||||
>
|
||||
<div class="mb-2 flex items-center gap-2">
|
||||
<span
|
||||
class="text-sm font-medium"
|
||||
:style="{ color: activeNodeCategoryData?.color }"
|
||||
>{{ hoveredNode }}</span>
|
||||
</div>
|
||||
<p class="text-xs leading-relaxed text-zinc-400">
|
||||
Node for processing data in the workflow. Drag to canvas to add.
|
||||
</p>
|
||||
<div class="mt-2 flex flex-wrap gap-1">
|
||||
<span class="rounded bg-zinc-800 px-1.5 py-0.5 text-[10px] text-zinc-500">input: any</span>
|
||||
<span class="rounded bg-zinc-800 px-1.5 py-0.5 text-[10px] text-zinc-500">output: any</span>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
<V2NodePanel />
|
||||
</template>
|
||||
|
||||
<!-- ================================================================== -->
|
||||
<!-- V1 INTERFACE: Legacy Sidebar with Nodes, Models, Workflows, etc. -->
|
||||
<!-- ================================================================== -->
|
||||
<!-- V1 Interface: Legacy Sidebar with Nodes, Models, Workflows, etc. -->
|
||||
<template v-else>
|
||||
<!-- Icon Toolbar -->
|
||||
<nav class="flex w-12 flex-col items-center border-r border-zinc-800 bg-zinc-900 py-2">
|
||||
<!-- Tab buttons -->
|
||||
<div class="flex flex-col gap-1">
|
||||
<button
|
||||
v-for="tab in SIDEBAR_TABS"
|
||||
:key="tab.id"
|
||||
v-tooltip.right="{ value: tab.tooltip, showDelay: 50 }"
|
||||
class="flex h-8 w-8 items-center justify-center rounded-md transition-colors"
|
||||
:class="[
|
||||
activeSidebarTab === tab.id
|
||||
? 'bg-zinc-700 text-zinc-100'
|
||||
: 'text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200'
|
||||
]"
|
||||
@click="handleTabClick(tab.id)"
|
||||
>
|
||||
<i :class="[tab.icon, 'text-sm']" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Bottom section -->
|
||||
<div class="mt-auto flex flex-col gap-1">
|
||||
<button
|
||||
v-tooltip.right="{ value: 'Console', showDelay: 50 }"
|
||||
class="flex h-8 w-8 items-center justify-center rounded-md text-zinc-400 transition-colors hover:bg-zinc-800 hover:text-zinc-200"
|
||||
>
|
||||
<i class="pi pi-code text-sm" />
|
||||
</button>
|
||||
<button
|
||||
v-tooltip.right="{ value: 'Settings', showDelay: 50 }"
|
||||
class="flex h-8 w-8 items-center justify-center rounded-md text-zinc-400 transition-colors hover:bg-zinc-800 hover:text-zinc-200"
|
||||
>
|
||||
<i class="pi pi-cog text-sm" />
|
||||
</button>
|
||||
<button
|
||||
v-tooltip.right="{ value: 'Help', showDelay: 50 }"
|
||||
class="flex h-8 w-8 items-center justify-center rounded-md text-zinc-400 transition-colors hover:bg-zinc-800 hover:text-zinc-200"
|
||||
>
|
||||
<i class="pi pi-question-circle text-sm" />
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Expandable Panel -->
|
||||
<aside
|
||||
class="border-r border-zinc-800 bg-zinc-900/95 transition-all duration-200"
|
||||
:class="sidebarPanelExpanded ? 'w-80' : 'w-0 overflow-hidden'"
|
||||
>
|
||||
<div v-if="sidebarPanelExpanded" class="flex h-full w-80 flex-col">
|
||||
<!-- Panel Header -->
|
||||
<div class="flex items-center justify-between border-b border-zinc-800 px-3 py-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-wide text-zinc-400">
|
||||
{{ SIDEBAR_TABS.find(t => t.id === activeSidebarTab)?.label }}
|
||||
</span>
|
||||
<Button
|
||||
icon="pi pi-times"
|
||||
text
|
||||
severity="secondary"
|
||||
size="small"
|
||||
class="!h-6 !w-6"
|
||||
@click="uiStore.closeSidebarPanel()"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Search Box -->
|
||||
<div class="border-b border-zinc-800 p-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex flex-1 items-center rounded bg-zinc-800 px-2 py-1.5">
|
||||
<i class="pi pi-search text-xs text-zinc-500" />
|
||||
<input
|
||||
type="text"
|
||||
:placeholder="`Search ${SIDEBAR_TABS.find(t => t.id === activeSidebarTab)?.label?.toLowerCase()}...`"
|
||||
class="ml-2 w-full bg-transparent text-xs text-zinc-300 outline-none placeholder:text-zinc-500"
|
||||
/>
|
||||
</div>
|
||||
<!-- Import button for workflows -->
|
||||
<button
|
||||
v-if="activeSidebarTab === 'workflows'"
|
||||
v-tooltip.top="{ value: 'Import Workflow', showDelay: 50 }"
|
||||
class="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded bg-zinc-800 text-zinc-400 transition-colors hover:bg-zinc-700 hover:text-zinc-200"
|
||||
>
|
||||
<i class="pi pi-plus text-xs" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- View Controls -->
|
||||
<div class="mt-2 flex items-center justify-between">
|
||||
<!-- View Mode Toggle -->
|
||||
<div class="flex items-center rounded bg-zinc-800 p-0.5">
|
||||
<button
|
||||
v-tooltip.bottom="{ value: 'List View', showDelay: 50 }"
|
||||
class="flex h-6 w-6 items-center justify-center rounded transition-colors"
|
||||
:class="viewMode === 'list' ? 'bg-zinc-700 text-zinc-200' : 'text-zinc-500 hover:text-zinc-300'"
|
||||
@click="viewMode = 'list'"
|
||||
>
|
||||
<i class="pi pi-list text-[10px]" />
|
||||
</button>
|
||||
<button
|
||||
v-tooltip.bottom="{ value: 'Grid View', showDelay: 50 }"
|
||||
class="flex h-6 w-6 items-center justify-center rounded transition-colors"
|
||||
:class="viewMode === 'grid' ? 'bg-zinc-700 text-zinc-200' : 'text-zinc-500 hover:text-zinc-300'"
|
||||
@click="viewMode = 'grid'"
|
||||
>
|
||||
<i class="pi pi-th-large text-[10px]" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Filter & Sort -->
|
||||
<div class="flex items-center gap-1">
|
||||
<!-- Filter Dropdown -->
|
||||
<div class="relative">
|
||||
<button
|
||||
class="flex h-6 items-center gap-1 rounded bg-zinc-800 px-2 text-[10px] text-zinc-400 transition-colors hover:bg-zinc-700 hover:text-zinc-200"
|
||||
@click="showFilterMenu = !showFilterMenu"
|
||||
>
|
||||
<i class="pi pi-filter text-[10px]" />
|
||||
<span>{{ activeFilter }}</span>
|
||||
<i class="pi pi-chevron-down text-[8px]" />
|
||||
</button>
|
||||
<!-- Filter Menu -->
|
||||
<div
|
||||
v-if="showFilterMenu"
|
||||
class="absolute left-0 top-full z-50 mt-1 min-w-[120px] rounded-lg border border-zinc-700 bg-zinc-900 py-1 shadow-xl"
|
||||
>
|
||||
<button
|
||||
v-for="option in filterOptions"
|
||||
:key="option"
|
||||
class="flex w-full items-center px-3 py-1.5 text-left text-xs transition-colors"
|
||||
:class="activeFilter === option ? 'bg-zinc-800 text-zinc-200' : 'text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200'"
|
||||
@click="setFilter(option)"
|
||||
>
|
||||
{{ option }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sort Dropdown -->
|
||||
<div class="relative">
|
||||
<button
|
||||
class="flex h-6 items-center gap-1 rounded bg-zinc-800 px-2 text-[10px] text-zinc-400 transition-colors hover:bg-zinc-700 hover:text-zinc-200"
|
||||
@click="showSortMenu = !showSortMenu"
|
||||
>
|
||||
<i class="pi pi-sort-alt text-[10px]" />
|
||||
<span>{{ sortOptions.find(o => o.value === sortBy)?.label }}</span>
|
||||
<i class="pi pi-chevron-down text-[8px]" />
|
||||
</button>
|
||||
<!-- Sort Menu -->
|
||||
<div
|
||||
v-if="showSortMenu"
|
||||
class="absolute right-0 top-full z-50 mt-1 min-w-[120px] rounded-lg border border-zinc-700 bg-zinc-900 py-1 shadow-xl"
|
||||
>
|
||||
<button
|
||||
v-for="option in sortOptions"
|
||||
:key="option.value"
|
||||
class="flex w-full items-center px-3 py-1.5 text-left text-xs transition-colors"
|
||||
:class="sortBy === option.value ? 'bg-zinc-800 text-zinc-200' : 'text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200'"
|
||||
@click="setSort(option.value)"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Panel Content -->
|
||||
<div class="flex-1 overflow-y-auto p-2">
|
||||
<!-- Nodes Tab - Tree Structure -->
|
||||
<div v-if="activeSidebarTab === 'nodes'" class="space-y-0.5">
|
||||
<div
|
||||
v-for="category in nodeCategories"
|
||||
:key="category.id"
|
||||
class="select-none"
|
||||
>
|
||||
<!-- Category Header (Folder) -->
|
||||
<button
|
||||
class="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left transition-colors hover:bg-zinc-800"
|
||||
@click="toggleCategory(category.id)"
|
||||
>
|
||||
<i
|
||||
class="text-[10px] text-zinc-500 transition-transform"
|
||||
:class="category.expanded ? 'pi pi-chevron-down' : 'pi pi-chevron-right'"
|
||||
/>
|
||||
<i :class="[category.icon, 'text-xs text-zinc-400']" />
|
||||
<span class="flex-1 text-xs font-medium text-zinc-300">
|
||||
{{ category.label }}
|
||||
</span>
|
||||
<span class="rounded bg-zinc-800 px-1.5 py-0.5 text-[10px] text-zinc-500">
|
||||
{{ category.nodes.length }}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<!-- Nodes List (Expandable) -->
|
||||
<div
|
||||
v-if="category.expanded"
|
||||
class="ml-4 space-y-0.5 border-l border-zinc-800 pl-2"
|
||||
>
|
||||
<div
|
||||
v-for="node in category.nodes"
|
||||
:key="node.name"
|
||||
class="group flex cursor-pointer items-center gap-2 rounded px-2 py-1 transition-colors hover:bg-zinc-800"
|
||||
draggable="true"
|
||||
>
|
||||
<i class="pi pi-circle-fill text-[5px] text-zinc-600 group-hover:text-zinc-400" />
|
||||
<span class="flex-1 truncate text-xs text-zinc-400 group-hover:text-zinc-200">
|
||||
{{ node.display }}
|
||||
</span>
|
||||
<i class="pi pi-plus text-[10px] text-zinc-600 opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Models Tab - Tree Structure -->
|
||||
<div v-else-if="activeSidebarTab === 'models'" class="space-y-0.5">
|
||||
<div
|
||||
v-for="category in modelCategories"
|
||||
:key="category.id"
|
||||
class="select-none"
|
||||
>
|
||||
<!-- Category Header (Folder) -->
|
||||
<button
|
||||
class="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left transition-colors hover:bg-zinc-800"
|
||||
@click="toggleModelCategory(category.id)"
|
||||
>
|
||||
<i
|
||||
class="text-[10px] text-zinc-500 transition-transform"
|
||||
:class="category.expanded ? 'pi pi-chevron-down' : 'pi pi-chevron-right'"
|
||||
/>
|
||||
<i :class="[category.icon, 'text-xs text-zinc-400']" />
|
||||
<span class="flex-1 text-xs font-medium text-zinc-300">
|
||||
{{ category.label }}
|
||||
</span>
|
||||
<span class="rounded bg-zinc-800 px-1.5 py-0.5 text-[10px] text-zinc-500">
|
||||
{{ category.models.length }}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<!-- Models List (Expandable) -->
|
||||
<div
|
||||
v-if="category.expanded"
|
||||
class="ml-4 space-y-0.5 border-l border-zinc-800 pl-2"
|
||||
>
|
||||
<div
|
||||
v-for="model in category.models"
|
||||
:key="model.name"
|
||||
class="group flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 transition-colors hover:bg-zinc-800"
|
||||
draggable="true"
|
||||
>
|
||||
<i class="pi pi-file text-[10px] text-zinc-600 group-hover:text-zinc-400" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="truncate text-xs text-zinc-400 group-hover:text-zinc-200">
|
||||
{{ model.display }}
|
||||
</div>
|
||||
<div class="text-[10px] text-zinc-600">{{ model.size }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Workflows Tab -->
|
||||
<div v-else-if="activeSidebarTab === 'workflows'" class="space-y-2">
|
||||
<!-- Workflow Cards -->
|
||||
<div
|
||||
v-for="workflow in mockWorkflows"
|
||||
:key="workflow.name"
|
||||
class="group cursor-pointer overflow-hidden rounded-lg border border-zinc-800 bg-zinc-900 transition-all hover:border-zinc-700 hover:bg-zinc-800/50"
|
||||
>
|
||||
<!-- Thumbnail (16:9) -->
|
||||
<div class="relative aspect-video bg-zinc-950">
|
||||
<!-- Placeholder thumbnail with gradient based on workflow type -->
|
||||
<div
|
||||
class="absolute inset-0 flex items-center justify-center"
|
||||
:class="{
|
||||
'bg-gradient-to-br from-blue-900/30 to-purple-900/30': workflow.thumbnail === 'txt2img',
|
||||
'bg-gradient-to-br from-green-900/30 to-teal-900/30': workflow.thumbnail === 'img2img',
|
||||
'bg-gradient-to-br from-orange-900/30 to-red-900/30': workflow.thumbnail === 'controlnet',
|
||||
'bg-gradient-to-br from-violet-900/30 to-pink-900/30': workflow.thumbnail === 'sdxl',
|
||||
'bg-gradient-to-br from-cyan-900/30 to-blue-900/30': workflow.thumbnail === 'inpaint',
|
||||
}"
|
||||
>
|
||||
<i class="pi pi-sitemap text-2xl text-zinc-700" />
|
||||
</div>
|
||||
<!-- Share Button (always visible) -->
|
||||
<button
|
||||
v-tooltip.left="{ value: 'Share', showDelay: 50 }"
|
||||
class="absolute right-1.5 top-1.5 flex h-6 w-6 items-center justify-center rounded bg-zinc-800/90 text-zinc-400 transition-colors hover:bg-zinc-700 hover:text-zinc-200"
|
||||
>
|
||||
<i class="pi pi-share-alt text-[10px]" />
|
||||
</button>
|
||||
<!-- Node count badge -->
|
||||
<div class="absolute bottom-1.5 left-1.5 rounded bg-zinc-900/80 px-1.5 py-0.5 text-[10px] text-zinc-400">
|
||||
{{ workflow.nodes }} nodes
|
||||
</div>
|
||||
</div>
|
||||
<!-- Info -->
|
||||
<div class="flex items-center justify-between px-2.5 py-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-xs font-medium text-zinc-300">{{ workflow.name }}</div>
|
||||
<div class="mt-0.5 text-[10px] text-zinc-500">{{ workflow.date }}</div>
|
||||
</div>
|
||||
<!-- Add to canvas button -->
|
||||
<button
|
||||
v-tooltip.left="{ value: 'Add to Canvas', showDelay: 50 }"
|
||||
class="ml-2 flex h-6 w-6 flex-shrink-0 items-center justify-center rounded bg-blue-600 text-white transition-all hover:bg-blue-500"
|
||||
>
|
||||
<i class="pi pi-plus text-[10px]" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Assets Tab -->
|
||||
<div v-else-if="activeSidebarTab === 'assets'" class="space-y-1">
|
||||
<div
|
||||
v-for="asset in mockAssets"
|
||||
:key="asset.name"
|
||||
class="cursor-pointer rounded px-2 py-1.5 text-xs transition-colors hover:bg-zinc-800"
|
||||
>
|
||||
<i class="pi pi-image mr-2 text-zinc-500" />
|
||||
<span class="text-zinc-300">{{ asset.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Templates Tab - Tree Structure -->
|
||||
<div v-else-if="activeSidebarTab === 'templates'" class="space-y-0.5">
|
||||
<div
|
||||
v-for="category in templateCategories"
|
||||
:key="category.id"
|
||||
class="select-none"
|
||||
>
|
||||
<!-- Category Header (Folder) -->
|
||||
<button
|
||||
class="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left transition-colors hover:bg-zinc-800"
|
||||
@click="toggleTemplateCategory(category.id)"
|
||||
>
|
||||
<i
|
||||
class="text-[10px] text-zinc-500 transition-transform"
|
||||
:class="category.expanded ? 'pi pi-chevron-down' : 'pi pi-chevron-right'"
|
||||
/>
|
||||
<i :class="[category.icon, 'text-xs text-zinc-400']" />
|
||||
<span class="flex-1 text-xs font-medium text-zinc-300">
|
||||
{{ category.label }}
|
||||
</span>
|
||||
<span class="rounded bg-zinc-800 px-1.5 py-0.5 text-[10px] text-zinc-500">
|
||||
{{ category.templates.length }}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<!-- Templates List (Expandable) -->
|
||||
<div
|
||||
v-if="category.expanded"
|
||||
class="ml-4 space-y-0.5 border-l border-zinc-800 pl-2"
|
||||
>
|
||||
<div
|
||||
v-for="template in category.templates"
|
||||
:key="template.name"
|
||||
class="group flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 transition-colors hover:bg-zinc-800"
|
||||
draggable="true"
|
||||
>
|
||||
<i class="pi pi-clone text-[10px] text-zinc-600 group-hover:text-zinc-400" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="truncate text-xs text-zinc-400 group-hover:text-zinc-200">
|
||||
{{ template.display }}
|
||||
</div>
|
||||
<div class="truncate text-[10px] text-zinc-600">{{ template.description }}</div>
|
||||
</div>
|
||||
<span class="rounded bg-zinc-800 px-1.5 py-0.5 text-[10px] text-zinc-600">
|
||||
{{ template.nodes }}
|
||||
</span>
|
||||
<i class="pi pi-plus text-[10px] text-zinc-600 opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Library Tab -->
|
||||
<div v-else-if="activeSidebarTab === 'library'" class="space-y-2">
|
||||
<div class="text-xs text-zinc-500">
|
||||
<i class="pi pi-bookmark mr-2" />
|
||||
Bookmarked items will appear here
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<V1SidebarIconBar />
|
||||
<V1SidebarPanel />
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
110
ComfyUI_vibe/src/components/v2/canvas/CanvasLogoMenu.vue
Normal file
110
ComfyUI_vibe/src/components/v2/canvas/CanvasLogoMenu.vue
Normal file
@@ -0,0 +1,110 @@
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useUiStore } from '@/stores/uiStore'
|
||||
|
||||
defineProps<{
|
||||
show: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
const uiStore = useUiStore()
|
||||
|
||||
function goToWorkspace(): void {
|
||||
emit('close')
|
||||
router.push({ name: 'workspace-dashboard', params: { workspaceId: 'default' } })
|
||||
}
|
||||
|
||||
function goToProjects(): void {
|
||||
emit('close')
|
||||
router.push({ name: 'workspace-projects', params: { workspaceId: 'default' } })
|
||||
}
|
||||
|
||||
function goToSettings(): void {
|
||||
emit('close')
|
||||
router.push({ name: 'workspace-settings', params: { workspaceId: 'default' } })
|
||||
}
|
||||
|
||||
function signOut(): void {
|
||||
emit('close')
|
||||
router.push('/')
|
||||
}
|
||||
|
||||
function toggleExperimentalUI(): void {
|
||||
uiStore.toggleInterfaceVersion()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="show" class="absolute left-0 top-full z-[100] mt-1 min-w-[240px] rounded-lg border border-zinc-800 bg-zinc-900 p-1 shadow-xl">
|
||||
<!-- File Section -->
|
||||
<div class="px-3 pb-1 pt-1.5 text-[11px] font-medium uppercase tracking-wide text-zinc-500">File</div>
|
||||
<button class="flex w-full items-center gap-2.5 rounded-md px-3 py-2 text-left text-[13px] text-zinc-200 transition-colors hover:bg-zinc-800">
|
||||
<i class="pi pi-file w-4 text-sm text-zinc-500" />
|
||||
<span class="flex-1">New Workflow</span>
|
||||
<span class="text-[11px] text-zinc-600">Ctrl+N</span>
|
||||
</button>
|
||||
<button class="flex w-full items-center gap-2.5 rounded-md px-3 py-2 text-left text-[13px] text-zinc-200 transition-colors hover:bg-zinc-800">
|
||||
<i class="pi pi-folder-open w-4 text-sm text-zinc-500" />
|
||||
<span class="flex-1">Open...</span>
|
||||
<span class="text-[11px] text-zinc-600">Ctrl+O</span>
|
||||
</button>
|
||||
<button class="flex w-full items-center gap-2.5 rounded-md px-3 py-2 text-left text-[13px] text-zinc-200 transition-colors hover:bg-zinc-800">
|
||||
<i class="pi pi-save w-4 text-sm text-zinc-500" />
|
||||
<span class="flex-1">Save</span>
|
||||
<span class="text-[11px] text-zinc-600">Ctrl+S</span>
|
||||
</button>
|
||||
<button class="flex w-full items-center gap-2.5 rounded-md px-3 py-2 text-left text-[13px] text-zinc-200 transition-colors hover:bg-zinc-800">
|
||||
<i class="pi pi-download w-4 text-sm text-zinc-500" />
|
||||
<span>Export...</span>
|
||||
</button>
|
||||
|
||||
<div class="mx-2 my-1 h-px bg-zinc-800" />
|
||||
|
||||
<!-- Workspace Section -->
|
||||
<div class="px-3 pb-1 pt-1.5 text-[11px] font-medium uppercase tracking-wide text-zinc-500">Workspace</div>
|
||||
<button class="flex w-full items-center gap-2.5 rounded-md px-3 py-2 text-left text-[13px] text-zinc-200 transition-colors hover:bg-zinc-800" @click="goToWorkspace">
|
||||
<i class="pi pi-home w-4 text-sm text-zinc-500" />
|
||||
<span>Dashboard</span>
|
||||
</button>
|
||||
<button class="flex w-full items-center gap-2.5 rounded-md px-3 py-2 text-left text-[13px] text-zinc-200 transition-colors hover:bg-zinc-800" @click="goToProjects">
|
||||
<i class="pi pi-folder w-4 text-sm text-zinc-500" />
|
||||
<span>Projects</span>
|
||||
</button>
|
||||
|
||||
<div class="mx-2 my-1 h-px bg-zinc-800" />
|
||||
|
||||
<!-- Account Section -->
|
||||
<div class="px-3 pb-1 pt-1.5 text-[11px] font-medium uppercase tracking-wide text-zinc-500">Account</div>
|
||||
<button class="flex w-full items-center gap-2.5 rounded-md px-3 py-2 text-left text-[13px] text-zinc-200 transition-colors hover:bg-zinc-800" @click="goToSettings">
|
||||
<i class="pi pi-cog w-4 text-sm text-zinc-500" />
|
||||
<span>Settings</span>
|
||||
</button>
|
||||
<button class="flex w-full items-center gap-2.5 rounded-md px-3 py-2 text-left text-[13px] text-zinc-200 transition-colors hover:bg-zinc-800" @click="toggleExperimentalUI">
|
||||
<i class="pi pi-sparkles w-4 text-sm text-zinc-500" />
|
||||
<span class="flex-1">Experimental UI</span>
|
||||
<div
|
||||
class="h-5 w-9 rounded-full p-0.5 transition-colors"
|
||||
:class="uiStore.interfaceVersion === 'v2' ? 'bg-blue-500' : 'bg-zinc-700'"
|
||||
>
|
||||
<div
|
||||
class="h-4 w-4 rounded-full bg-white transition-transform"
|
||||
:class="uiStore.interfaceVersion === 'v2' ? 'translate-x-4' : 'translate-x-0'"
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div class="mx-2 my-1 h-px bg-zinc-800" />
|
||||
|
||||
<button class="flex w-full items-center gap-2.5 rounded-md px-3 py-2 text-left text-[13px] text-red-400 transition-colors hover:bg-red-500/10" @click="signOut">
|
||||
<i class="pi pi-sign-out w-4 text-sm text-red-400" />
|
||||
<span>Sign out</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Backdrop -->
|
||||
<div v-if="show" class="fixed inset-0 z-[99]" @click="emit('close')" />
|
||||
</template>
|
||||
@@ -1,20 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useUiStore } from '@/stores/uiStore'
|
||||
import Tooltip from 'primevue/tooltip'
|
||||
|
||||
const vTooltip = Tooltip
|
||||
|
||||
interface CanvasTab {
|
||||
id: string
|
||||
name: string
|
||||
isActive: boolean
|
||||
isDirty?: boolean
|
||||
}
|
||||
import CanvasLogoMenu from './CanvasLogoMenu.vue'
|
||||
import CanvasTabs, { type CanvasTab } from './CanvasTabs.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const uiStore = useUiStore()
|
||||
|
||||
const tabs = ref<CanvasTab[]>([
|
||||
{ id: 'workflow-1', name: 'Main Workflow', isActive: true },
|
||||
@@ -33,26 +23,6 @@ function handleHomeClick(): void {
|
||||
router.push({ name: 'workspace-dashboard', params: { workspaceId: 'default' } })
|
||||
}
|
||||
|
||||
function goToWorkspace(): void {
|
||||
showMenu.value = false
|
||||
router.push({ name: 'workspace-dashboard', params: { workspaceId: 'default' } })
|
||||
}
|
||||
|
||||
function goToProjects(): void {
|
||||
showMenu.value = false
|
||||
router.push({ name: 'workspace-projects', params: { workspaceId: 'default' } })
|
||||
}
|
||||
|
||||
function goToSettings(): void {
|
||||
showMenu.value = false
|
||||
router.push({ name: 'workspace-settings', params: { workspaceId: 'default' } })
|
||||
}
|
||||
|
||||
function signOut(): void {
|
||||
showMenu.value = false
|
||||
router.push('/')
|
||||
}
|
||||
|
||||
function selectTab(tabId: string): void {
|
||||
activeTabId.value = tabId
|
||||
tabs.value = tabs.value.map(tab => ({
|
||||
@@ -61,467 +31,81 @@ function selectTab(tabId: string): void {
|
||||
}))
|
||||
}
|
||||
|
||||
function closeTab(tabId: string, event: MouseEvent): void {
|
||||
event.stopPropagation()
|
||||
function closeTab(tabId: string): void {
|
||||
const index = tabs.value.findIndex(t => t.id === tabId)
|
||||
if (index > -1) {
|
||||
tabs.value.splice(index, 1)
|
||||
if (tabId === activeTabId.value && tabs.value.length > 0) {
|
||||
const newIndex = Math.min(index, tabs.value.length - 1)
|
||||
selectTab(tabs.value[newIndex].id)
|
||||
selectTab(tabs.value[newIndex]!.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createNewTab(): void {
|
||||
const newId = `workflow-${Date.now()}`
|
||||
tabs.value.push({
|
||||
id: newId,
|
||||
name: 'Untitled Workflow',
|
||||
isActive: false,
|
||||
})
|
||||
selectTab(newId)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="canvas-tab-bar">
|
||||
<div class="flex h-10 items-center gap-1 border-b border-zinc-800 bg-zinc-950 px-2 select-none">
|
||||
<!-- Logo Section -->
|
||||
<div class="logo-section">
|
||||
<button class="logo-button" @click="handleLogoClick">
|
||||
<img src="/assets/images/comfy-logo-mono.svg" alt="Comfy" class="logo-icon" />
|
||||
<i class="pi pi-chevron-down chevron-icon" />
|
||||
<div class="relative">
|
||||
<button
|
||||
class="flex items-center gap-1 rounded-md px-2 py-1.5 text-zinc-400 transition-colors hover:bg-zinc-800 hover:text-zinc-100"
|
||||
@click="handleLogoClick"
|
||||
>
|
||||
<img src="/assets/images/comfy-logo-mono.svg" alt="Comfy" class="h-5 w-5" />
|
||||
<i class="pi pi-chevron-down text-[10px] opacity-70" />
|
||||
</button>
|
||||
|
||||
<!-- Dropdown Menu -->
|
||||
<div v-if="showMenu" class="dropdown-menu">
|
||||
<!-- File Section -->
|
||||
<div class="menu-section-label">File</div>
|
||||
<button class="menu-item">
|
||||
<i class="pi pi-file menu-item-icon" />
|
||||
<span>New Workflow</span>
|
||||
<span class="shortcut">Ctrl+N</span>
|
||||
</button>
|
||||
<button class="menu-item">
|
||||
<i class="pi pi-folder-open menu-item-icon" />
|
||||
<span>Open...</span>
|
||||
<span class="shortcut">Ctrl+O</span>
|
||||
</button>
|
||||
<button class="menu-item">
|
||||
<i class="pi pi-save menu-item-icon" />
|
||||
<span>Save</span>
|
||||
<span class="shortcut">Ctrl+S</span>
|
||||
</button>
|
||||
<button class="menu-item">
|
||||
<i class="pi pi-download menu-item-icon" />
|
||||
<span>Export...</span>
|
||||
</button>
|
||||
|
||||
<div class="menu-divider" />
|
||||
|
||||
<!-- Workspace Section -->
|
||||
<div class="menu-section-label">Workspace</div>
|
||||
<button class="menu-item" @click="goToWorkspace">
|
||||
<i class="pi pi-home menu-item-icon" />
|
||||
<span>Dashboard</span>
|
||||
</button>
|
||||
<button class="menu-item" @click="goToProjects">
|
||||
<i class="pi pi-folder menu-item-icon" />
|
||||
<span>Projects</span>
|
||||
</button>
|
||||
|
||||
<div class="menu-divider" />
|
||||
|
||||
<!-- Account Section -->
|
||||
<div class="menu-section-label">Account</div>
|
||||
<button class="menu-item" @click="goToSettings">
|
||||
<i class="pi pi-cog menu-item-icon" />
|
||||
<span>Settings</span>
|
||||
</button>
|
||||
<button class="menu-item" @click="uiStore.toggleInterfaceVersion()">
|
||||
<i class="pi pi-sparkles menu-item-icon" />
|
||||
<span>Experimental UI</span>
|
||||
<div :class="['toggle-switch', { active: uiStore.interfaceVersion === 'v2' }]">
|
||||
<div class="toggle-knob" />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div class="menu-divider" />
|
||||
|
||||
<button class="menu-item menu-item-danger" @click="signOut">
|
||||
<i class="pi pi-sign-out menu-item-icon" />
|
||||
<span>Sign out</span>
|
||||
</button>
|
||||
</div>
|
||||
<CanvasLogoMenu :show="showMenu" @close="showMenu = false" />
|
||||
</div>
|
||||
|
||||
<!-- Divider -->
|
||||
<div class="divider" />
|
||||
<div class="mx-1 h-5 w-px bg-zinc-800" />
|
||||
|
||||
<!-- Home Button -->
|
||||
<button v-tooltip.bottom="'Home'" class="home-button" @click="handleHomeClick">
|
||||
<i class="pi pi-home" />
|
||||
<button
|
||||
v-tooltip.bottom="{ value: 'Home', showDelay: 50 }"
|
||||
class="flex h-8 w-8 items-center justify-center rounded-md text-zinc-500 transition-colors hover:bg-zinc-800 hover:text-zinc-100"
|
||||
@click="handleHomeClick"
|
||||
>
|
||||
<i class="pi pi-home text-base" />
|
||||
</button>
|
||||
|
||||
<!-- Divider -->
|
||||
<div class="divider" />
|
||||
<div class="mx-1 h-5 w-px bg-zinc-800" />
|
||||
|
||||
<!-- Tabs Section -->
|
||||
<div class="tabs-section">
|
||||
<div class="tabs-container">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.id"
|
||||
:class="['tab', { active: tab.id === activeTabId }]"
|
||||
@click="selectTab(tab.id)"
|
||||
>
|
||||
<span class="tab-name">{{ tab.name }}</span>
|
||||
<span v-if="tab.isDirty" class="dirty-indicator" />
|
||||
<button class="tab-close" @click="closeTab(tab.id, $event)">
|
||||
<i class="pi pi-times" />
|
||||
</button>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- New Tab Button -->
|
||||
<button v-tooltip.bottom="'New Workflow'" class="new-tab-button">
|
||||
<i class="pi pi-plus" />
|
||||
</button>
|
||||
</div>
|
||||
<CanvasTabs
|
||||
:tabs="tabs"
|
||||
:active-tab-id="activeTabId"
|
||||
@select="selectTab"
|
||||
@close="closeTab"
|
||||
@new="createNewTab"
|
||||
/>
|
||||
|
||||
<!-- Right Section -->
|
||||
<div class="right-section">
|
||||
<button v-tooltip.bottom="'Share'" class="action-button">
|
||||
<i class="pi pi-share-alt" />
|
||||
<div class="ml-auto flex items-center gap-1">
|
||||
<button
|
||||
v-tooltip.bottom="{ value: 'Share', showDelay: 50 }"
|
||||
class="flex h-8 w-8 items-center justify-center rounded-md text-zinc-500 transition-colors hover:bg-zinc-800 hover:text-zinc-100"
|
||||
>
|
||||
<i class="pi pi-share-alt text-sm" />
|
||||
</button>
|
||||
<button v-tooltip.bottom="'Run Workflow'" class="action-button play">
|
||||
<i class="pi pi-play" />
|
||||
<button
|
||||
v-tooltip.bottom="{ value: 'Run Workflow', showDelay: 50 }"
|
||||
class="flex h-8 w-8 items-center justify-center rounded-md bg-blue-600 text-white transition-colors hover:bg-blue-500"
|
||||
>
|
||||
<i class="pi pi-play text-sm" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Click outside to close menu -->
|
||||
<div v-if="showMenu" class="menu-backdrop" @click="showMenu = false" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.canvas-tab-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 40px;
|
||||
background: #0a0a0a;
|
||||
border-bottom: 1px solid #27272a;
|
||||
padding: 0 8px;
|
||||
gap: 4px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.logo-section {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.logo-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px 8px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: #a1a1aa;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.logo-button:hover {
|
||||
background: #27272a;
|
||||
color: #fafafa;
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.chevron-icon {
|
||||
font-size: 10px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
min-width: 240px;
|
||||
background: #18181b;
|
||||
border: 1px solid #27272a;
|
||||
border-radius: 8px;
|
||||
padding: 4px;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: #e4e4e7;
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.menu-item:hover {
|
||||
background: #27272a;
|
||||
}
|
||||
|
||||
.menu-item-icon {
|
||||
font-size: 14px;
|
||||
color: #71717a;
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
.shortcut {
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
color: #52525b;
|
||||
}
|
||||
|
||||
.menu-section-label {
|
||||
padding: 6px 12px 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: #52525b;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.menu-divider {
|
||||
height: 1px;
|
||||
background: #27272a;
|
||||
margin: 4px 8px;
|
||||
}
|
||||
|
||||
.menu-item-danger {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.menu-item-danger:hover {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
|
||||
.menu-item-danger .menu-item-icon {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.toggle-switch {
|
||||
margin-left: auto;
|
||||
width: 36px;
|
||||
height: 20px;
|
||||
background: #3f3f46;
|
||||
border-radius: 10px;
|
||||
padding: 2px;
|
||||
transition: background 0.2s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.toggle-switch.active {
|
||||
background: #3b82f6;
|
||||
}
|
||||
|
||||
.toggle-knob {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.toggle-switch.active .toggle-knob {
|
||||
transform: translateX(16px);
|
||||
}
|
||||
|
||||
.menu-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 99;
|
||||
}
|
||||
|
||||
.divider {
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
background: #27272a;
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.home-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: #71717a;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.home-button:hover {
|
||||
background: #27272a;
|
||||
color: #fafafa;
|
||||
}
|
||||
|
||||
.home-button i {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.tabs-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tabs-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.tabs-container::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px 6px 12px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: #71717a;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
background: #1f1f23;
|
||||
color: #a1a1aa;
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
background: #27272a;
|
||||
color: #fafafa;
|
||||
}
|
||||
|
||||
.tab-name {
|
||||
max-width: 150px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.dirty-indicator {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: #3b82f6;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tab-close {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: #52525b;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.tab:hover .tab-close {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.tab-close:hover {
|
||||
background: #3f3f46;
|
||||
color: #fafafa;
|
||||
}
|
||||
|
||||
.tab-close i {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.new-tab-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: #52525b;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.new-tab-button:hover {
|
||||
background: #27272a;
|
||||
color: #a1a1aa;
|
||||
}
|
||||
|
||||
.new-tab-button i {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.right-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.action-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: #71717a;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.action-button:hover {
|
||||
background: #27272a;
|
||||
color: #fafafa;
|
||||
}
|
||||
|
||||
.action-button.play {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.action-button.play:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.action-button i {
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
|
||||
72
ComfyUI_vibe/src/components/v2/canvas/CanvasTabs.vue
Normal file
72
ComfyUI_vibe/src/components/v2/canvas/CanvasTabs.vue
Normal file
@@ -0,0 +1,72 @@
|
||||
<script setup lang="ts">
|
||||
export interface CanvasTab {
|
||||
id: string
|
||||
name: string
|
||||
isActive: boolean
|
||||
isDirty?: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
tabs: CanvasTab[]
|
||||
activeTabId: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [tabId: string]
|
||||
close: [tabId: string]
|
||||
new: []
|
||||
}>()
|
||||
|
||||
function handleClose(tabId: string, event: MouseEvent): void {
|
||||
event.stopPropagation()
|
||||
emit('close', tabId)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-1 items-center gap-0.5 overflow-hidden">
|
||||
<!-- Tabs Container -->
|
||||
<div class="flex items-center gap-0.5 overflow-x-auto scrollbar-hide">
|
||||
<button
|
||||
v-for="tab in props.tabs"
|
||||
:key="tab.id"
|
||||
class="group flex items-center gap-2 whitespace-nowrap rounded-md px-3 py-1.5 text-xs transition-colors"
|
||||
:class="[
|
||||
tab.id === props.activeTabId
|
||||
? 'bg-zinc-800 text-zinc-100'
|
||||
: 'text-zinc-500 hover:bg-zinc-800/50 hover:text-zinc-400'
|
||||
]"
|
||||
@click="emit('select', tab.id)"
|
||||
>
|
||||
<span class="max-w-[150px] truncate">{{ tab.name }}</span>
|
||||
<span v-if="tab.isDirty" class="h-1.5 w-1.5 shrink-0 rounded-full bg-blue-500" />
|
||||
<span
|
||||
class="flex h-4 w-4 items-center justify-center rounded text-zinc-600 opacity-0 transition-all hover:bg-zinc-700 hover:text-zinc-300 group-hover:opacity-100"
|
||||
@click="handleClose(tab.id, $event)"
|
||||
>
|
||||
<i class="pi pi-times text-[10px]" />
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- New Tab Button -->
|
||||
<button
|
||||
v-tooltip.bottom="{ value: 'New Workflow', showDelay: 50 }"
|
||||
class="flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-zinc-600 transition-colors hover:bg-zinc-800 hover:text-zinc-400"
|
||||
@click="emit('new')"
|
||||
>
|
||||
<i class="pi pi-plus text-xs" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
270
ComfyUI_vibe/src/components/v2/canvas/LibrarySidebar.vue
Normal file
270
ComfyUI_vibe/src/components/v2/canvas/LibrarySidebar.vue
Normal file
@@ -0,0 +1,270 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import Button from 'primevue/button'
|
||||
import Avatar from 'primevue/avatar'
|
||||
import AvatarGroup from 'primevue/avatargroup'
|
||||
import { SidebarSearchBox, SidebarViewToggle } from '@/components/common/sidebar'
|
||||
import LibraryBrandKitSection from '@/components/v1/sidebar/LibraryBrandKitSection.vue'
|
||||
import LibraryWorkflowsSection from '@/components/v1/sidebar/LibraryWorkflowsSection.vue'
|
||||
import LibraryModelsSection from '@/components/v1/sidebar/LibraryModelsSection.vue'
|
||||
import LibraryNodesSection from '@/components/v1/sidebar/LibraryNodesSection.vue'
|
||||
import {
|
||||
TEAM_MEMBERS_DATA,
|
||||
BRAND_ASSETS_DATA,
|
||||
createSharedWorkflowsData,
|
||||
createTeamModelsData,
|
||||
NODE_PACKS_DATA,
|
||||
} from '@/data/sidebarMockData'
|
||||
|
||||
const props = defineProps<{
|
||||
teamName?: string
|
||||
teamLogo?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const searchQuery = ref('')
|
||||
const viewMode = ref<'list' | 'grid'>('list')
|
||||
const sortBy = ref('name')
|
||||
const showFilterMenu = ref(false)
|
||||
const showSortMenu = ref(false)
|
||||
const activeFilter = ref('All')
|
||||
|
||||
const sortOptions = [
|
||||
{ label: 'Name', value: 'name' },
|
||||
{ label: 'Recently Added', value: 'recent' },
|
||||
{ label: 'Author', value: 'author' },
|
||||
]
|
||||
|
||||
const filterOptions = ['All', 'Brand Kit', 'Workflows', 'Models', 'Nodes']
|
||||
|
||||
function setSort(value: string): void {
|
||||
sortBy.value = value
|
||||
showSortMenu.value = false
|
||||
}
|
||||
|
||||
function setFilter(value: string): void {
|
||||
activeFilter.value = value
|
||||
showFilterMenu.value = false
|
||||
}
|
||||
|
||||
// Current team info
|
||||
const currentTeam = computed(() => ({
|
||||
name: props.teamName || 'Netflix',
|
||||
logo: props.teamLogo,
|
||||
plan: 'Enterprise',
|
||||
members: 24,
|
||||
}))
|
||||
|
||||
// Collapsible sections
|
||||
const sections = ref({
|
||||
brand: true,
|
||||
workflows: true,
|
||||
models: false,
|
||||
nodes: false,
|
||||
})
|
||||
|
||||
function toggleSection(sectionId: keyof typeof sections.value): void {
|
||||
sections.value[sectionId] = !sections.value[sectionId]
|
||||
}
|
||||
|
||||
// Data
|
||||
const teamMembers = TEAM_MEMBERS_DATA
|
||||
const brandAssets = BRAND_ASSETS_DATA
|
||||
const sharedWorkflows = computed(() => createSharedWorkflowsData(teamMembers))
|
||||
const teamModels = computed(() => createTeamModelsData(teamMembers))
|
||||
const nodePacks = NODE_PACKS_DATA
|
||||
|
||||
const filteredWorkflows = computed(() => {
|
||||
if (!searchQuery.value) return sharedWorkflows.value
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
return sharedWorkflows.value.filter(
|
||||
w => w.name.toLowerCase().includes(query) || w.description.toLowerCase().includes(query)
|
||||
)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full w-80 flex-col">
|
||||
<!-- Panel Header -->
|
||||
<div class="flex items-center justify-between border-b border-zinc-800 px-3 py-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-wide text-zinc-400">
|
||||
TEAM LIBRARY
|
||||
</span>
|
||||
<Button
|
||||
icon="pi pi-times"
|
||||
text
|
||||
severity="secondary"
|
||||
size="small"
|
||||
class="!h-6 !w-6"
|
||||
@click="emit('close')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Search & Controls -->
|
||||
<div class="border-b border-zinc-800 p-2">
|
||||
<SidebarSearchBox
|
||||
v-model="searchQuery"
|
||||
placeholder="Search library..."
|
||||
:show-action="true"
|
||||
action-tooltip="Manage Library"
|
||||
action-icon="pi pi-cog"
|
||||
/>
|
||||
|
||||
<!-- View Controls -->
|
||||
<div class="mt-2 flex items-center justify-between">
|
||||
<SidebarViewToggle v-model="viewMode" />
|
||||
|
||||
<!-- Filter & Sort -->
|
||||
<div class="flex items-center gap-1">
|
||||
<!-- Filter Dropdown -->
|
||||
<div class="relative">
|
||||
<button
|
||||
class="flex h-6 items-center gap-1 rounded bg-zinc-800 px-2 text-[10px] text-zinc-400 transition-colors hover:bg-zinc-700 hover:text-zinc-200"
|
||||
@click="showFilterMenu = !showFilterMenu"
|
||||
>
|
||||
<i class="pi pi-filter text-[10px]" />
|
||||
<span>{{ activeFilter }}</span>
|
||||
<i class="pi pi-chevron-down text-[8px]" />
|
||||
</button>
|
||||
<div
|
||||
v-if="showFilterMenu"
|
||||
class="absolute left-0 top-full z-50 mt-1 min-w-[120px] rounded-lg border border-zinc-700 bg-zinc-900 py-1 shadow-xl"
|
||||
>
|
||||
<button
|
||||
v-for="option in filterOptions"
|
||||
:key="option"
|
||||
class="flex w-full items-center px-3 py-1.5 text-left text-xs transition-colors"
|
||||
:class="activeFilter === option ? 'bg-zinc-800 text-zinc-200' : 'text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200'"
|
||||
@click="setFilter(option)"
|
||||
>
|
||||
{{ option }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sort Dropdown -->
|
||||
<div class="relative">
|
||||
<button
|
||||
class="flex h-6 items-center gap-1 rounded bg-zinc-800 px-2 text-[10px] text-zinc-400 transition-colors hover:bg-zinc-700 hover:text-zinc-200"
|
||||
@click="showSortMenu = !showSortMenu"
|
||||
>
|
||||
<i class="pi pi-sort-alt text-[10px]" />
|
||||
<span>{{ sortOptions.find(o => o.value === sortBy)?.label }}</span>
|
||||
<i class="pi pi-chevron-down text-[8px]" />
|
||||
</button>
|
||||
<div
|
||||
v-if="showSortMenu"
|
||||
class="absolute right-0 top-full z-50 mt-1 min-w-[120px] rounded-lg border border-zinc-700 bg-zinc-900 py-1 shadow-xl"
|
||||
>
|
||||
<button
|
||||
v-for="option in sortOptions"
|
||||
:key="option.value"
|
||||
class="flex w-full items-center px-3 py-1.5 text-left text-xs transition-colors"
|
||||
:class="sortBy === option.value ? 'bg-zinc-800 text-zinc-200' : 'text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200'"
|
||||
@click="setSort(option.value)"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1 overflow-y-auto p-2">
|
||||
<!-- Team Header Card -->
|
||||
<div class="mb-3 rounded-lg border border-zinc-800 bg-zinc-900 p-2.5">
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg text-lg font-bold"
|
||||
:style="{ backgroundColor: '#E50914' }"
|
||||
>
|
||||
<span class="text-white">N</span>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="truncate text-sm font-semibold text-zinc-100">{{ currentTeam.name }}</span>
|
||||
<span class="rounded bg-blue-500/20 px-1.5 py-0.5 text-[10px] font-medium text-blue-400">
|
||||
{{ currentTeam.plan }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-0.5 flex items-center gap-2">
|
||||
<AvatarGroup class="!gap-0">
|
||||
<Avatar
|
||||
v-for="member in teamMembers.slice(0, 3)"
|
||||
:key="member.name"
|
||||
:label="member.initials"
|
||||
shape="circle"
|
||||
size="small"
|
||||
class="!h-5 !w-5 !border !border-zinc-900 !bg-zinc-700 !text-[9px] !text-zinc-300"
|
||||
/>
|
||||
</AvatarGroup>
|
||||
<span class="text-[10px] text-zinc-500">
|
||||
{{ currentTeam.members }} members
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- List View -->
|
||||
<div v-if="viewMode === 'list'" class="select-none space-y-0.5">
|
||||
<LibraryBrandKitSection
|
||||
:assets="brandAssets"
|
||||
:view-mode="viewMode"
|
||||
:expanded="sections.brand"
|
||||
@toggle="toggleSection('brand')"
|
||||
/>
|
||||
<LibraryWorkflowsSection
|
||||
:workflows="filteredWorkflows"
|
||||
:view-mode="viewMode"
|
||||
:expanded="sections.workflows"
|
||||
@toggle="toggleSection('workflows')"
|
||||
/>
|
||||
<LibraryModelsSection
|
||||
:models="teamModels"
|
||||
:view-mode="viewMode"
|
||||
:expanded="sections.models"
|
||||
@toggle="toggleSection('models')"
|
||||
/>
|
||||
<LibraryNodesSection
|
||||
:packs="nodePacks"
|
||||
:view-mode="viewMode"
|
||||
:expanded="sections.nodes"
|
||||
@toggle="toggleSection('nodes')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Grid View -->
|
||||
<div v-else class="space-y-3">
|
||||
<LibraryBrandKitSection :assets="brandAssets" :view-mode="viewMode" :expanded="true" />
|
||||
<LibraryWorkflowsSection :workflows="filteredWorkflows" :view-mode="viewMode" :expanded="true" />
|
||||
<LibraryModelsSection :models="teamModels" :view-mode="viewMode" :expanded="true" />
|
||||
<LibraryNodesSection :packs="nodePacks" :view-mode="viewMode" :expanded="true" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
div::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
div::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
div::-webkit-scrollbar-thumb {
|
||||
background: #3f3f46;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
div::-webkit-scrollbar-thumb:hover {
|
||||
background: #52525b;
|
||||
}
|
||||
</style>
|
||||
104
ComfyUI_vibe/src/components/v2/canvas/NodePropertiesPanel.vue
Normal file
104
ComfyUI_vibe/src/components/v2/canvas/NodePropertiesPanel.vue
Normal file
@@ -0,0 +1,104 @@
|
||||
<script setup lang="ts">
|
||||
import type { Node } from '@vue-flow/core'
|
||||
import type { FlowNodeData, NodeState } from '@/types/node'
|
||||
|
||||
const props = defineProps<{
|
||||
node: Node<FlowNodeData>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
toggleState: [state: NodeState]
|
||||
toggleCollapsed: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="flex w-80 flex-col border-l border-zinc-200 bg-zinc-50/50 dark:border-zinc-800 dark:bg-zinc-900/50">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between border-b border-zinc-200 px-4 py-3 dark:border-zinc-800">
|
||||
<h2 class="text-sm font-medium text-zinc-900 dark:text-zinc-100">Properties</h2>
|
||||
<button
|
||||
class="rounded p-1 text-zinc-400 transition-colors hover:bg-zinc-100 hover:text-zinc-600 dark:hover:bg-zinc-800 dark:hover:text-zinc-300"
|
||||
@click="emit('close')"
|
||||
>
|
||||
<i class="pi pi-times text-xs" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Node Info -->
|
||||
<div class="flex-1 overflow-y-auto p-4">
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="text-xs font-medium text-zinc-500 dark:text-zinc-400">Node Type</label>
|
||||
<p class="mt-1 text-sm text-zinc-900 dark:text-zinc-100">
|
||||
{{ props.node.data.definition.displayName }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs font-medium text-zinc-500 dark:text-zinc-400">Node ID</label>
|
||||
<p class="mt-1 font-mono text-xs text-zinc-600 dark:text-zinc-400">{{ props.node.id }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs font-medium text-zinc-500 dark:text-zinc-400">State</label>
|
||||
<p
|
||||
class="mt-1 text-sm capitalize"
|
||||
:class="{
|
||||
'text-zinc-400': props.node.data.state === 'idle',
|
||||
'text-blue-400': props.node.data.state === 'executing',
|
||||
'text-green-400': props.node.data.state === 'completed',
|
||||
'text-red-400': props.node.data.state === 'error',
|
||||
'text-amber-400': props.node.data.state === 'bypassed',
|
||||
'text-zinc-500': props.node.data.state === 'muted',
|
||||
}"
|
||||
>
|
||||
{{ props.node.data.state }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs font-medium text-zinc-500 dark:text-zinc-400">Position</label>
|
||||
<p class="mt-1 font-mono text-xs text-zinc-600 dark:text-zinc-400">
|
||||
x: {{ Math.round(props.node.position.x) }}, y: {{ Math.round(props.node.position.y) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- State toggles for demo -->
|
||||
<div class="border-t border-zinc-700 pt-4">
|
||||
<label class="mb-2 block text-xs font-medium text-zinc-500 dark:text-zinc-400">Toggle State (Demo)</label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
class="rounded bg-zinc-700 px-2 py-1 text-xs text-zinc-300 hover:bg-zinc-600"
|
||||
@click="emit('toggleCollapsed')"
|
||||
>
|
||||
{{ props.node.data.flags.collapsed ? 'Expand' : 'Collapse' }}
|
||||
</button>
|
||||
<button
|
||||
class="rounded bg-blue-600 px-2 py-1 text-xs text-white hover:bg-blue-500"
|
||||
@click="emit('toggleState', 'executing')"
|
||||
>
|
||||
Executing
|
||||
</button>
|
||||
<button
|
||||
class="rounded bg-red-600 px-2 py-1 text-xs text-white hover:bg-red-500"
|
||||
@click="emit('toggleState', 'error')"
|
||||
>
|
||||
Error
|
||||
</button>
|
||||
<button
|
||||
class="rounded bg-amber-600 px-2 py-1 text-xs text-white hover:bg-amber-500"
|
||||
@click="emit('toggleState', 'bypassed')"
|
||||
>
|
||||
Bypass
|
||||
</button>
|
||||
<button
|
||||
class="rounded bg-zinc-600 px-2 py-1 text-xs text-white hover:bg-zinc-500"
|
||||
@click="emit('toggleState', 'muted')"
|
||||
>
|
||||
Mute
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
@@ -2,10 +2,10 @@
|
||||
import { computed } from 'vue'
|
||||
import { Handle, Position } from '@vue-flow/core'
|
||||
import type { FlowNodeData } from '@/types/node'
|
||||
import { getSlotColor } from '@/types/node'
|
||||
import NodeHeader from './NodeHeader.vue'
|
||||
import NodeSlots from './NodeSlots.vue'
|
||||
import NodeWidgets from './NodeWidgets.vue'
|
||||
import FlowNodeMinimized from './FlowNodeMinimized.vue'
|
||||
|
||||
interface Props {
|
||||
id: string
|
||||
@@ -84,16 +84,6 @@ function handleWidgetUpdate(name: string, value: unknown): void {
|
||||
})
|
||||
}
|
||||
|
||||
const hasInputs = computed(() => props.data.definition.inputs.length > 0)
|
||||
const hasOutputs = computed(() => props.data.definition.outputs.length > 0)
|
||||
|
||||
const visibleInputs = computed(() =>
|
||||
props.data.definition.inputs.filter(s => !s.hidden)
|
||||
)
|
||||
const visibleOutputs = computed(() =>
|
||||
props.data.definition.outputs.filter(s => !s.hidden)
|
||||
)
|
||||
|
||||
function handleMinimize(): void {
|
||||
emit('update:data', {
|
||||
flags: { ...props.data.flags, minimized: !isMinimized.value }
|
||||
@@ -101,7 +91,10 @@ function handleMinimize(): void {
|
||||
emit('minimize', !isMinimized.value)
|
||||
}
|
||||
|
||||
// Handle positioning: header (36px) + optional progress bar (4px) + half slot height (12px)
|
||||
const hasInputs = computed(() => props.data.definition.inputs.length > 0)
|
||||
const hasOutputs = computed(() => props.data.definition.outputs.length > 0)
|
||||
|
||||
// Handle positioning constants
|
||||
const HEADER_HEIGHT = 36
|
||||
const SLOT_HEIGHT = 24
|
||||
const PROGRESS_BAR_HEIGHT = 4
|
||||
@@ -118,103 +111,21 @@ function getHandleTop(index: number): string {
|
||||
|
||||
<template>
|
||||
<!-- Minimized View -->
|
||||
<div
|
||||
<FlowNodeMinimized
|
||||
v-if="isMinimized"
|
||||
:class="[
|
||||
'flow-node-minimized relative rounded-lg',
|
||||
'border transition-all duration-150',
|
||||
'bg-zinc-900',
|
||||
borderClass,
|
||||
outlineClass,
|
||||
{
|
||||
'ring-4 ring-blue-500/30': selected && !hasError && !isExecuting,
|
||||
}
|
||||
]"
|
||||
:style="[bodyStyle, { opacity: nodeOpacity }]"
|
||||
>
|
||||
<div
|
||||
v-if="isBypassed || isMuted"
|
||||
class="pointer-events-none absolute inset-0 rounded-lg"
|
||||
:class="isBypassed ? 'bg-amber-500/20' : 'bg-zinc-500/30'"
|
||||
/>
|
||||
|
||||
<!-- Compact header -->
|
||||
<div
|
||||
:class="[
|
||||
'node-header-minimized py-1.5 px-2 text-xs',
|
||||
'bg-zinc-800 text-zinc-100 rounded-t-lg',
|
||||
]"
|
||||
:style="headerStyle"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2 min-w-0">
|
||||
<div class="flex items-center gap-1.5 min-w-0 flex-1">
|
||||
<button
|
||||
class="flex h-4 w-4 shrink-0 items-center justify-center rounded text-zinc-400 transition-colors hover:bg-zinc-700 hover:text-zinc-200"
|
||||
@click.stop="handleMinimize"
|
||||
>
|
||||
<i class="pi pi-chevron-down -rotate-90 text-[10px]" />
|
||||
</button>
|
||||
<span class="truncate font-medium text-[11px]">{{ displayTitle }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 items-center gap-1">
|
||||
<i
|
||||
v-if="isExecuting"
|
||||
class="pi pi-spin pi-spinner text-[10px] text-blue-400"
|
||||
/>
|
||||
<i
|
||||
v-if="hasError"
|
||||
class="pi pi-exclamation-triangle text-[10px] text-red-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Compact slots row -->
|
||||
<div class="flex items-center justify-between px-1 py-1 rounded-b-lg">
|
||||
<!-- Input dots -->
|
||||
<div class="flex items-center gap-0.5">
|
||||
<div
|
||||
v-for="(input, index) in visibleInputs"
|
||||
:key="`input-${index}`"
|
||||
class="h-2 w-2 rounded-full border border-zinc-900"
|
||||
:style="{ backgroundColor: getSlotColor(input.type) }"
|
||||
:title="input.label || input.name"
|
||||
/>
|
||||
<div v-if="!hasInputs" class="w-2" />
|
||||
</div>
|
||||
|
||||
<!-- Output dots -->
|
||||
<div class="flex items-center gap-0.5">
|
||||
<div
|
||||
v-for="(output, index) in visibleOutputs"
|
||||
:key="`output-${index}`"
|
||||
class="h-2 w-2 rounded-full border border-zinc-900"
|
||||
:style="{ backgroundColor: getSlotColor(output.type) }"
|
||||
:title="output.label || output.name"
|
||||
/>
|
||||
<div v-if="!hasOutputs" class="w-2" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Vue Flow Handles (invisible, centered vertically) -->
|
||||
<Handle
|
||||
v-if="hasInputs"
|
||||
id="input-minimized"
|
||||
type="target"
|
||||
:position="Position.Left"
|
||||
class="vue-flow-handle"
|
||||
:style="{ top: '50%' }"
|
||||
/>
|
||||
<Handle
|
||||
v-if="hasOutputs"
|
||||
id="output-minimized"
|
||||
type="source"
|
||||
:position="Position.Right"
|
||||
class="vue-flow-handle"
|
||||
:style="{ top: '50%' }"
|
||||
/>
|
||||
</div>
|
||||
:title="displayTitle"
|
||||
:selected="selected"
|
||||
:is-executing="isExecuting"
|
||||
:has-error="hasError"
|
||||
:is-bypassed="isBypassed"
|
||||
:is-muted="isMuted"
|
||||
:node-opacity="nodeOpacity"
|
||||
:header-style="headerStyle"
|
||||
:body-style="bodyStyle"
|
||||
:inputs="data.definition.inputs"
|
||||
:outputs="data.definition.outputs"
|
||||
@expand="handleMinimize"
|
||||
/>
|
||||
|
||||
<!-- Full View -->
|
||||
<div
|
||||
@@ -348,13 +259,6 @@ function getHandleTop(index: number): string {
|
||||
background-color: var(--node-body-bg);
|
||||
}
|
||||
|
||||
.flow-node-minimized {
|
||||
--node-body-bg: #18181b;
|
||||
background-color: var(--node-body-bg);
|
||||
min-width: 100px;
|
||||
max-width: 160px;
|
||||
}
|
||||
|
||||
.vue-flow-handle {
|
||||
width: 16px !important;
|
||||
height: 16px !important;
|
||||
|
||||
169
ComfyUI_vibe/src/components/v2/nodes/FlowNodeMinimized.vue
Normal file
169
ComfyUI_vibe/src/components/v2/nodes/FlowNodeMinimized.vue
Normal file
@@ -0,0 +1,169 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { Handle, Position } from '@vue-flow/core'
|
||||
import type { SlotDefinition } from '@/types/node'
|
||||
import { getSlotColor } from '@/types/node'
|
||||
|
||||
interface Props {
|
||||
title: string
|
||||
selected?: boolean
|
||||
isExecuting?: boolean
|
||||
hasError?: boolean
|
||||
isBypassed?: boolean
|
||||
isMuted?: boolean
|
||||
nodeOpacity?: number
|
||||
headerStyle?: Record<string, string>
|
||||
bodyStyle?: Record<string, string>
|
||||
inputs: SlotDefinition[]
|
||||
outputs: SlotDefinition[]
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
selected: false,
|
||||
isExecuting: false,
|
||||
hasError: false,
|
||||
isBypassed: false,
|
||||
isMuted: false,
|
||||
nodeOpacity: 1,
|
||||
headerStyle: () => ({}),
|
||||
bodyStyle: () => ({}),
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
expand: []
|
||||
}>()
|
||||
|
||||
const borderClass = computed(() => {
|
||||
if (props.hasError) return 'border-red-500'
|
||||
if (props.isExecuting) return 'border-blue-500'
|
||||
return 'border-zinc-700'
|
||||
})
|
||||
|
||||
const outlineClass = computed(() => {
|
||||
if (!props.selected) return ''
|
||||
if (props.hasError) return 'outline outline-2 outline-red-500/50'
|
||||
if (props.isExecuting) return 'outline outline-2 outline-blue-500/50'
|
||||
return 'outline outline-2 outline-blue-500/50'
|
||||
})
|
||||
|
||||
const visibleInputs = computed(() => props.inputs.filter(s => !s.hidden))
|
||||
const visibleOutputs = computed(() => props.outputs.filter(s => !s.hidden))
|
||||
const hasInputs = computed(() => props.inputs.length > 0)
|
||||
const hasOutputs = computed(() => props.outputs.length > 0)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="[
|
||||
'flow-node-minimized relative rounded-lg',
|
||||
'border transition-all duration-150',
|
||||
'bg-zinc-900',
|
||||
borderClass,
|
||||
outlineClass,
|
||||
{
|
||||
'ring-4 ring-blue-500/30': selected && !hasError && !isExecuting,
|
||||
}
|
||||
]"
|
||||
:style="[bodyStyle, { opacity: nodeOpacity }]"
|
||||
>
|
||||
<div
|
||||
v-if="isBypassed || isMuted"
|
||||
class="pointer-events-none absolute inset-0 rounded-lg"
|
||||
:class="isBypassed ? 'bg-amber-500/20' : 'bg-zinc-500/30'"
|
||||
/>
|
||||
|
||||
<!-- Compact header -->
|
||||
<div
|
||||
:class="[
|
||||
'node-header-minimized py-1.5 px-2 text-xs',
|
||||
'bg-zinc-800 text-zinc-100 rounded-t-lg',
|
||||
]"
|
||||
:style="headerStyle"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2 min-w-0">
|
||||
<div class="flex items-center gap-1.5 min-w-0 flex-1">
|
||||
<button
|
||||
class="flex h-4 w-4 shrink-0 items-center justify-center rounded text-zinc-400 transition-colors hover:bg-zinc-700 hover:text-zinc-200"
|
||||
@click.stop="emit('expand')"
|
||||
>
|
||||
<i class="pi pi-chevron-down -rotate-90 text-[10px]" />
|
||||
</button>
|
||||
<span class="truncate font-medium text-[11px]">{{ title }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 items-center gap-1">
|
||||
<i
|
||||
v-if="isExecuting"
|
||||
class="pi pi-spin pi-spinner text-[10px] text-blue-400"
|
||||
/>
|
||||
<i
|
||||
v-if="hasError"
|
||||
class="pi pi-exclamation-triangle text-[10px] text-red-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Compact slots row -->
|
||||
<div class="flex items-center justify-between px-1 py-1 rounded-b-lg">
|
||||
<!-- Input dots -->
|
||||
<div class="flex items-center gap-0.5">
|
||||
<div
|
||||
v-for="(input, index) in visibleInputs"
|
||||
:key="`input-${index}`"
|
||||
class="h-2 w-2 rounded-full border border-zinc-900"
|
||||
:style="{ backgroundColor: getSlotColor(input.type) }"
|
||||
:title="input.label || input.name"
|
||||
/>
|
||||
<div v-if="!hasInputs" class="w-2" />
|
||||
</div>
|
||||
|
||||
<!-- Output dots -->
|
||||
<div class="flex items-center gap-0.5">
|
||||
<div
|
||||
v-for="(output, index) in visibleOutputs"
|
||||
:key="`output-${index}`"
|
||||
class="h-2 w-2 rounded-full border border-zinc-900"
|
||||
:style="{ backgroundColor: getSlotColor(output.type) }"
|
||||
:title="output.label || output.name"
|
||||
/>
|
||||
<div v-if="!hasOutputs" class="w-2" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Vue Flow Handles (invisible, centered vertically) -->
|
||||
<Handle
|
||||
v-if="hasInputs"
|
||||
id="input-minimized"
|
||||
type="target"
|
||||
:position="Position.Left"
|
||||
class="vue-flow-handle"
|
||||
:style="{ top: '50%' }"
|
||||
/>
|
||||
<Handle
|
||||
v-if="hasOutputs"
|
||||
id="output-minimized"
|
||||
type="source"
|
||||
:position="Position.Right"
|
||||
class="vue-flow-handle"
|
||||
:style="{ top: '50%' }"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.flow-node-minimized {
|
||||
--node-body-bg: #18181b;
|
||||
background-color: var(--node-body-bg);
|
||||
min-width: 100px;
|
||||
max-width: 160px;
|
||||
}
|
||||
|
||||
.vue-flow-handle {
|
||||
width: 16px !important;
|
||||
height: 16px !important;
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
opacity: 0 !important;
|
||||
}
|
||||
</style>
|
||||
198
ComfyUI_vibe/src/components/v2/sidebar/V2NodePanel.vue
Normal file
198
ComfyUI_vibe/src/components/v2/sidebar/V2NodePanel.vue
Normal file
@@ -0,0 +1,198 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import Button from 'primevue/button'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import { useUiStore, NODE_CATEGORIES, type NodeCategoryId } from '@/stores/uiStore'
|
||||
|
||||
const uiStore = useUiStore()
|
||||
|
||||
const activeNodeCategory = computed(() => uiStore.activeNodeCategory)
|
||||
const activeNodeCategoryData = computed(() => uiStore.activeNodeCategoryData)
|
||||
const nodePanelExpanded = computed(() => uiStore.nodePanelExpanded)
|
||||
|
||||
const searchQuery = ref('')
|
||||
|
||||
// Node preview on hover
|
||||
const hoveredNode = ref<string | null>(null)
|
||||
const previewPosition = ref({ top: 0 })
|
||||
|
||||
function handleCategoryClick(categoryId: Exclude<NodeCategoryId, null>): void {
|
||||
uiStore.toggleNodeCategory(categoryId)
|
||||
}
|
||||
|
||||
function handleNodeHover(nodeName: string, event: MouseEvent): void {
|
||||
hoveredNode.value = nodeName
|
||||
const target = event.currentTarget as HTMLElement
|
||||
const rect = target.getBoundingClientRect()
|
||||
previewPosition.value = { top: rect.top }
|
||||
}
|
||||
|
||||
function handleNodeLeave(): void {
|
||||
hoveredNode.value = null
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Level 1: Category Icon Bar -->
|
||||
<nav class="flex w-12 flex-col items-center border-r border-zinc-800 bg-zinc-900 py-2">
|
||||
<div class="flex flex-1 flex-col gap-0.5 overflow-y-auto scrollbar-hide">
|
||||
<button
|
||||
v-for="category in NODE_CATEGORIES"
|
||||
:key="category.id"
|
||||
v-tooltip.right="{ value: category.label, showDelay: 50 }"
|
||||
class="flex h-8 w-8 items-center justify-center rounded-md transition-all"
|
||||
:class="[
|
||||
activeNodeCategory === category.id
|
||||
? 'text-white'
|
||||
: 'text-zinc-500 hover:text-zinc-200'
|
||||
]"
|
||||
:style="{
|
||||
backgroundColor: activeNodeCategory === category.id ? category.color + '15' : 'transparent',
|
||||
}"
|
||||
@click="handleCategoryClick(category.id)"
|
||||
>
|
||||
<i
|
||||
:class="[category.icon, 'text-base']"
|
||||
:style="{ color: activeNodeCategory === category.id ? category.color : undefined }"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-auto flex flex-col gap-1 pt-2">
|
||||
<button
|
||||
v-tooltip.right="{ value: 'Settings', showDelay: 50 }"
|
||||
class="flex h-8 w-8 items-center justify-center rounded-md text-zinc-500 transition-colors hover:bg-zinc-800 hover:text-zinc-200"
|
||||
>
|
||||
<i class="pi pi-cog text-xs" />
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Level 2: Subcategory Panel -->
|
||||
<aside
|
||||
class="border-r border-zinc-800 bg-zinc-900/98 transition-all duration-200 ease-out"
|
||||
:class="nodePanelExpanded ? 'w-72' : 'w-0 overflow-hidden'"
|
||||
>
|
||||
<div v-if="nodePanelExpanded && activeNodeCategoryData" class="flex h-full w-72 flex-col">
|
||||
<!-- Panel Header -->
|
||||
<div class="flex items-center justify-between border-b border-zinc-800 px-3 py-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<i
|
||||
:class="activeNodeCategoryData.icon"
|
||||
class="text-sm"
|
||||
:style="{ color: activeNodeCategoryData.color }"
|
||||
/>
|
||||
<span
|
||||
class="text-sm font-semibold"
|
||||
:style="{ color: activeNodeCategoryData.color }"
|
||||
>
|
||||
{{ activeNodeCategoryData.label }}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
icon="pi pi-times"
|
||||
text
|
||||
severity="secondary"
|
||||
size="small"
|
||||
class="!h-6 !w-6"
|
||||
@click="uiStore.closeNodePanel()"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Search Box -->
|
||||
<div class="border-b border-zinc-800/50 p-2">
|
||||
<div class="relative">
|
||||
<i class="pi pi-search absolute left-2.5 top-1/2 -translate-y-1/2 text-xs text-zinc-500" />
|
||||
<InputText
|
||||
v-model="searchQuery"
|
||||
:placeholder="`Search ${activeNodeCategoryData.label.toLowerCase()}...`"
|
||||
class="!h-8 w-full !rounded !border-zinc-700 !bg-zinc-800/50 !pl-8 !text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Nodes List -->
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<div class="space-y-3 p-2">
|
||||
<div
|
||||
v-for="subcategory in activeNodeCategoryData.subcategories"
|
||||
:key="subcategory.id"
|
||||
>
|
||||
<div class="mb-1 flex h-5 items-center rounded bg-zinc-950/70 px-2">
|
||||
<span class="text-[10px] font-medium uppercase tracking-wider text-zinc-500">
|
||||
{{ subcategory.label }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="space-y-0.5">
|
||||
<div
|
||||
v-for="nodeName in subcategory.nodes"
|
||||
:key="nodeName"
|
||||
class="group flex cursor-pointer items-center rounded px-2 py-1.5 transition-colors hover:bg-zinc-800"
|
||||
draggable="true"
|
||||
@mouseenter="handleNodeHover(nodeName, $event)"
|
||||
@mouseleave="handleNodeLeave"
|
||||
>
|
||||
<span class="flex-1 truncate text-xs text-zinc-400 group-hover:text-zinc-200">
|
||||
{{ nodeName }}
|
||||
</span>
|
||||
<i class="pi pi-plus text-[10px] text-zinc-600 opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="border-t border-zinc-800/50 px-3 py-2">
|
||||
<div class="text-[10px] text-zinc-500">
|
||||
{{ activeNodeCategoryData.subcategories.reduce((acc, sub) => acc + sub.nodes.length, 0) }} nodes
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Node Preview Popup -->
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="hoveredNode && nodePanelExpanded"
|
||||
class="pointer-events-none fixed z-50 ml-2 w-64 rounded-lg border border-zinc-700 bg-zinc-900 p-3 shadow-xl"
|
||||
:style="{ top: `${previewPosition.top}px`, left: 'calc(48px + 288px + 8px)' }"
|
||||
>
|
||||
<div class="mb-2 flex items-center gap-2">
|
||||
<span
|
||||
class="text-sm font-medium"
|
||||
:style="{ color: activeNodeCategoryData?.color }"
|
||||
>{{ hoveredNode }}</span>
|
||||
</div>
|
||||
<p class="text-xs leading-relaxed text-zinc-400">
|
||||
Node for processing data in the workflow. Drag to canvas to add.
|
||||
</p>
|
||||
<div class="mt-2 flex flex-wrap gap-1">
|
||||
<span class="rounded bg-zinc-800 px-1.5 py-0.5 text-[10px] text-zinc-500">input: any</span>
|
||||
<span class="rounded bg-zinc-800 px-1.5 py-0.5 text-[10px] text-zinc-500">output: any</span>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
105
ComfyUI_vibe/src/components/v2/workspace/CreateProjectDialog.vue
Normal file
105
ComfyUI_vibe/src/components/v2/workspace/CreateProjectDialog.vue
Normal file
@@ -0,0 +1,105 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import Dialog from 'primevue/dialog'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import Textarea from 'primevue/textarea'
|
||||
|
||||
defineProps<{
|
||||
visible: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:visible': [value: boolean]
|
||||
create: [data: { name: string; description: string }]
|
||||
}>()
|
||||
|
||||
const newProject = ref({ name: '', description: '' })
|
||||
|
||||
watch(() => newProject.value.name, () => {
|
||||
// Reset on close
|
||||
})
|
||||
|
||||
function handleCreate(): void {
|
||||
if (!newProject.value.name.trim()) return
|
||||
emit('create', { ...newProject.value })
|
||||
emit('update:visible', false)
|
||||
newProject.value = { name: '', description: '' }
|
||||
}
|
||||
|
||||
function handleClose(): void {
|
||||
emit('update:visible', false)
|
||||
newProject.value = { name: '', description: '' }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
:visible="visible"
|
||||
:modal="true"
|
||||
:draggable="false"
|
||||
:closable="true"
|
||||
:style="{ width: '420px' }"
|
||||
:pt="{
|
||||
root: { class: 'dialog-root' },
|
||||
mask: { class: 'dialog-mask' },
|
||||
header: { class: 'dialog-header' },
|
||||
title: { class: 'dialog-title' },
|
||||
headerActions: { class: 'dialog-header-actions' },
|
||||
content: { class: 'dialog-content' },
|
||||
footer: { class: 'dialog-footer' }
|
||||
}"
|
||||
@update:visible="$emit('update:visible', $event)"
|
||||
>
|
||||
<template #header>
|
||||
<span class="dialog-title-text">Create Project</span>
|
||||
</template>
|
||||
|
||||
<div class="dialog-form">
|
||||
<div class="dialog-field">
|
||||
<label class="dialog-label">Name</label>
|
||||
<InputText
|
||||
v-model="newProject.name"
|
||||
placeholder="Project name"
|
||||
class="dialog-input"
|
||||
:pt="{
|
||||
root: { class: 'dialog-input-root' }
|
||||
}"
|
||||
@keyup.enter="handleCreate"
|
||||
/>
|
||||
</div>
|
||||
<div class="dialog-field">
|
||||
<label class="dialog-label">Description</label>
|
||||
<Textarea
|
||||
v-model="newProject.description"
|
||||
placeholder="Optional description"
|
||||
rows="3"
|
||||
class="dialog-textarea"
|
||||
:pt="{
|
||||
root: { class: 'dialog-textarea-root' }
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="dialog-actions">
|
||||
<button
|
||||
class="dialog-btn dialog-btn-secondary"
|
||||
@click="handleClose"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
:disabled="!newProject.name.trim()"
|
||||
:class="[
|
||||
'dialog-btn',
|
||||
newProject.name.trim() ? 'dialog-btn-primary' : 'dialog-btn-disabled'
|
||||
]"
|
||||
@click="handleCreate"
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -0,0 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
icon: string
|
||||
title: string
|
||||
description: string
|
||||
actionLabel?: string
|
||||
actionIcon?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
actionIcon: 'pi pi-plus'
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
action: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col items-center justify-center rounded-lg border border-dashed border-zinc-300 py-16 dark:border-zinc-700">
|
||||
<div class="flex h-12 w-12 items-center justify-center rounded-full bg-zinc-100 dark:bg-zinc-800">
|
||||
<i :class="[props.icon, 'text-xl text-zinc-400']" />
|
||||
</div>
|
||||
<h3 class="mt-4 text-sm font-medium text-zinc-900 dark:text-zinc-100">{{ props.title }}</h3>
|
||||
<p class="mt-1 text-sm text-zinc-500 dark:text-zinc-400">{{ props.description }}</p>
|
||||
<button
|
||||
v-if="props.actionLabel"
|
||||
class="mt-4 inline-flex items-center gap-2 rounded-md bg-zinc-900 px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-zinc-800 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200"
|
||||
@click="emit('action')"
|
||||
>
|
||||
<i :class="[props.actionIcon, 'text-xs']" />
|
||||
{{ props.actionLabel }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
modelValue: string
|
||||
placeholder?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative flex-1">
|
||||
<i class="pi pi-search absolute left-3 top-1/2 -translate-y-1/2 text-sm text-zinc-400" />
|
||||
<input
|
||||
:value="modelValue"
|
||||
type="text"
|
||||
:placeholder="placeholder"
|
||||
class="w-full rounded-md border border-zinc-200 bg-white py-2 pl-9 pr-4 text-sm text-zinc-900 placeholder-zinc-400 outline-none transition-colors focus:border-zinc-400 focus:ring-1 focus:ring-zinc-400 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 dark:placeholder-zinc-500 dark:focus:border-zinc-500 dark:focus:ring-zinc-500"
|
||||
@input="emit('update:modelValue', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
interface SortOption {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
modelValue: string
|
||||
options: SortOption[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative">
|
||||
<select
|
||||
:value="modelValue"
|
||||
class="appearance-none rounded-md border border-zinc-200 bg-white py-2 pl-3 pr-8 text-sm text-zinc-700 outline-none transition-colors focus:border-zinc-400 focus:ring-1 focus:ring-zinc-400 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-300 dark:focus:border-zinc-500 dark:focus:ring-zinc-500"
|
||||
@change="emit('update:modelValue', ($event.target as HTMLSelectElement).value)"
|
||||
>
|
||||
<option v-for="option in options" :key="option.value" :value="option.value">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
<i class="pi pi-chevron-down pointer-events-none absolute right-2.5 top-1/2 -translate-y-1/2 text-xs text-zinc-400" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
title: string
|
||||
subtitle?: string
|
||||
actionLabel?: string
|
||||
actionIcon?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
actionIcon: 'pi pi-plus'
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
action: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-100">
|
||||
{{ props.title }}
|
||||
</h1>
|
||||
<p v-if="props.subtitle" class="mt-1 text-sm text-zinc-500 dark:text-zinc-400">
|
||||
{{ props.subtitle }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
v-if="props.actionLabel"
|
||||
class="inline-flex items-center gap-2 rounded-md bg-zinc-900 px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-zinc-800 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200"
|
||||
@click="emit('action')"
|
||||
>
|
||||
<i :class="[props.actionIcon, 'text-xs']" />
|
||||
{{ props.actionLabel }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
type ViewMode = 'grid' | 'list'
|
||||
|
||||
defineProps<{
|
||||
modelValue: ViewMode
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: ViewMode]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex rounded-md border border-zinc-200 dark:border-zinc-700">
|
||||
<button
|
||||
:class="[
|
||||
'px-3 py-2 text-sm transition-colors',
|
||||
modelValue === 'grid'
|
||||
? 'bg-zinc-100 text-zinc-900 dark:bg-zinc-700 dark:text-zinc-100'
|
||||
: 'text-zinc-500 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100'
|
||||
]"
|
||||
@click="emit('update:modelValue', 'grid')"
|
||||
>
|
||||
<i class="pi pi-th-large" />
|
||||
</button>
|
||||
<button
|
||||
:class="[
|
||||
'px-3 py-2 text-sm transition-colors',
|
||||
modelValue === 'list'
|
||||
? 'bg-zinc-100 text-zinc-900 dark:bg-zinc-700 dark:text-zinc-100'
|
||||
: 'text-zinc-500 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100'
|
||||
]"
|
||||
@click="emit('update:modelValue', 'list')"
|
||||
>
|
||||
<i class="pi pi-list" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
6
ComfyUI_vibe/src/components/v2/workspace/index.ts
Normal file
6
ComfyUI_vibe/src/components/v2/workspace/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export { default as WorkspaceViewHeader } from './WorkspaceViewHeader.vue'
|
||||
export { default as WorkspaceEmptyState } from './WorkspaceEmptyState.vue'
|
||||
export { default as WorkspaceViewToggle } from './WorkspaceViewToggle.vue'
|
||||
export { default as WorkspaceSearchInput } from './WorkspaceSearchInput.vue'
|
||||
export { default as WorkspaceSortSelect } from './WorkspaceSortSelect.vue'
|
||||
export { default as CreateProjectDialog } from './CreateProjectDialog.vue'
|
||||
433
ComfyUI_vibe/src/data/linearTemplates.ts
Normal file
433
ComfyUI_vibe/src/data/linearTemplates.ts
Normal file
@@ -0,0 +1,433 @@
|
||||
/**
|
||||
* Linear Mode Workflow Templates
|
||||
*
|
||||
* Pre-configured workflow templates for the simplified linear interface.
|
||||
*/
|
||||
|
||||
import type { LinearWorkflowTemplate } from '@/types/linear'
|
||||
|
||||
export const LINEAR_WORKFLOW_TEMPLATES: LinearWorkflowTemplate[] = [
|
||||
// ===== TEXT TO IMAGE =====
|
||||
{
|
||||
id: 'txt2img-basic',
|
||||
name: 'Text to Image',
|
||||
description: 'Generate images from text prompts using Stable Diffusion',
|
||||
icon: 'pi-image',
|
||||
category: 'text-to-image',
|
||||
tags: ['basic', 'sd1.5', 'sdxl'],
|
||||
featured: true,
|
||||
thumbnailUrl: 'https://picsum.photos/seed/txt2img/400/300',
|
||||
steps: [
|
||||
{
|
||||
id: 'model',
|
||||
nodeType: 'LoadCheckpoint',
|
||||
displayName: 'Model',
|
||||
description: 'Choose your AI model',
|
||||
icon: 'pi-box',
|
||||
exposedWidgets: ['ckpt_name'],
|
||||
defaultValues: {
|
||||
ckpt_name: 'sd_xl_base_1.0.safetensors',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'prompt',
|
||||
nodeType: 'CLIPTextEncode',
|
||||
displayName: 'Prompt',
|
||||
description: 'Describe what you want to create',
|
||||
icon: 'pi-pencil',
|
||||
exposedWidgets: ['text'],
|
||||
defaultValues: {
|
||||
text: 'A beautiful sunset over mountains, dramatic lighting, 8k, highly detailed',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'settings',
|
||||
nodeType: 'KSampler',
|
||||
displayName: 'Settings',
|
||||
description: 'Fine-tune generation parameters',
|
||||
icon: 'pi-sliders-h',
|
||||
exposedWidgets: ['seed', 'steps', 'cfg', 'sampler_name'],
|
||||
defaultValues: {
|
||||
seed: -1,
|
||||
steps: 25,
|
||||
cfg: 7,
|
||||
sampler_name: 'dpmpp_2m',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'size',
|
||||
nodeType: 'EmptyLatentImage',
|
||||
displayName: 'Size',
|
||||
description: 'Set output dimensions',
|
||||
icon: 'pi-arrows-alt',
|
||||
exposedWidgets: ['width', 'height'],
|
||||
defaultValues: {
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
batch_size: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'output',
|
||||
nodeType: 'SaveImage',
|
||||
displayName: 'Output',
|
||||
description: 'Save your creation',
|
||||
icon: 'pi-download',
|
||||
exposedWidgets: ['filename_prefix'],
|
||||
defaultValues: {
|
||||
filename_prefix: 'linear_gen',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'txt2img-portrait',
|
||||
name: 'Portrait Generator',
|
||||
description: 'Create stunning AI portraits with optimized settings',
|
||||
icon: 'pi-user',
|
||||
category: 'text-to-image',
|
||||
tags: ['portrait', 'face', 'character'],
|
||||
featured: true,
|
||||
thumbnailUrl: 'https://picsum.photos/seed/portrait/400/300',
|
||||
steps: [
|
||||
{
|
||||
id: 'model',
|
||||
nodeType: 'LoadCheckpoint',
|
||||
displayName: 'Model',
|
||||
description: 'Portrait-optimized model',
|
||||
icon: 'pi-box',
|
||||
exposedWidgets: ['ckpt_name'],
|
||||
defaultValues: {
|
||||
ckpt_name: 'dreamshaper_8.safetensors',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'prompt',
|
||||
nodeType: 'CLIPTextEncode',
|
||||
displayName: 'Describe Person',
|
||||
description: 'Describe the person you want to create',
|
||||
icon: 'pi-pencil',
|
||||
exposedWidgets: ['text'],
|
||||
defaultValues: {
|
||||
text: 'Portrait of a person, professional photography, soft lighting, sharp focus, high detail',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'settings',
|
||||
nodeType: 'KSampler',
|
||||
displayName: 'Quality Settings',
|
||||
description: 'Adjust quality and creativity',
|
||||
icon: 'pi-sliders-h',
|
||||
exposedWidgets: ['seed', 'steps', 'cfg'],
|
||||
defaultValues: {
|
||||
seed: -1,
|
||||
steps: 30,
|
||||
cfg: 7.5,
|
||||
sampler_name: 'euler_ancestral',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'size',
|
||||
nodeType: 'EmptyLatentImage',
|
||||
displayName: 'Format',
|
||||
description: 'Portrait dimensions',
|
||||
icon: 'pi-arrows-alt',
|
||||
exposedWidgets: ['width', 'height'],
|
||||
defaultValues: {
|
||||
width: 768,
|
||||
height: 1024,
|
||||
batch_size: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'output',
|
||||
nodeType: 'SaveImage',
|
||||
displayName: 'Save',
|
||||
description: 'Save portrait',
|
||||
icon: 'pi-download',
|
||||
exposedWidgets: ['filename_prefix'],
|
||||
defaultValues: {
|
||||
filename_prefix: 'portrait',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'txt2img-landscape',
|
||||
name: 'Landscape Creator',
|
||||
description: 'Generate breathtaking landscapes and environments',
|
||||
icon: 'pi-sun',
|
||||
category: 'text-to-image',
|
||||
tags: ['landscape', 'nature', 'environment'],
|
||||
featured: false,
|
||||
thumbnailUrl: 'https://picsum.photos/seed/landscape/400/300',
|
||||
steps: [
|
||||
{
|
||||
id: 'model',
|
||||
nodeType: 'LoadCheckpoint',
|
||||
displayName: 'Model',
|
||||
icon: 'pi-box',
|
||||
exposedWidgets: ['ckpt_name'],
|
||||
defaultValues: {
|
||||
ckpt_name: 'sd_xl_base_1.0.safetensors',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'prompt',
|
||||
nodeType: 'CLIPTextEncode',
|
||||
displayName: 'Scene Description',
|
||||
description: 'Describe your landscape',
|
||||
icon: 'pi-pencil',
|
||||
exposedWidgets: ['text'],
|
||||
defaultValues: {
|
||||
text: 'Majestic mountain landscape at golden hour, dramatic clouds, photorealistic, 8k',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'settings',
|
||||
nodeType: 'KSampler',
|
||||
displayName: 'Settings',
|
||||
icon: 'pi-sliders-h',
|
||||
exposedWidgets: ['seed', 'steps', 'cfg'],
|
||||
defaultValues: {
|
||||
seed: -1,
|
||||
steps: 25,
|
||||
cfg: 7,
|
||||
sampler_name: 'dpmpp_2m',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'size',
|
||||
nodeType: 'EmptyLatentImage',
|
||||
displayName: 'Size',
|
||||
description: 'Wide format for landscapes',
|
||||
icon: 'pi-arrows-alt',
|
||||
exposedWidgets: ['width', 'height'],
|
||||
defaultValues: {
|
||||
width: 1344,
|
||||
height: 768,
|
||||
batch_size: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'output',
|
||||
nodeType: 'SaveImage',
|
||||
displayName: 'Save',
|
||||
icon: 'pi-download',
|
||||
exposedWidgets: ['filename_prefix'],
|
||||
defaultValues: {
|
||||
filename_prefix: 'landscape',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ===== IMAGE TO IMAGE =====
|
||||
{
|
||||
id: 'img2img-basic',
|
||||
name: 'Image Variation',
|
||||
description: 'Create variations of an existing image',
|
||||
icon: 'pi-images',
|
||||
category: 'image-to-image',
|
||||
tags: ['variation', 'transform'],
|
||||
featured: true,
|
||||
thumbnailUrl: 'https://picsum.photos/seed/img2img/400/300',
|
||||
steps: [
|
||||
{
|
||||
id: 'model',
|
||||
nodeType: 'LoadCheckpoint',
|
||||
displayName: 'Model',
|
||||
icon: 'pi-box',
|
||||
exposedWidgets: ['ckpt_name'],
|
||||
defaultValues: {
|
||||
ckpt_name: 'sd_xl_base_1.0.safetensors',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'prompt',
|
||||
nodeType: 'CLIPTextEncode',
|
||||
displayName: 'Style Guide',
|
||||
description: 'Describe the style transformation',
|
||||
icon: 'pi-pencil',
|
||||
exposedWidgets: ['text'],
|
||||
defaultValues: {
|
||||
text: 'Same scene, oil painting style, artistic, masterpiece',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'settings',
|
||||
nodeType: 'KSampler',
|
||||
displayName: 'Transform Settings',
|
||||
description: 'Lower denoise = closer to original',
|
||||
icon: 'pi-sliders-h',
|
||||
exposedWidgets: ['seed', 'steps', 'cfg', 'denoise'],
|
||||
defaultValues: {
|
||||
seed: -1,
|
||||
steps: 20,
|
||||
cfg: 7,
|
||||
denoise: 0.6,
|
||||
sampler_name: 'euler',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'output',
|
||||
nodeType: 'SaveImage',
|
||||
displayName: 'Save',
|
||||
icon: 'pi-download',
|
||||
exposedWidgets: ['filename_prefix'],
|
||||
defaultValues: {
|
||||
filename_prefix: 'variation',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ===== UPSCALING =====
|
||||
{
|
||||
id: 'upscale-basic',
|
||||
name: 'Image Upscaler',
|
||||
description: 'Upscale images to higher resolution with AI enhancement',
|
||||
icon: 'pi-expand',
|
||||
category: 'upscaling',
|
||||
tags: ['upscale', 'enhance', '4k'],
|
||||
featured: true,
|
||||
thumbnailUrl: 'https://picsum.photos/seed/upscale/400/300',
|
||||
steps: [
|
||||
{
|
||||
id: 'model',
|
||||
nodeType: 'LoadCheckpoint',
|
||||
displayName: 'Model',
|
||||
icon: 'pi-box',
|
||||
exposedWidgets: ['ckpt_name'],
|
||||
defaultValues: {
|
||||
ckpt_name: 'sd_xl_base_1.0.safetensors',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'prompt',
|
||||
nodeType: 'CLIPTextEncode',
|
||||
displayName: 'Enhancement Guide',
|
||||
description: 'Describe details to enhance',
|
||||
icon: 'pi-pencil',
|
||||
exposedWidgets: ['text'],
|
||||
defaultValues: {
|
||||
text: 'High quality, sharp details, 4k, ultra detailed',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'settings',
|
||||
nodeType: 'KSampler',
|
||||
displayName: 'Upscale Settings',
|
||||
icon: 'pi-sliders-h',
|
||||
exposedWidgets: ['seed', 'steps', 'denoise'],
|
||||
defaultValues: {
|
||||
seed: -1,
|
||||
steps: 15,
|
||||
cfg: 5,
|
||||
denoise: 0.4,
|
||||
sampler_name: 'euler',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'output',
|
||||
nodeType: 'SaveImage',
|
||||
displayName: 'Save',
|
||||
icon: 'pi-download',
|
||||
exposedWidgets: ['filename_prefix'],
|
||||
defaultValues: {
|
||||
filename_prefix: 'upscaled',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ===== INPAINTING =====
|
||||
{
|
||||
id: 'inpaint-basic',
|
||||
name: 'Inpaint / Edit',
|
||||
description: 'Edit parts of an image using AI',
|
||||
icon: 'pi-pencil',
|
||||
category: 'inpainting',
|
||||
tags: ['inpaint', 'edit', 'fix'],
|
||||
featured: false,
|
||||
thumbnailUrl: 'https://picsum.photos/seed/inpaint/400/300',
|
||||
steps: [
|
||||
{
|
||||
id: 'model',
|
||||
nodeType: 'LoadCheckpoint',
|
||||
displayName: 'Model',
|
||||
icon: 'pi-box',
|
||||
exposedWidgets: ['ckpt_name'],
|
||||
defaultValues: {
|
||||
ckpt_name: 'sd_xl_base_1.0.safetensors',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'prompt',
|
||||
nodeType: 'CLIPTextEncode',
|
||||
displayName: 'What to Paint',
|
||||
description: 'Describe what to add in the masked area',
|
||||
icon: 'pi-pencil',
|
||||
exposedWidgets: ['text'],
|
||||
defaultValues: {
|
||||
text: 'A beautiful flower, natural lighting, photorealistic',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'settings',
|
||||
nodeType: 'KSampler',
|
||||
displayName: 'Inpaint Settings',
|
||||
icon: 'pi-sliders-h',
|
||||
exposedWidgets: ['seed', 'steps', 'cfg', 'denoise'],
|
||||
defaultValues: {
|
||||
seed: -1,
|
||||
steps: 25,
|
||||
cfg: 7,
|
||||
denoise: 0.8,
|
||||
sampler_name: 'euler',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'output',
|
||||
nodeType: 'SaveImage',
|
||||
displayName: 'Save',
|
||||
icon: 'pi-download',
|
||||
exposedWidgets: ['filename_prefix'],
|
||||
defaultValues: {
|
||||
filename_prefix: 'inpainted',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* Get template by ID
|
||||
*/
|
||||
export function getTemplateById(id: string): LinearWorkflowTemplate | undefined {
|
||||
return LINEAR_WORKFLOW_TEMPLATES.find((t) => t.id === id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get templates by category
|
||||
*/
|
||||
export function getTemplatesByCategory(
|
||||
category: string
|
||||
): LinearWorkflowTemplate[] {
|
||||
return LINEAR_WORKFLOW_TEMPLATES.filter((t) => t.category === category)
|
||||
}
|
||||
|
||||
/**
|
||||
* Category display names and icons
|
||||
*/
|
||||
export const TEMPLATE_CATEGORIES = [
|
||||
{ id: 'text-to-image', name: 'Text to Image', icon: 'pi-image' },
|
||||
{ id: 'image-to-image', name: 'Image to Image', icon: 'pi-images' },
|
||||
{ id: 'inpainting', name: 'Inpainting', icon: 'pi-pencil' },
|
||||
{ id: 'upscaling', name: 'Upscaling', icon: 'pi-expand' },
|
||||
{ id: 'video', name: 'Video', icon: 'pi-video' },
|
||||
{ id: 'audio', name: 'Audio', icon: 'pi-volume-up' },
|
||||
{ id: 'custom', name: 'Custom', icon: 'pi-cog' },
|
||||
] as const
|
||||
460
ComfyUI_vibe/src/data/sidebarMockData.ts
Normal file
460
ComfyUI_vibe/src/data/sidebarMockData.ts
Normal file
@@ -0,0 +1,460 @@
|
||||
// Mock data for sidebar tabs
|
||||
// TODO: Replace with real API data from ComfyUI backend
|
||||
|
||||
export interface NodeItem {
|
||||
name: string
|
||||
display: string
|
||||
}
|
||||
|
||||
export interface NodeCategory {
|
||||
id: string
|
||||
label: string
|
||||
icon: string
|
||||
expanded: boolean
|
||||
nodes: NodeItem[]
|
||||
}
|
||||
|
||||
export interface ModelItem {
|
||||
name: string
|
||||
display: string
|
||||
size: string
|
||||
}
|
||||
|
||||
export interface ModelCategory {
|
||||
id: string
|
||||
label: string
|
||||
icon: string
|
||||
expanded: boolean
|
||||
models: ModelItem[]
|
||||
}
|
||||
|
||||
export interface WorkflowItem {
|
||||
name: string
|
||||
date: string
|
||||
nodes: number
|
||||
thumbnail: string
|
||||
}
|
||||
|
||||
export interface AssetItem {
|
||||
name: string
|
||||
type: string
|
||||
}
|
||||
|
||||
export interface TemplateItem {
|
||||
name: string
|
||||
display: string
|
||||
description: string
|
||||
nodes: number
|
||||
}
|
||||
|
||||
export interface TemplateCategory {
|
||||
id: string
|
||||
label: string
|
||||
icon: string
|
||||
expanded: boolean
|
||||
templates: TemplateItem[]
|
||||
}
|
||||
|
||||
export const NODE_CATEGORIES_DATA: NodeCategory[] = [
|
||||
{
|
||||
id: 'loaders',
|
||||
label: 'Loaders',
|
||||
icon: 'pi pi-download',
|
||||
expanded: true,
|
||||
nodes: [
|
||||
{ name: 'CheckpointLoaderSimple', display: 'Load Checkpoint' },
|
||||
{ name: 'VAELoader', display: 'Load VAE' },
|
||||
{ name: 'LoraLoader', display: 'Load LoRA' },
|
||||
{ name: 'CLIPLoader', display: 'Load CLIP' },
|
||||
{ name: 'ControlNetLoader', display: 'Load ControlNet Model' },
|
||||
{ name: 'UNETLoader', display: 'Load Diffusion Model' },
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'conditioning',
|
||||
label: 'Conditioning',
|
||||
icon: 'pi pi-sliders-h',
|
||||
expanded: false,
|
||||
nodes: [
|
||||
{ name: 'CLIPTextEncode', display: 'CLIP Text Encode (Prompt)' },
|
||||
{ name: 'ConditioningCombine', display: 'Conditioning (Combine)' },
|
||||
{ name: 'ConditioningSetArea', display: 'Conditioning (Set Area)' },
|
||||
{ name: 'ControlNetApply', display: 'Apply ControlNet' },
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'sampling',
|
||||
label: 'Sampling',
|
||||
icon: 'pi pi-box',
|
||||
expanded: false,
|
||||
nodes: [
|
||||
{ name: 'KSampler', display: 'KSampler' },
|
||||
{ name: 'KSamplerAdvanced', display: 'KSampler (Advanced)' },
|
||||
{ name: 'SamplerCustom', display: 'SamplerCustom' },
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'latent',
|
||||
label: 'Latent',
|
||||
icon: 'pi pi-th-large',
|
||||
expanded: false,
|
||||
nodes: [
|
||||
{ name: 'EmptyLatentImage', display: 'Empty Latent Image' },
|
||||
{ name: 'LatentUpscale', display: 'Upscale Latent' },
|
||||
{ name: 'LatentComposite', display: 'Latent Composite' },
|
||||
{ name: 'VAEDecode', display: 'VAE Decode' },
|
||||
{ name: 'VAEEncode', display: 'VAE Encode' },
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'image',
|
||||
label: 'Image',
|
||||
icon: 'pi pi-image',
|
||||
expanded: false,
|
||||
nodes: [
|
||||
{ name: 'LoadImage', display: 'Load Image' },
|
||||
{ name: 'SaveImage', display: 'Save Image' },
|
||||
{ name: 'PreviewImage', display: 'Preview Image' },
|
||||
{ name: 'ImageScale', display: 'Upscale Image' },
|
||||
{ name: 'ImageInvert', display: 'Invert Image' },
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'masking',
|
||||
label: 'Masking',
|
||||
icon: 'pi pi-clone',
|
||||
expanded: false,
|
||||
nodes: [
|
||||
{ name: 'LoadImageMask', display: 'Load Image (as Mask)' },
|
||||
{ name: 'MaskComposite', display: 'Mask Composite' },
|
||||
{ name: 'ImageToMask', display: 'Convert Image to Mask' },
|
||||
]
|
||||
},
|
||||
]
|
||||
|
||||
export const MODEL_CATEGORIES_DATA: ModelCategory[] = [
|
||||
{
|
||||
id: 'checkpoints',
|
||||
label: 'Checkpoints',
|
||||
icon: 'pi pi-box',
|
||||
expanded: true,
|
||||
models: [
|
||||
{ name: 'sd_v1-5', display: 'SD 1.5', size: '4.27 GB' },
|
||||
{ name: 'sd_xl_base_1.0', display: 'SDXL Base 1.0', size: '6.94 GB' },
|
||||
{ name: 'realistic_vision_v5', display: 'Realistic Vision V5', size: '2.13 GB' },
|
||||
{ name: 'dreamshaper_8', display: 'DreamShaper 8', size: '2.13 GB' },
|
||||
{ name: 'deliberate_v3', display: 'Deliberate V3', size: '2.13 GB' },
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'loras',
|
||||
label: 'LoRAs',
|
||||
icon: 'pi pi-link',
|
||||
expanded: false,
|
||||
models: [
|
||||
{ name: 'add_detail', display: 'Add Detail', size: '144 MB' },
|
||||
{ name: 'epi_noiseoffset', display: 'Epi Noise Offset', size: '36 MB' },
|
||||
{ name: 'film_grain', display: 'Film Grain', size: '72 MB' },
|
||||
{ name: 'lcm_lora_sdxl', display: 'LCM LoRA SDXL', size: '393 MB' },
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'vae',
|
||||
label: 'VAE',
|
||||
icon: 'pi pi-sitemap',
|
||||
expanded: false,
|
||||
models: [
|
||||
{ name: 'vae-ft-mse-840000', display: 'VAE ft MSE', size: '335 MB' },
|
||||
{ name: 'sdxl_vae', display: 'SDXL VAE', size: '335 MB' },
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'controlnet',
|
||||
label: 'ControlNet',
|
||||
icon: 'pi pi-sliders-v',
|
||||
expanded: false,
|
||||
models: [
|
||||
{ name: 'control_v11p_sd15_canny', display: 'Canny (SD1.5)', size: '1.45 GB' },
|
||||
{ name: 'control_v11p_sd15_openpose', display: 'OpenPose (SD1.5)', size: '1.45 GB' },
|
||||
{ name: 'control_v11f1p_sd15_depth', display: 'Depth (SD1.5)', size: '1.45 GB' },
|
||||
{ name: 'controlnet_sdxl_canny', display: 'Canny (SDXL)', size: '2.5 GB' },
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'embeddings',
|
||||
label: 'Embeddings',
|
||||
icon: 'pi pi-tag',
|
||||
expanded: false,
|
||||
models: [
|
||||
{ name: 'easynegative', display: 'EasyNegative', size: '24 KB' },
|
||||
{ name: 'bad_prompt_v2', display: 'Bad Prompt V2', size: '24 KB' },
|
||||
{ name: 'ng_deepnegative', display: 'NG DeepNegative', size: '24 KB' },
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'upscale',
|
||||
label: 'Upscale Models',
|
||||
icon: 'pi pi-expand',
|
||||
expanded: false,
|
||||
models: [
|
||||
{ name: '4x_ultrasharp', display: '4x UltraSharp', size: '67 MB' },
|
||||
{ name: 'realesrgan_x4plus', display: 'RealESRGAN x4+', size: '64 MB' },
|
||||
{ name: '4x_nmkd_superscale', display: '4x NMKD Superscale', size: '67 MB' },
|
||||
]
|
||||
},
|
||||
]
|
||||
|
||||
export const WORKFLOWS_DATA: WorkflowItem[] = [
|
||||
{ name: 'Basic txt2img', date: '2024-01-15', nodes: 8, thumbnail: 'txt2img' },
|
||||
{ name: 'Img2Img Pipeline', date: '2024-01-14', nodes: 12, thumbnail: 'img2img' },
|
||||
{ name: 'ControlNet Canny', date: '2024-01-13', nodes: 15, thumbnail: 'controlnet' },
|
||||
{ name: 'SDXL with Refiner', date: '2024-01-12', nodes: 18, thumbnail: 'sdxl' },
|
||||
{ name: 'Inpainting Setup', date: '2024-01-10', nodes: 10, thumbnail: 'inpaint' },
|
||||
]
|
||||
|
||||
export const ASSETS_DATA: AssetItem[] = [
|
||||
{ name: 'reference_01.png', type: 'image' },
|
||||
{ name: 'mask_template.png', type: 'image' },
|
||||
{ name: 'init_image.jpg', type: 'image' },
|
||||
]
|
||||
|
||||
// Team Library Types
|
||||
export interface TeamMember {
|
||||
name: string
|
||||
avatar?: string
|
||||
initials: string
|
||||
role: 'admin' | 'editor' | 'viewer'
|
||||
}
|
||||
|
||||
export interface BrandAsset {
|
||||
id: string
|
||||
name: string
|
||||
type: 'logo' | 'color' | 'font' | 'template' | 'guideline'
|
||||
thumbnail?: string
|
||||
value?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export interface SharedWorkflow {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
author: TeamMember
|
||||
updatedAt: string
|
||||
nodes: number
|
||||
category: string
|
||||
starred: boolean
|
||||
}
|
||||
|
||||
export interface TeamModel {
|
||||
id: string
|
||||
name: string
|
||||
type: 'checkpoint' | 'lora' | 'embedding' | 'controlnet'
|
||||
description: string
|
||||
size: string
|
||||
author: TeamMember
|
||||
downloads: number
|
||||
}
|
||||
|
||||
export interface NodePack {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
version: string
|
||||
nodes: number
|
||||
author: string
|
||||
installed: boolean
|
||||
}
|
||||
|
||||
export const TEMPLATE_CATEGORIES_DATA: TemplateCategory[] = [
|
||||
{
|
||||
id: 'official',
|
||||
label: 'Official',
|
||||
icon: 'pi pi-verified',
|
||||
expanded: true,
|
||||
templates: [
|
||||
{ name: 'txt2img-basic', display: 'Text to Image (Basic)', description: 'Simple text-to-image generation', nodes: 6 },
|
||||
{ name: 'img2img-basic', display: 'Image to Image', description: 'Transform existing images', nodes: 8 },
|
||||
{ name: 'inpainting', display: 'Inpainting', description: 'Fill masked regions', nodes: 10 },
|
||||
{ name: 'upscaling', display: 'Upscaling', description: '2x-4x image upscaling', nodes: 5 },
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'sdxl',
|
||||
label: 'SDXL',
|
||||
icon: 'pi pi-star',
|
||||
expanded: false,
|
||||
templates: [
|
||||
{ name: 'sdxl-txt2img', display: 'SDXL Text to Image', description: 'SDXL base workflow', nodes: 8 },
|
||||
{ name: 'sdxl-refiner', display: 'SDXL + Refiner', description: 'Base with refiner', nodes: 14 },
|
||||
{ name: 'sdxl-lightning', display: 'SDXL Lightning', description: '4-step fast generation', nodes: 9 },
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'controlnet',
|
||||
label: 'ControlNet',
|
||||
icon: 'pi pi-sliders-v',
|
||||
expanded: false,
|
||||
templates: [
|
||||
{ name: 'cn-canny', display: 'Canny Edge', description: 'Edge detection control', nodes: 12 },
|
||||
{ name: 'cn-depth', display: 'Depth Map', description: 'Depth-based control', nodes: 12 },
|
||||
{ name: 'cn-openpose', display: 'OpenPose', description: 'Pose control', nodes: 14 },
|
||||
{ name: 'cn-lineart', display: 'Line Art', description: 'Sketch to image', nodes: 11 },
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'video',
|
||||
label: 'Video',
|
||||
icon: 'pi pi-video',
|
||||
expanded: false,
|
||||
templates: [
|
||||
{ name: 'svd-basic', display: 'SVD Image to Video', description: 'Stable Video Diffusion', nodes: 10 },
|
||||
{ name: 'animatediff', display: 'AnimateDiff', description: 'Animation generation', nodes: 16 },
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'community',
|
||||
label: 'Community',
|
||||
icon: 'pi pi-users',
|
||||
expanded: false,
|
||||
templates: [
|
||||
{ name: 'portrait-enhance', display: 'Portrait Enhancer', description: 'Face restoration workflow', nodes: 12 },
|
||||
{ name: 'style-transfer', display: 'Style Transfer', description: 'Apply art styles', nodes: 14 },
|
||||
{ name: 'batch-process', display: 'Batch Processing', description: 'Process multiple images', nodes: 18 },
|
||||
]
|
||||
},
|
||||
]
|
||||
|
||||
// Team Library Mock Data
|
||||
export const TEAM_MEMBERS_DATA: TeamMember[] = [
|
||||
{ name: 'Sarah Chen', initials: 'SC', role: 'admin' },
|
||||
{ name: 'Mike Johnson', initials: 'MJ', role: 'editor' },
|
||||
{ name: 'Alex Rivera', initials: 'AR', role: 'editor' },
|
||||
{ name: 'Emma Wilson', initials: 'EW', role: 'viewer' },
|
||||
]
|
||||
|
||||
export const BRAND_ASSETS_DATA: BrandAsset[] = [
|
||||
{ id: '1', name: 'Primary Logo', type: 'logo', description: 'Main Netflix N logo' },
|
||||
{ id: '2', name: 'Wordmark', type: 'logo', description: 'Netflix text logo' },
|
||||
{ id: '3', name: 'Netflix Red', type: 'color', value: '#E50914', description: 'Primary brand color' },
|
||||
{ id: '4', name: 'Background Black', type: 'color', value: '#141414', description: 'Standard background' },
|
||||
{ id: '5', name: 'Netflix Sans', type: 'font', description: 'Primary typeface' },
|
||||
{ id: '6', name: 'Thumbnail Template', type: 'template', description: '16:9 show thumbnail' },
|
||||
{ id: '7', name: 'Brand Guidelines', type: 'guideline', description: 'Full brand documentation' },
|
||||
]
|
||||
|
||||
export function createSharedWorkflowsData(members: TeamMember[]): SharedWorkflow[] {
|
||||
return [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Show Thumbnail Generator',
|
||||
description: 'Standard workflow for generating show thumbnails with proper dimensions and styling',
|
||||
author: members[0]!,
|
||||
updatedAt: '2 hours ago',
|
||||
nodes: 12,
|
||||
category: 'Production',
|
||||
starred: true,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Character Portrait Pipeline',
|
||||
description: 'Generate consistent character portraits for marketing materials',
|
||||
author: members[1]!,
|
||||
updatedAt: '1 day ago',
|
||||
nodes: 18,
|
||||
category: 'Marketing',
|
||||
starred: true,
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'Background Scene Creator',
|
||||
description: 'Create atmospheric background scenes with Netflix visual style',
|
||||
author: members[2]!,
|
||||
updatedAt: '3 days ago',
|
||||
nodes: 24,
|
||||
category: 'Production',
|
||||
starred: false,
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
name: 'Social Media Variants',
|
||||
description: 'Batch generate social media sized versions',
|
||||
author: members[0]!,
|
||||
updatedAt: '1 week ago',
|
||||
nodes: 8,
|
||||
category: 'Marketing',
|
||||
starred: false,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
export function createTeamModelsData(members: TeamMember[]): TeamModel[] {
|
||||
return [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Netflix Style v2',
|
||||
type: 'lora',
|
||||
description: 'Fine-tuned for Netflix visual aesthetic',
|
||||
size: '144 MB',
|
||||
author: members[0]!,
|
||||
downloads: 156,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Show Thumbnail SDXL',
|
||||
type: 'checkpoint',
|
||||
description: 'SDXL checkpoint trained on approved thumbnails',
|
||||
size: '6.94 GB',
|
||||
author: members[1]!,
|
||||
downloads: 89,
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'Character Consistency',
|
||||
type: 'lora',
|
||||
description: 'Maintain character consistency across generations',
|
||||
size: '72 MB',
|
||||
author: members[2]!,
|
||||
downloads: 234,
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
name: 'Brand Color Embedding',
|
||||
type: 'embedding',
|
||||
description: 'Netflix color palette embedding',
|
||||
size: '24 KB',
|
||||
author: members[0]!,
|
||||
downloads: 312,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
export const NODE_PACKS_DATA: NodePack[] = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Netflix Brand Tools',
|
||||
description: 'Custom nodes for brand compliance checking and color matching',
|
||||
version: '1.2.0',
|
||||
nodes: 8,
|
||||
author: 'Netflix Creative Tech',
|
||||
installed: true,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Thumbnail Validator',
|
||||
description: 'Validates generated thumbnails against brand guidelines',
|
||||
version: '2.0.1',
|
||||
nodes: 4,
|
||||
author: 'Netflix Creative Tech',
|
||||
installed: true,
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'Asset Exporter Pro',
|
||||
description: 'Export to Netflix-standard formats and dimensions',
|
||||
version: '1.5.3',
|
||||
nodes: 6,
|
||||
author: 'Netflix Creative Tech',
|
||||
installed: false,
|
||||
},
|
||||
]
|
||||
166
ComfyUI_vibe/src/data/workflowMockData.ts
Normal file
166
ComfyUI_vibe/src/data/workflowMockData.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import type { Node, Edge } from '@vue-flow/core'
|
||||
import type { FlowNodeData, NodeState } from '@/types/node'
|
||||
import {
|
||||
LOAD_CHECKPOINT,
|
||||
CLIP_TEXT_ENCODE,
|
||||
KSAMPLER,
|
||||
EMPTY_LATENT_IMAGE,
|
||||
VAE_DECODE,
|
||||
SAVE_IMAGE,
|
||||
} from '@/data/nodeDefinitions'
|
||||
|
||||
// Helper to create FlowNodeData
|
||||
export function createNodeData(
|
||||
definition: typeof LOAD_CHECKPOINT,
|
||||
overrides: Partial<FlowNodeData> = {}
|
||||
): FlowNodeData {
|
||||
return {
|
||||
definition,
|
||||
widgetValues: Object.fromEntries(
|
||||
definition.widgets.map((w) => [w.name, w.value])
|
||||
),
|
||||
state: 'idle' as NodeState,
|
||||
flags: {},
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
// Sample workflow nodes
|
||||
export const DEMO_WORKFLOW_NODES: Node<FlowNodeData>[] = [
|
||||
{
|
||||
id: 'load-checkpoint',
|
||||
type: 'flowNode',
|
||||
position: { x: 50, y: 150 },
|
||||
data: createNodeData(LOAD_CHECKPOINT),
|
||||
},
|
||||
{
|
||||
id: 'empty-latent',
|
||||
type: 'flowNode',
|
||||
position: { x: 50, y: 400 },
|
||||
data: createNodeData(EMPTY_LATENT_IMAGE),
|
||||
},
|
||||
{
|
||||
id: 'clip-text-pos',
|
||||
type: 'flowNode',
|
||||
position: { x: 350, y: 50 },
|
||||
data: createNodeData(CLIP_TEXT_ENCODE, {
|
||||
title: 'Positive Prompt',
|
||||
widgetValues: { text: 'beautiful mountain landscape, sunset, dramatic lighting, 8k, detailed' },
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'clip-text-neg',
|
||||
type: 'flowNode',
|
||||
position: { x: 350, y: 280 },
|
||||
data: createNodeData(CLIP_TEXT_ENCODE, {
|
||||
title: 'Negative Prompt',
|
||||
widgetValues: { text: 'blurry, low quality, watermark, text' },
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'ksampler',
|
||||
type: 'flowNode',
|
||||
position: { x: 700, y: 150 },
|
||||
data: createNodeData(KSAMPLER, {
|
||||
state: 'executing',
|
||||
progress: 0.65,
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'vae-decode',
|
||||
type: 'flowNode',
|
||||
position: { x: 1050, y: 200 },
|
||||
data: createNodeData(VAE_DECODE),
|
||||
},
|
||||
{
|
||||
id: 'save-image',
|
||||
type: 'flowNode',
|
||||
position: { x: 1300, y: 200 },
|
||||
data: createNodeData(SAVE_IMAGE),
|
||||
},
|
||||
]
|
||||
|
||||
// Edges with proper slot connections
|
||||
export const DEMO_WORKFLOW_EDGES: Edge[] = [
|
||||
// LoadCheckpoint -> CLIP Text Encode (Positive)
|
||||
{
|
||||
id: 'e1',
|
||||
source: 'load-checkpoint',
|
||||
sourceHandle: 'output-1', // CLIP output
|
||||
target: 'clip-text-pos',
|
||||
targetHandle: 'input-0', // clip input
|
||||
style: { stroke: '#ffcc80', strokeWidth: 2 },
|
||||
},
|
||||
// LoadCheckpoint -> CLIP Text Encode (Negative)
|
||||
{
|
||||
id: 'e2',
|
||||
source: 'load-checkpoint',
|
||||
sourceHandle: 'output-1', // CLIP output
|
||||
target: 'clip-text-neg',
|
||||
targetHandle: 'input-0', // clip input
|
||||
style: { stroke: '#ffcc80', strokeWidth: 2 },
|
||||
},
|
||||
// LoadCheckpoint -> KSampler (model)
|
||||
{
|
||||
id: 'e3',
|
||||
source: 'load-checkpoint',
|
||||
sourceHandle: 'output-0', // MODEL output
|
||||
target: 'ksampler',
|
||||
targetHandle: 'input-0', // model input
|
||||
style: { stroke: '#b39ddb', strokeWidth: 2 },
|
||||
},
|
||||
// CLIP Positive -> KSampler (positive)
|
||||
{
|
||||
id: 'e4',
|
||||
source: 'clip-text-pos',
|
||||
sourceHandle: 'output-0', // CONDITIONING output
|
||||
target: 'ksampler',
|
||||
targetHandle: 'input-1', // positive input
|
||||
style: { stroke: '#ffab40', strokeWidth: 2 },
|
||||
},
|
||||
// CLIP Negative -> KSampler (negative)
|
||||
{
|
||||
id: 'e5',
|
||||
source: 'clip-text-neg',
|
||||
sourceHandle: 'output-0', // CONDITIONING output
|
||||
target: 'ksampler',
|
||||
targetHandle: 'input-2', // negative input
|
||||
style: { stroke: '#ffab40', strokeWidth: 2 },
|
||||
},
|
||||
// Empty Latent -> KSampler (latent_image)
|
||||
{
|
||||
id: 'e6',
|
||||
source: 'empty-latent',
|
||||
sourceHandle: 'output-0', // LATENT output
|
||||
target: 'ksampler',
|
||||
targetHandle: 'input-3', // latent_image input
|
||||
style: { stroke: '#ff80ab', strokeWidth: 2 },
|
||||
},
|
||||
// KSampler -> VAE Decode (samples)
|
||||
{
|
||||
id: 'e7',
|
||||
source: 'ksampler',
|
||||
sourceHandle: 'output-0', // LATENT output
|
||||
target: 'vae-decode',
|
||||
targetHandle: 'input-0', // samples input
|
||||
style: { stroke: '#ff80ab', strokeWidth: 2 },
|
||||
},
|
||||
// LoadCheckpoint -> VAE Decode (vae)
|
||||
{
|
||||
id: 'e8',
|
||||
source: 'load-checkpoint',
|
||||
sourceHandle: 'output-2', // VAE output
|
||||
target: 'vae-decode',
|
||||
targetHandle: 'input-1', // vae input
|
||||
style: { stroke: '#ef5350', strokeWidth: 2 },
|
||||
},
|
||||
// VAE Decode -> Save Image
|
||||
{
|
||||
id: 'e9',
|
||||
source: 'vae-decode',
|
||||
sourceHandle: 'output-0', // IMAGE output
|
||||
target: 'save-image',
|
||||
targetHandle: 'input-0', // images input
|
||||
style: { stroke: '#64b5f6', strokeWidth: 2 },
|
||||
},
|
||||
]
|
||||
349
ComfyUI_vibe/src/stores/linearModeStore.ts
Normal file
349
ComfyUI_vibe/src/stores/linearModeStore.ts
Normal file
@@ -0,0 +1,349 @@
|
||||
/**
|
||||
* Linear Mode Store
|
||||
*
|
||||
* Manages state for the simplified Runway/Midjourney-style workflow interface.
|
||||
*/
|
||||
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import type {
|
||||
LinearWorkflowTemplate,
|
||||
LinearWorkflowInstance,
|
||||
LinearStep,
|
||||
LinearOutput,
|
||||
LinearExecutionState,
|
||||
LinearViewMode,
|
||||
LinearHistoryEntry,
|
||||
} from '@/types/linear'
|
||||
import { LINEAR_WORKFLOW_TEMPLATES } from '@/data/linearTemplates'
|
||||
import { NODE_DEFINITIONS } from '@/data/nodeDefinitions'
|
||||
|
||||
export const useLinearModeStore = defineStore('linearMode', () => {
|
||||
// ===== State =====
|
||||
|
||||
// Current view mode
|
||||
const viewMode = ref<LinearViewMode>('create')
|
||||
|
||||
// Available templates
|
||||
const templates = ref<LinearWorkflowTemplate[]>(LINEAR_WORKFLOW_TEMPLATES)
|
||||
|
||||
// Selected template
|
||||
const selectedTemplate = ref<LinearWorkflowTemplate | null>(null)
|
||||
|
||||
// Current workflow instance
|
||||
const currentWorkflow = ref<LinearWorkflowInstance | null>(null)
|
||||
|
||||
// Generated outputs gallery
|
||||
const outputs = ref<LinearOutput[]>([])
|
||||
|
||||
// History of completed workflows
|
||||
const history = ref<LinearHistoryEntry[]>([])
|
||||
|
||||
// UI State
|
||||
const isGenerating = ref(false)
|
||||
const showTemplateSelector = ref(true)
|
||||
|
||||
// ===== Getters =====
|
||||
|
||||
const featuredTemplates = computed(() =>
|
||||
templates.value.filter((t) => t.featured)
|
||||
)
|
||||
|
||||
const templatesByCategory = computed(() => {
|
||||
const grouped: Record<string, LinearWorkflowTemplate[]> = {}
|
||||
for (const template of templates.value) {
|
||||
if (!grouped[template.category]) {
|
||||
grouped[template.category] = []
|
||||
}
|
||||
grouped[template.category].push(template)
|
||||
}
|
||||
return grouped
|
||||
})
|
||||
|
||||
const currentSteps = computed(() => currentWorkflow.value?.steps ?? [])
|
||||
|
||||
const currentStepIndex = computed(
|
||||
() => currentWorkflow.value?.currentStepIndex ?? 0
|
||||
)
|
||||
|
||||
const executionProgress = computed(() => {
|
||||
if (!currentWorkflow.value) return 0
|
||||
const { steps, currentStepIndex } = currentWorkflow.value
|
||||
if (steps.length === 0) return 0
|
||||
|
||||
// Calculate progress based on completed steps
|
||||
const completedSteps = steps.filter((s) => s.state === 'completed').length
|
||||
const currentProgress = steps[currentStepIndex]?.progress ?? 0
|
||||
|
||||
return ((completedSteps + currentProgress / 100) / steps.length) * 100
|
||||
})
|
||||
|
||||
const canGenerate = computed(() => {
|
||||
return currentWorkflow.value !== null && !isGenerating.value
|
||||
})
|
||||
|
||||
// ===== Actions =====
|
||||
|
||||
/**
|
||||
* Select a template and create a new workflow instance
|
||||
*/
|
||||
function selectTemplate(template: LinearWorkflowTemplate): void {
|
||||
selectedTemplate.value = template
|
||||
showTemplateSelector.value = false
|
||||
|
||||
// Create workflow instance from template
|
||||
const steps: LinearStep[] = template.steps.map((stepTemplate, index) => {
|
||||
const nodeDef = NODE_DEFINITIONS[stepTemplate.nodeType]
|
||||
if (!nodeDef) {
|
||||
console.warn(`Node definition not found: ${stepTemplate.nodeType}`)
|
||||
}
|
||||
|
||||
// Determine category based on step position
|
||||
let category: 'input' | 'process' | 'output' = 'process'
|
||||
if (index === 0) category = 'input'
|
||||
else if (index === template.steps.length - 1) category = 'output'
|
||||
|
||||
// Build widget values from defaults
|
||||
const widgetValues: Record<string, unknown> = {}
|
||||
if (nodeDef) {
|
||||
for (const widget of nodeDef.widgets) {
|
||||
widgetValues[widget.name] =
|
||||
stepTemplate.defaultValues?.[widget.name] ?? widget.value
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: `step-${index}-${Date.now()}`,
|
||||
nodeType: stepTemplate.nodeType,
|
||||
displayName: stepTemplate.displayName,
|
||||
description: stepTemplate.description,
|
||||
icon: stepTemplate.icon,
|
||||
category,
|
||||
state: 'idle',
|
||||
exposedWidgets: stepTemplate.exposedWidgets,
|
||||
widgetValues,
|
||||
definition: nodeDef ?? createPlaceholderDefinition(stepTemplate.nodeType),
|
||||
}
|
||||
})
|
||||
|
||||
currentWorkflow.value = {
|
||||
id: `workflow-${Date.now()}`,
|
||||
templateId: template.id,
|
||||
templateName: template.name,
|
||||
executionState: 'idle',
|
||||
currentStepIndex: 0,
|
||||
steps,
|
||||
outputs: [],
|
||||
createdAt: new Date(),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a widget value for a step
|
||||
*/
|
||||
function updateStepWidget(
|
||||
stepId: string,
|
||||
widgetName: string,
|
||||
value: unknown
|
||||
): void {
|
||||
if (!currentWorkflow.value) return
|
||||
|
||||
const step = currentWorkflow.value.steps.find((s) => s.id === stepId)
|
||||
if (step) {
|
||||
step.widgetValues[widgetName] = value
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start generating (mock implementation)
|
||||
*/
|
||||
async function startGeneration(): Promise<void> {
|
||||
if (!currentWorkflow.value) return
|
||||
|
||||
isGenerating.value = true
|
||||
currentWorkflow.value.executionState = 'running'
|
||||
currentWorkflow.value.startedAt = new Date()
|
||||
|
||||
// Simulate step-by-step execution
|
||||
for (let i = 0; i < currentWorkflow.value.steps.length; i++) {
|
||||
currentWorkflow.value.currentStepIndex = i
|
||||
const step = currentWorkflow.value.steps[i]
|
||||
|
||||
step.state = 'executing'
|
||||
|
||||
// Simulate progress
|
||||
for (let progress = 0; progress <= 100; progress += 10) {
|
||||
step.progress = progress
|
||||
await sleep(100)
|
||||
}
|
||||
|
||||
step.state = 'completed'
|
||||
step.progress = 100
|
||||
}
|
||||
|
||||
// Add mock output
|
||||
const mockOutput: LinearOutput = {
|
||||
id: `output-${Date.now()}`,
|
||||
type: 'image',
|
||||
url: 'https://picsum.photos/seed/' + Date.now() + '/512/512',
|
||||
thumbnailUrl: 'https://picsum.photos/seed/' + Date.now() + '/256/256',
|
||||
filename: `generation_${Date.now()}.png`,
|
||||
createdAt: new Date(),
|
||||
metadata: extractMetadata(currentWorkflow.value),
|
||||
}
|
||||
|
||||
currentWorkflow.value.outputs.push(mockOutput)
|
||||
outputs.value.unshift(mockOutput)
|
||||
|
||||
currentWorkflow.value.executionState = 'completed'
|
||||
currentWorkflow.value.completedAt = new Date()
|
||||
|
||||
// Add to history
|
||||
history.value.unshift({
|
||||
id: `history-${Date.now()}`,
|
||||
workflowInstance: { ...currentWorkflow.value },
|
||||
outputs: [...currentWorkflow.value.outputs],
|
||||
createdAt: new Date(),
|
||||
})
|
||||
|
||||
isGenerating.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel generation
|
||||
*/
|
||||
function cancelGeneration(): void {
|
||||
if (!currentWorkflow.value || !isGenerating.value) return
|
||||
|
||||
isGenerating.value = false
|
||||
currentWorkflow.value.executionState = 'cancelled'
|
||||
|
||||
// Mark current step as idle
|
||||
const currentStep =
|
||||
currentWorkflow.value.steps[currentWorkflow.value.currentStepIndex]
|
||||
if (currentStep) {
|
||||
currentStep.state = 'idle'
|
||||
currentStep.progress = 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset current workflow to initial state
|
||||
*/
|
||||
function resetWorkflow(): void {
|
||||
if (!currentWorkflow.value) return
|
||||
|
||||
currentWorkflow.value.executionState = 'idle'
|
||||
currentWorkflow.value.currentStepIndex = 0
|
||||
currentWorkflow.value.outputs = []
|
||||
currentWorkflow.value.startedAt = undefined
|
||||
currentWorkflow.value.completedAt = undefined
|
||||
|
||||
for (const step of currentWorkflow.value.steps) {
|
||||
step.state = 'idle'
|
||||
step.progress = 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Go back to template selection
|
||||
*/
|
||||
function showTemplates(): void {
|
||||
showTemplateSelector.value = true
|
||||
selectedTemplate.value = null
|
||||
currentWorkflow.value = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Set view mode
|
||||
*/
|
||||
function setViewMode(mode: LinearViewMode): void {
|
||||
viewMode.value = mode
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an output from gallery
|
||||
*/
|
||||
function deleteOutput(outputId: string): void {
|
||||
const index = outputs.value.findIndex((o) => o.id === outputId)
|
||||
if (index !== -1) {
|
||||
outputs.value.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all outputs
|
||||
*/
|
||||
function clearOutputs(): void {
|
||||
outputs.value = []
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
viewMode,
|
||||
templates,
|
||||
selectedTemplate,
|
||||
currentWorkflow,
|
||||
outputs,
|
||||
history,
|
||||
isGenerating,
|
||||
showTemplateSelector,
|
||||
|
||||
// Getters
|
||||
featuredTemplates,
|
||||
templatesByCategory,
|
||||
currentSteps,
|
||||
currentStepIndex,
|
||||
executionProgress,
|
||||
canGenerate,
|
||||
|
||||
// Actions
|
||||
selectTemplate,
|
||||
updateStepWidget,
|
||||
startGeneration,
|
||||
cancelGeneration,
|
||||
resetWorkflow,
|
||||
showTemplates,
|
||||
setViewMode,
|
||||
deleteOutput,
|
||||
clearOutputs,
|
||||
}
|
||||
})
|
||||
|
||||
// ===== Helpers =====
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
function createPlaceholderDefinition(nodeType: string) {
|
||||
return {
|
||||
type: nodeType,
|
||||
displayName: nodeType,
|
||||
category: { name: 'unknown', color: '#888' },
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
widgets: [],
|
||||
}
|
||||
}
|
||||
|
||||
function extractMetadata(
|
||||
workflow: LinearWorkflowInstance
|
||||
): LinearOutput['metadata'] {
|
||||
const metadata: LinearOutput['metadata'] = {}
|
||||
|
||||
for (const step of workflow.steps) {
|
||||
const values = step.widgetValues
|
||||
|
||||
if (values.text) metadata.prompt = String(values.text)
|
||||
if (values.seed) metadata.seed = Number(values.seed)
|
||||
if (values.steps) metadata.steps = Number(values.steps)
|
||||
if (values.cfg) metadata.cfg = Number(values.cfg)
|
||||
if (values.sampler_name) metadata.sampler = String(values.sampler_name)
|
||||
if (values.ckpt_name) metadata.model = String(values.ckpt_name)
|
||||
if (values.width) metadata.width = Number(values.width)
|
||||
if (values.height) metadata.height = Number(values.height)
|
||||
}
|
||||
|
||||
return metadata
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { ref, computed } from 'vue'
|
||||
|
||||
export type InterfaceVersion = 'v1' | 'v2'
|
||||
|
||||
export type SidebarTabId = 'nodes' | 'models' | 'workflows' | 'assets' | 'templates' | 'library' | null
|
||||
export type SidebarTabId = 'nodes' | 'models' | 'workflows' | 'assets' | 'templates' | 'library' | 'packages' | null
|
||||
|
||||
export interface SidebarTab {
|
||||
id: Exclude<SidebarTabId, null>
|
||||
@@ -219,16 +219,16 @@ export const SIDEBAR_TABS: SidebarTab[] = [
|
||||
|
||||
// V2 bottom bar tabs
|
||||
export const BOTTOM_BAR_TABS: SidebarTab[] = [
|
||||
{ id: 'workflows', label: 'Workflows', icon: 'pi pi-sitemap', tooltip: 'Canvas Workflows' },
|
||||
{ id: 'assets', label: 'Assets', icon: 'pi pi-images', tooltip: 'Assets (Generated, Imported)' },
|
||||
{ id: 'models', label: 'Models', icon: 'pi pi-box', tooltip: 'Model Library' },
|
||||
{ id: 'workflows', label: 'Workflows', icon: 'pi pi-folder-open', tooltip: 'Workflows' },
|
||||
{ id: 'assets', label: 'Assets', icon: 'pi pi-images', tooltip: 'Assets' },
|
||||
{ id: 'packages', label: 'Packages', icon: 'pi pi-th-large', tooltip: 'Node Packages' },
|
||||
{ id: 'templates', label: 'Templates', icon: 'pi pi-clone', tooltip: 'Templates' },
|
||||
{ id: 'library', label: 'Library', icon: 'pi pi-bookmark', tooltip: 'Library' },
|
||||
]
|
||||
|
||||
export const useUiStore = defineStore('ui', () => {
|
||||
// Interface version: v1 = legacy, v2 = experimental
|
||||
const interfaceVersion = ref<InterfaceVersion>('v2')
|
||||
const interfaceVersion = ref<InterfaceVersion>('v1')
|
||||
const leftSidebarOpen = ref(true)
|
||||
const rightSidebarOpen = ref(false)
|
||||
|
||||
|
||||
176
ComfyUI_vibe/src/types/linear.ts
Normal file
176
ComfyUI_vibe/src/types/linear.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* Linear Mode Type Definitions
|
||||
*
|
||||
* Linear Mode is a simplified Runway/Midjourney-style interface
|
||||
* that presents complex ComfyUI workflows as simple step-by-step flows.
|
||||
*/
|
||||
|
||||
import type { NodeDefinition, WidgetDefinition, NodeState } from './node'
|
||||
|
||||
/**
|
||||
* A step in a linear workflow - represents a node in simplified form
|
||||
*/
|
||||
export interface LinearStep {
|
||||
id: string
|
||||
nodeType: string
|
||||
displayName: string
|
||||
description?: string
|
||||
icon?: string
|
||||
category: 'input' | 'process' | 'output'
|
||||
state: NodeState
|
||||
progress?: number
|
||||
|
||||
// Which widgets to expose to the user (simplified subset)
|
||||
exposedWidgets: string[]
|
||||
|
||||
// Current widget values
|
||||
widgetValues: Record<string, unknown>
|
||||
|
||||
// Original node definition for reference
|
||||
definition: NodeDefinition
|
||||
|
||||
// Preview image URL (for output nodes)
|
||||
previewUrl?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* A linear workflow template - a pre-configured workflow
|
||||
*/
|
||||
export interface LinearWorkflowTemplate {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
icon: string
|
||||
category: LinearTemplateCategory
|
||||
tags: string[]
|
||||
|
||||
// The steps in order
|
||||
steps: LinearStepTemplate[]
|
||||
|
||||
// Thumbnail preview
|
||||
thumbnailUrl?: string
|
||||
|
||||
// Is this a featured/official template?
|
||||
featured?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Template for a step - defines what node to use and default config
|
||||
*/
|
||||
export interface LinearStepTemplate {
|
||||
id: string
|
||||
nodeType: string
|
||||
displayName: string
|
||||
description?: string
|
||||
icon?: string
|
||||
|
||||
// Which widgets to expose (by name)
|
||||
exposedWidgets: string[]
|
||||
|
||||
// Default values for widgets
|
||||
defaultValues?: Record<string, unknown>
|
||||
|
||||
// Widget overrides (labels, options, etc.)
|
||||
widgetOverrides?: Record<string, Partial<WidgetDefinition>>
|
||||
}
|
||||
|
||||
/**
|
||||
* Categories for workflow templates
|
||||
*/
|
||||
export type LinearTemplateCategory =
|
||||
| 'text-to-image'
|
||||
| 'image-to-image'
|
||||
| 'inpainting'
|
||||
| 'upscaling'
|
||||
| 'video'
|
||||
| 'audio'
|
||||
| 'custom'
|
||||
|
||||
/**
|
||||
* Execution state for linear workflow
|
||||
*/
|
||||
export type LinearExecutionState =
|
||||
| 'idle'
|
||||
| 'queued'
|
||||
| 'running'
|
||||
| 'completed'
|
||||
| 'error'
|
||||
| 'cancelled'
|
||||
|
||||
/**
|
||||
* A running linear workflow instance
|
||||
*/
|
||||
export interface LinearWorkflowInstance {
|
||||
id: string
|
||||
templateId: string
|
||||
templateName: string
|
||||
|
||||
// Current execution state
|
||||
executionState: LinearExecutionState
|
||||
|
||||
// Which step is currently executing (0-indexed)
|
||||
currentStepIndex: number
|
||||
|
||||
// The actual steps with current values
|
||||
steps: LinearStep[]
|
||||
|
||||
// Generated outputs
|
||||
outputs: LinearOutput[]
|
||||
|
||||
// Error message if failed
|
||||
errorMessage?: string
|
||||
|
||||
// Timestamps
|
||||
createdAt: Date
|
||||
startedAt?: Date
|
||||
completedAt?: Date
|
||||
}
|
||||
|
||||
/**
|
||||
* An output from a linear workflow
|
||||
*/
|
||||
export interface LinearOutput {
|
||||
id: string
|
||||
type: 'image' | 'video' | 'audio' | 'text' | 'file'
|
||||
url: string
|
||||
thumbnailUrl?: string
|
||||
filename: string
|
||||
createdAt: Date
|
||||
|
||||
// Metadata about the generation
|
||||
metadata?: {
|
||||
prompt?: string
|
||||
negativePrompt?: string
|
||||
seed?: number
|
||||
steps?: number
|
||||
cfg?: number
|
||||
sampler?: string
|
||||
model?: string
|
||||
width?: number
|
||||
height?: number
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* History entry for linear mode
|
||||
*/
|
||||
export interface LinearHistoryEntry {
|
||||
id: string
|
||||
workflowInstance: LinearWorkflowInstance
|
||||
outputs: LinearOutput[]
|
||||
createdAt: Date
|
||||
}
|
||||
|
||||
/**
|
||||
* View modes for linear mode
|
||||
*/
|
||||
export type LinearViewMode = 'create' | 'gallery' | 'history'
|
||||
|
||||
/**
|
||||
* Panel states for the UI
|
||||
*/
|
||||
export interface LinearPanelState {
|
||||
templateSelectorOpen: boolean
|
||||
parametersPanelOpen: boolean
|
||||
historyPanelOpen: boolean
|
||||
}
|
||||
@@ -8,22 +8,15 @@ import '@vue-flow/core/dist/theme-default.css'
|
||||
import CanvasTabBar from '@/components/v2/canvas/CanvasTabBar.vue'
|
||||
import CanvasLeftSidebar from '@/components/v2/canvas/CanvasLeftSidebar.vue'
|
||||
import CanvasBottomBar from '@/components/v2/canvas/CanvasBottomBar.vue'
|
||||
import NodePropertiesPanel from '@/components/v2/canvas/NodePropertiesPanel.vue'
|
||||
import { FlowNode } from '@/components/v2/nodes'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { useUiStore } from '@/stores/uiStore'
|
||||
|
||||
import {
|
||||
LOAD_CHECKPOINT,
|
||||
CLIP_TEXT_ENCODE,
|
||||
KSAMPLER,
|
||||
EMPTY_LATENT_IMAGE,
|
||||
VAE_DECODE,
|
||||
SAVE_IMAGE,
|
||||
} from '@/data/nodeDefinitions'
|
||||
import { DEMO_WORKFLOW_NODES, DEMO_WORKFLOW_EDGES } from '@/data/workflowMockData'
|
||||
|
||||
import type { CanvasRouteParams } from '@/types/canvas'
|
||||
import type { FlowNodeData, NodeState } from '@/types/node'
|
||||
import type { Node, Edge } from '@vue-flow/core'
|
||||
import type { Node } from '@vue-flow/core'
|
||||
|
||||
const props = defineProps<CanvasRouteParams>()
|
||||
|
||||
@@ -37,169 +30,24 @@ const nodeTypes = {
|
||||
flowNode: markRaw(FlowNode),
|
||||
}
|
||||
|
||||
// Helper to create FlowNodeData
|
||||
function createNodeData(
|
||||
definition: typeof LOAD_CHECKPOINT,
|
||||
overrides: Partial<FlowNodeData> = {}
|
||||
): FlowNodeData {
|
||||
return {
|
||||
definition,
|
||||
widgetValues: Object.fromEntries(
|
||||
definition.widgets.map((w) => [w.name, w.value])
|
||||
),
|
||||
state: 'idle' as NodeState,
|
||||
flags: {},
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
workspaceStore.setCurrentIds(props.workspaceId, props.projectId, props.canvasId)
|
||||
workspaceStore.openCanvas(props.canvasId, props.canvasId, props.projectId)
|
||||
})
|
||||
|
||||
// Vue Flow
|
||||
const { onNodeClick, onPaneClick } = useVueFlow()
|
||||
const { onNodeClick, onPaneClick, fitView } = useVueFlow()
|
||||
|
||||
// Sample workflow nodes
|
||||
const nodes = ref<Node<FlowNodeData>[]>([
|
||||
{
|
||||
id: 'load-checkpoint',
|
||||
type: 'flowNode',
|
||||
position: { x: 50, y: 150 },
|
||||
data: createNodeData(LOAD_CHECKPOINT),
|
||||
},
|
||||
{
|
||||
id: 'empty-latent',
|
||||
type: 'flowNode',
|
||||
position: { x: 50, y: 400 },
|
||||
data: createNodeData(EMPTY_LATENT_IMAGE),
|
||||
},
|
||||
{
|
||||
id: 'clip-text-pos',
|
||||
type: 'flowNode',
|
||||
position: { x: 350, y: 50 },
|
||||
data: createNodeData(CLIP_TEXT_ENCODE, {
|
||||
title: 'Positive Prompt',
|
||||
widgetValues: { text: 'beautiful mountain landscape, sunset, dramatic lighting, 8k, detailed' },
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'clip-text-neg',
|
||||
type: 'flowNode',
|
||||
position: { x: 350, y: 280 },
|
||||
data: createNodeData(CLIP_TEXT_ENCODE, {
|
||||
title: 'Negative Prompt',
|
||||
widgetValues: { text: 'blurry, low quality, watermark, text' },
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'ksampler',
|
||||
type: 'flowNode',
|
||||
position: { x: 700, y: 150 },
|
||||
data: createNodeData(KSAMPLER, {
|
||||
state: 'executing',
|
||||
progress: 0.65,
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'vae-decode',
|
||||
type: 'flowNode',
|
||||
position: { x: 1050, y: 200 },
|
||||
data: createNodeData(VAE_DECODE),
|
||||
},
|
||||
{
|
||||
id: 'save-image',
|
||||
type: 'flowNode',
|
||||
position: { x: 1300, y: 200 },
|
||||
data: createNodeData(SAVE_IMAGE),
|
||||
},
|
||||
])
|
||||
// Center the workflow on mount with 50% zoom
|
||||
onMounted(() => {
|
||||
setTimeout(() => {
|
||||
fitView({ padding: 0.3, maxZoom: 0.75 })
|
||||
}, 100)
|
||||
})
|
||||
|
||||
// Edges with proper slot connections
|
||||
const edges = ref<Edge[]>([
|
||||
// LoadCheckpoint -> CLIP Text Encode (Positive)
|
||||
{
|
||||
id: 'e1',
|
||||
source: 'load-checkpoint',
|
||||
sourceHandle: 'output-1', // CLIP output
|
||||
target: 'clip-text-pos',
|
||||
targetHandle: 'input-0', // clip input
|
||||
style: { stroke: '#ffcc80', strokeWidth: 2 },
|
||||
},
|
||||
// LoadCheckpoint -> CLIP Text Encode (Negative)
|
||||
{
|
||||
id: 'e2',
|
||||
source: 'load-checkpoint',
|
||||
sourceHandle: 'output-1', // CLIP output
|
||||
target: 'clip-text-neg',
|
||||
targetHandle: 'input-0', // clip input
|
||||
style: { stroke: '#ffcc80', strokeWidth: 2 },
|
||||
},
|
||||
// LoadCheckpoint -> KSampler (model)
|
||||
{
|
||||
id: 'e3',
|
||||
source: 'load-checkpoint',
|
||||
sourceHandle: 'output-0', // MODEL output
|
||||
target: 'ksampler',
|
||||
targetHandle: 'input-0', // model input
|
||||
style: { stroke: '#b39ddb', strokeWidth: 2 },
|
||||
},
|
||||
// CLIP Positive -> KSampler (positive)
|
||||
{
|
||||
id: 'e4',
|
||||
source: 'clip-text-pos',
|
||||
sourceHandle: 'output-0', // CONDITIONING output
|
||||
target: 'ksampler',
|
||||
targetHandle: 'input-1', // positive input
|
||||
style: { stroke: '#ffab40', strokeWidth: 2 },
|
||||
},
|
||||
// CLIP Negative -> KSampler (negative)
|
||||
{
|
||||
id: 'e5',
|
||||
source: 'clip-text-neg',
|
||||
sourceHandle: 'output-0', // CONDITIONING output
|
||||
target: 'ksampler',
|
||||
targetHandle: 'input-2', // negative input
|
||||
style: { stroke: '#ffab40', strokeWidth: 2 },
|
||||
},
|
||||
// Empty Latent -> KSampler (latent_image)
|
||||
{
|
||||
id: 'e6',
|
||||
source: 'empty-latent',
|
||||
sourceHandle: 'output-0', // LATENT output
|
||||
target: 'ksampler',
|
||||
targetHandle: 'input-3', // latent_image input
|
||||
style: { stroke: '#ff80ab', strokeWidth: 2 },
|
||||
},
|
||||
// KSampler -> VAE Decode (samples)
|
||||
{
|
||||
id: 'e7',
|
||||
source: 'ksampler',
|
||||
sourceHandle: 'output-0', // LATENT output
|
||||
target: 'vae-decode',
|
||||
targetHandle: 'input-0', // samples input
|
||||
style: { stroke: '#ff80ab', strokeWidth: 2 },
|
||||
},
|
||||
// LoadCheckpoint -> VAE Decode (vae)
|
||||
{
|
||||
id: 'e8',
|
||||
source: 'load-checkpoint',
|
||||
sourceHandle: 'output-2', // VAE output
|
||||
target: 'vae-decode',
|
||||
targetHandle: 'input-1', // vae input
|
||||
style: { stroke: '#ef5350', strokeWidth: 2 },
|
||||
},
|
||||
// VAE Decode -> Save Image
|
||||
{
|
||||
id: 'e9',
|
||||
source: 'vae-decode',
|
||||
sourceHandle: 'output-0', // IMAGE output
|
||||
target: 'save-image',
|
||||
targetHandle: 'input-0', // images input
|
||||
style: { stroke: '#64b5f6', strokeWidth: 2 },
|
||||
},
|
||||
])
|
||||
// Workflow data
|
||||
const nodes = ref([...DEMO_WORKFLOW_NODES])
|
||||
const edges = ref([...DEMO_WORKFLOW_EDGES])
|
||||
|
||||
const selectedNode = ref<Node<FlowNodeData> | null>(null)
|
||||
|
||||
@@ -227,6 +75,10 @@ function toggleCollapsed(): void {
|
||||
node.data.flags.collapsed = !node.data.flags.collapsed
|
||||
}
|
||||
}
|
||||
|
||||
function closePropertiesPanel(): void {
|
||||
selectedNode.value = null
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -246,10 +98,9 @@ function toggleCollapsed(): void {
|
||||
v-model:nodes="nodes"
|
||||
v-model:edges="edges"
|
||||
:node-types="nodeTypes"
|
||||
:default-viewport="{ x: 50, y: 50, zoom: 0.85 }"
|
||||
:default-viewport="{ x: 0, y: 0, zoom: 0.75 }"
|
||||
:min-zoom="0.1"
|
||||
:max-zoom="4"
|
||||
fit-view-on-init
|
||||
class="vue-flow-canvas"
|
||||
>
|
||||
<Background pattern-color="#27272a" :gap="20" :size="1" />
|
||||
@@ -266,97 +117,16 @@ function toggleCollapsed(): void {
|
||||
{{ props.canvasId }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
|
||||
<!-- Right sidebar - Node Properties -->
|
||||
<aside
|
||||
<NodePropertiesPanel
|
||||
v-if="selectedNode"
|
||||
class="flex w-80 flex-col border-l border-zinc-200 bg-zinc-50/50 dark:border-zinc-800 dark:bg-zinc-900/50"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between border-b border-zinc-200 px-4 py-3 dark:border-zinc-800">
|
||||
<h2 class="text-sm font-medium text-zinc-900 dark:text-zinc-100">Properties</h2>
|
||||
<button
|
||||
class="rounded p-1 text-zinc-400 transition-colors hover:bg-zinc-100 hover:text-zinc-600 dark:hover:bg-zinc-800 dark:hover:text-zinc-300"
|
||||
@click="selectedNode = null"
|
||||
>
|
||||
<i class="pi pi-times text-xs" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Node Info -->
|
||||
<div class="flex-1 overflow-y-auto p-4">
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="text-xs font-medium text-zinc-500 dark:text-zinc-400">Node Type</label>
|
||||
<p class="mt-1 text-sm text-zinc-900 dark:text-zinc-100">
|
||||
{{ selectedNode.data.definition.displayName }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs font-medium text-zinc-500 dark:text-zinc-400">Node ID</label>
|
||||
<p class="mt-1 font-mono text-xs text-zinc-600 dark:text-zinc-400">{{ selectedNode.id }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs font-medium text-zinc-500 dark:text-zinc-400">State</label>
|
||||
<p class="mt-1 text-sm capitalize" :class="{
|
||||
'text-zinc-400': selectedNode.data.state === 'idle',
|
||||
'text-blue-400': selectedNode.data.state === 'executing',
|
||||
'text-green-400': selectedNode.data.state === 'completed',
|
||||
'text-red-400': selectedNode.data.state === 'error',
|
||||
'text-amber-400': selectedNode.data.state === 'bypassed',
|
||||
'text-zinc-500': selectedNode.data.state === 'muted',
|
||||
}">
|
||||
{{ selectedNode.data.state }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs font-medium text-zinc-500 dark:text-zinc-400">Position</label>
|
||||
<p class="mt-1 font-mono text-xs text-zinc-600 dark:text-zinc-400">
|
||||
x: {{ Math.round(selectedNode.position.x) }}, y: {{ Math.round(selectedNode.position.y) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- State toggles for demo -->
|
||||
<div class="border-t border-zinc-700 pt-4">
|
||||
<label class="text-xs font-medium text-zinc-500 dark:text-zinc-400 mb-2 block">Toggle State (Demo)</label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
class="rounded px-2 py-1 text-xs bg-zinc-700 text-zinc-300 hover:bg-zinc-600"
|
||||
@click="toggleCollapsed"
|
||||
>
|
||||
{{ selectedNode.data.flags.collapsed ? 'Expand' : 'Collapse' }}
|
||||
</button>
|
||||
<button
|
||||
class="rounded px-2 py-1 text-xs bg-blue-600 text-white hover:bg-blue-500"
|
||||
@click="toggleNodeState('executing')"
|
||||
>
|
||||
Executing
|
||||
</button>
|
||||
<button
|
||||
class="rounded px-2 py-1 text-xs bg-red-600 text-white hover:bg-red-500"
|
||||
@click="toggleNodeState('error')"
|
||||
>
|
||||
Error
|
||||
</button>
|
||||
<button
|
||||
class="rounded px-2 py-1 text-xs bg-amber-600 text-white hover:bg-amber-500"
|
||||
@click="toggleNodeState('bypassed')"
|
||||
>
|
||||
Bypass
|
||||
</button>
|
||||
<button
|
||||
class="rounded px-2 py-1 text-xs bg-zinc-600 text-white hover:bg-zinc-500"
|
||||
@click="toggleNodeState('muted')"
|
||||
>
|
||||
Mute
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
:node="selectedNode"
|
||||
@close="closePropertiesPanel"
|
||||
@toggle-state="toggleNodeState"
|
||||
@toggle-collapsed="toggleCollapsed"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import {
|
||||
WorkspaceViewHeader,
|
||||
WorkspaceEmptyState,
|
||||
WorkspaceViewToggle,
|
||||
WorkspaceSearchInput,
|
||||
WorkspaceSortSelect,
|
||||
} from '@/components/v2/workspace'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -11,11 +18,11 @@ const workspaceId = computed(() => route.params.workspaceId as string)
|
||||
type ViewMode = 'grid' | 'list'
|
||||
const viewMode = ref<ViewMode>('grid')
|
||||
|
||||
// Sort
|
||||
// Sort options
|
||||
type SortOption = 'name' | 'updated' | 'project'
|
||||
const sortBy = ref<SortOption>('updated')
|
||||
|
||||
const sortOptions: { value: SortOption; label: string }[] = [
|
||||
const sortOptions = [
|
||||
{ value: 'updated', label: 'Last updated' },
|
||||
{ value: 'name', label: 'Name' },
|
||||
{ value: 'project', label: 'Project' }
|
||||
@@ -24,7 +31,10 @@ const sortOptions: { value: SortOption; label: string }[] = [
|
||||
// Project filter
|
||||
const filterProject = ref<string>('all')
|
||||
|
||||
// Mock canvases data (all canvases across projects)
|
||||
// Search
|
||||
const searchQuery = ref('')
|
||||
|
||||
// Mock canvases data
|
||||
const canvases = ref([
|
||||
{ id: 'main-workflow', name: 'Main Workflow', projectId: 'img-gen', projectName: 'Image Generation', updatedAt: '2 hours ago', updatedTimestamp: Date.now() - 2 * 60 * 60 * 1000 },
|
||||
{ id: 'test-canvas', name: 'Test Canvas', projectId: 'img-gen', projectName: 'Image Generation', updatedAt: '1 day ago', updatedTimestamp: Date.now() - 24 * 60 * 60 * 1000 },
|
||||
@@ -40,20 +50,20 @@ const projectOptions = computed(() => {
|
||||
canvases.value.forEach((c) => {
|
||||
projects.set(c.projectId, c.projectName)
|
||||
})
|
||||
return Array.from(projects.entries()).map(([id, name]) => ({ id, name }))
|
||||
return [
|
||||
{ value: 'all', label: 'All projects' },
|
||||
...Array.from(projects.entries()).map(([id, name]) => ({ value: id, label: name }))
|
||||
]
|
||||
})
|
||||
|
||||
// Search, filter and sort
|
||||
const searchQuery = ref('')
|
||||
// Filter and sort canvases
|
||||
const filteredCanvases = computed(() => {
|
||||
let result = canvases.value
|
||||
|
||||
// Filter by project
|
||||
if (filterProject.value !== 'all') {
|
||||
result = result.filter((c) => c.projectId === filterProject.value)
|
||||
}
|
||||
|
||||
// Filter by search
|
||||
if (searchQuery.value) {
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
result = result.filter(
|
||||
@@ -63,7 +73,6 @@ const filteredCanvases = computed(() => {
|
||||
)
|
||||
}
|
||||
|
||||
// Sort
|
||||
result = [...result].sort((a, b) => {
|
||||
switch (sortBy.value) {
|
||||
case 'name':
|
||||
@@ -86,116 +95,38 @@ function openCanvas(canvas: { id: string; projectId: string }): void {
|
||||
function createCanvas(): void {
|
||||
router.push(`/${workspaceId.value}/default/untitled`)
|
||||
}
|
||||
|
||||
const emptyStateDescription = computed(() =>
|
||||
searchQuery.value ? 'Try a different search term' : 'Get started by creating a new canvas'
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6">
|
||||
<!-- Header -->
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-100">
|
||||
Canvases
|
||||
</h1>
|
||||
<p class="mt-1 text-sm text-zinc-500 dark:text-zinc-400">
|
||||
{{ canvases.length }} canvases across all projects
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
class="inline-flex items-center gap-2 rounded-md bg-zinc-900 px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-zinc-800 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200"
|
||||
@click="createCanvas"
|
||||
>
|
||||
<i class="pi pi-plus text-xs" />
|
||||
New Canvas
|
||||
</button>
|
||||
</div>
|
||||
<WorkspaceViewHeader
|
||||
title="Canvases"
|
||||
action-label="New Canvas"
|
||||
@action="createCanvas"
|
||||
/>
|
||||
|
||||
<!-- Search, Filter, Sort & View Toggle -->
|
||||
<div class="mb-6 flex items-center gap-3">
|
||||
<div class="relative flex-1">
|
||||
<i class="pi pi-search absolute left-3 top-1/2 -translate-y-1/2 text-sm text-zinc-400" />
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
placeholder="Search canvases..."
|
||||
class="w-full rounded-md border border-zinc-200 bg-white py-2 pl-9 pr-4 text-sm text-zinc-900 placeholder-zinc-400 outline-none transition-colors focus:border-zinc-400 focus:ring-1 focus:ring-zinc-400 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 dark:placeholder-zinc-500 dark:focus:border-zinc-500 dark:focus:ring-zinc-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Project Filter -->
|
||||
<div class="relative">
|
||||
<select
|
||||
v-model="filterProject"
|
||||
class="appearance-none rounded-md border border-zinc-200 bg-white py-2 pl-3 pr-8 text-sm text-zinc-700 outline-none transition-colors focus:border-zinc-400 focus:ring-1 focus:ring-zinc-400 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-300 dark:focus:border-zinc-500 dark:focus:ring-zinc-500"
|
||||
>
|
||||
<option value="all">All projects</option>
|
||||
<option v-for="project in projectOptions" :key="project.id" :value="project.id">
|
||||
{{ project.name }}
|
||||
</option>
|
||||
</select>
|
||||
<i class="pi pi-chevron-down pointer-events-none absolute right-2.5 top-1/2 -translate-y-1/2 text-xs text-zinc-400" />
|
||||
</div>
|
||||
|
||||
<!-- Sort -->
|
||||
<div class="relative">
|
||||
<select
|
||||
v-model="sortBy"
|
||||
class="appearance-none rounded-md border border-zinc-200 bg-white py-2 pl-3 pr-8 text-sm text-zinc-700 outline-none transition-colors focus:border-zinc-400 focus:ring-1 focus:ring-zinc-400 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-300 dark:focus:border-zinc-500 dark:focus:ring-zinc-500"
|
||||
>
|
||||
<option v-for="option in sortOptions" :key="option.value" :value="option.value">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
<i class="pi pi-chevron-down pointer-events-none absolute right-2.5 top-1/2 -translate-y-1/2 text-xs text-zinc-400" />
|
||||
</div>
|
||||
|
||||
<!-- View Toggle -->
|
||||
<div class="flex rounded-md border border-zinc-200 dark:border-zinc-700">
|
||||
<button
|
||||
:class="[
|
||||
'px-3 py-2 text-sm transition-colors',
|
||||
viewMode === 'grid'
|
||||
? 'bg-zinc-100 text-zinc-900 dark:bg-zinc-700 dark:text-zinc-100'
|
||||
: 'text-zinc-500 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100'
|
||||
]"
|
||||
@click="viewMode = 'grid'"
|
||||
>
|
||||
<i class="pi pi-th-large" />
|
||||
</button>
|
||||
<button
|
||||
:class="[
|
||||
'px-3 py-2 text-sm transition-colors',
|
||||
viewMode === 'list'
|
||||
? 'bg-zinc-100 text-zinc-900 dark:bg-zinc-700 dark:text-zinc-100'
|
||||
: 'text-zinc-500 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100'
|
||||
]"
|
||||
@click="viewMode = 'list'"
|
||||
>
|
||||
<i class="pi pi-list" />
|
||||
</button>
|
||||
</div>
|
||||
<WorkspaceSearchInput
|
||||
v-model="searchQuery"
|
||||
placeholder="Search canvases..."
|
||||
/>
|
||||
<WorkspaceSortSelect v-model="filterProject" :options="projectOptions" />
|
||||
<WorkspaceSortSelect v-model="sortBy" :options="sortOptions" />
|
||||
<WorkspaceViewToggle v-model="viewMode" />
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div
|
||||
<WorkspaceEmptyState
|
||||
v-if="filteredCanvases.length === 0"
|
||||
class="flex flex-col items-center justify-center rounded-lg border border-dashed border-zinc-300 py-16 dark:border-zinc-700"
|
||||
>
|
||||
<div class="flex h-12 w-12 items-center justify-center rounded-full bg-zinc-100 dark:bg-zinc-800">
|
||||
<i class="pi pi-objects-column text-xl text-zinc-400" />
|
||||
</div>
|
||||
<h3 class="mt-4 text-sm font-medium text-zinc-900 dark:text-zinc-100">No canvases found</h3>
|
||||
<p class="mt-1 text-sm text-zinc-500 dark:text-zinc-400">
|
||||
{{ searchQuery ? 'Try a different search term' : 'Get started by creating a new canvas' }}
|
||||
</p>
|
||||
<button
|
||||
v-if="!searchQuery"
|
||||
class="mt-4 inline-flex items-center gap-2 rounded-md bg-zinc-900 px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-zinc-800 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200"
|
||||
@click="createCanvas"
|
||||
>
|
||||
<i class="pi pi-plus text-xs" />
|
||||
New Canvas
|
||||
</button>
|
||||
</div>
|
||||
icon="pi pi-sitemap"
|
||||
title="No canvases found"
|
||||
:description="emptyStateDescription"
|
||||
/>
|
||||
|
||||
<!-- Grid View -->
|
||||
<div
|
||||
@@ -211,7 +142,7 @@ function createCanvas(): void {
|
||||
<div class="flex h-full flex-col">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-md bg-zinc-100 dark:bg-zinc-800">
|
||||
<i class="pi pi-objects-column text-zinc-500 dark:text-zinc-400" />
|
||||
<i class="pi pi-sitemap text-zinc-500 dark:text-zinc-400" />
|
||||
</div>
|
||||
<button
|
||||
class="rounded p-1 text-zinc-400 opacity-0 transition-opacity hover:bg-zinc-100 hover:text-zinc-600 group-hover:opacity-100 dark:hover:bg-zinc-800 dark:hover:text-zinc-300"
|
||||
@@ -244,7 +175,7 @@ function createCanvas(): void {
|
||||
@click="openCanvas(canvas)"
|
||||
>
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-md bg-zinc-100 dark:bg-zinc-800">
|
||||
<i class="pi pi-objects-column text-zinc-500 dark:text-zinc-400" />
|
||||
<i class="pi pi-sitemap text-zinc-500 dark:text-zinc-400" />
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="font-medium text-zinc-900 dark:text-zinc-100">{{ canvas.name }}</p>
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import Dialog from 'primevue/dialog'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import Textarea from 'primevue/textarea'
|
||||
import {
|
||||
WorkspaceViewHeader,
|
||||
WorkspaceEmptyState,
|
||||
WorkspaceViewToggle,
|
||||
WorkspaceSearchInput,
|
||||
WorkspaceSortSelect,
|
||||
CreateProjectDialog,
|
||||
} from '@/components/v2/workspace'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -19,7 +23,7 @@ const viewMode = ref<ViewMode>('grid')
|
||||
type SortOption = 'name' | 'updated' | 'canvases'
|
||||
const sortBy = ref<SortOption>('updated')
|
||||
|
||||
const sortOptions: { value: SortOption; label: string }[] = [
|
||||
const sortOptions = [
|
||||
{ value: 'updated', label: 'Last updated' },
|
||||
{ value: 'name', label: 'Name' },
|
||||
{ value: 'canvases', label: 'Canvas count' }
|
||||
@@ -35,24 +39,18 @@ const projects = ref([
|
||||
|
||||
// Create dialog
|
||||
const showCreateDialog = ref(false)
|
||||
const newProject = ref({ name: '', description: '' })
|
||||
|
||||
function createProject(): void {
|
||||
if (!newProject.value.name.trim()) return
|
||||
|
||||
const id = newProject.value.name.toLowerCase().replace(/\s+/g, '-')
|
||||
function handleCreateProject(data: { name: string; description: string }): void {
|
||||
const id = data.name.toLowerCase().replace(/\s+/g, '-')
|
||||
projects.value.unshift({
|
||||
id,
|
||||
name: newProject.value.name,
|
||||
description: newProject.value.description,
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
canvasCount: 0,
|
||||
modelCount: 0,
|
||||
updatedAt: 'Just now',
|
||||
updatedTimestamp: Date.now()
|
||||
})
|
||||
|
||||
showCreateDialog.value = false
|
||||
newProject.value = { name: '', description: '' }
|
||||
}
|
||||
|
||||
function openProject(projectId: string): void {
|
||||
@@ -64,7 +62,6 @@ const searchQuery = ref('')
|
||||
const filteredProjects = computed(() => {
|
||||
let result = projects.value
|
||||
|
||||
// Filter
|
||||
if (searchQuery.value) {
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
result = result.filter(
|
||||
@@ -74,7 +71,6 @@ const filteredProjects = computed(() => {
|
||||
)
|
||||
}
|
||||
|
||||
// Sort
|
||||
result = [...result].sort((a, b) => {
|
||||
switch (sortBy.value) {
|
||||
case 'name':
|
||||
@@ -89,102 +85,40 @@ const filteredProjects = computed(() => {
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
const emptyStateDescription = computed(() =>
|
||||
searchQuery.value ? 'Try a different search term' : 'Get started by creating a new project'
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6">
|
||||
<!-- Header -->
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-100">
|
||||
Projects
|
||||
</h1>
|
||||
<p class="mt-1 text-sm text-zinc-500 dark:text-zinc-400">
|
||||
{{ projects.length }} projects
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
class="inline-flex items-center gap-2 rounded-md bg-zinc-900 px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-zinc-800 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200"
|
||||
@click="showCreateDialog = true"
|
||||
>
|
||||
<i class="pi pi-plus text-xs" />
|
||||
New Project
|
||||
</button>
|
||||
</div>
|
||||
<WorkspaceViewHeader
|
||||
title="Projects"
|
||||
:subtitle="`${projects.length} projects`"
|
||||
action-label="New Project"
|
||||
@action="showCreateDialog = true"
|
||||
/>
|
||||
|
||||
<!-- Search, Sort & View Toggle -->
|
||||
<div class="mb-6 flex items-center gap-3">
|
||||
<div class="relative flex-1">
|
||||
<i class="pi pi-search absolute left-3 top-1/2 -translate-y-1/2 text-sm text-zinc-400" />
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
placeholder="Search projects..."
|
||||
class="w-full rounded-md border border-zinc-200 bg-white py-2 pl-9 pr-4 text-sm text-zinc-900 placeholder-zinc-400 outline-none transition-colors focus:border-zinc-400 focus:ring-1 focus:ring-zinc-400 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 dark:placeholder-zinc-500 dark:focus:border-zinc-500 dark:focus:ring-zinc-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Sort -->
|
||||
<div class="relative">
|
||||
<select
|
||||
v-model="sortBy"
|
||||
class="appearance-none rounded-md border border-zinc-200 bg-white py-2 pl-3 pr-8 text-sm text-zinc-700 outline-none transition-colors focus:border-zinc-400 focus:ring-1 focus:ring-zinc-400 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-300 dark:focus:border-zinc-500 dark:focus:ring-zinc-500"
|
||||
>
|
||||
<option v-for="option in sortOptions" :key="option.value" :value="option.value">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
<i class="pi pi-chevron-down pointer-events-none absolute right-2.5 top-1/2 -translate-y-1/2 text-xs text-zinc-400" />
|
||||
</div>
|
||||
|
||||
<!-- View Toggle -->
|
||||
<div class="flex rounded-md border border-zinc-200 dark:border-zinc-700">
|
||||
<button
|
||||
:class="[
|
||||
'px-3 py-2 text-sm transition-colors',
|
||||
viewMode === 'grid'
|
||||
? 'bg-zinc-100 text-zinc-900 dark:bg-zinc-700 dark:text-zinc-100'
|
||||
: 'text-zinc-500 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100'
|
||||
]"
|
||||
@click="viewMode = 'grid'"
|
||||
>
|
||||
<i class="pi pi-th-large" />
|
||||
</button>
|
||||
<button
|
||||
:class="[
|
||||
'px-3 py-2 text-sm transition-colors',
|
||||
viewMode === 'list'
|
||||
? 'bg-zinc-100 text-zinc-900 dark:bg-zinc-700 dark:text-zinc-100'
|
||||
: 'text-zinc-500 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100'
|
||||
]"
|
||||
@click="viewMode = 'list'"
|
||||
>
|
||||
<i class="pi pi-list" />
|
||||
</button>
|
||||
</div>
|
||||
<WorkspaceSearchInput
|
||||
v-model="searchQuery"
|
||||
placeholder="Search projects..."
|
||||
/>
|
||||
<WorkspaceSortSelect v-model="sortBy" :options="sortOptions" />
|
||||
<WorkspaceViewToggle v-model="viewMode" />
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div
|
||||
<WorkspaceEmptyState
|
||||
v-if="filteredProjects.length === 0"
|
||||
class="flex flex-col items-center justify-center rounded-lg border border-dashed border-zinc-300 py-16 dark:border-zinc-700"
|
||||
>
|
||||
<div class="flex h-12 w-12 items-center justify-center rounded-full bg-zinc-100 dark:bg-zinc-800">
|
||||
<i class="pi pi-folder text-xl text-zinc-400" />
|
||||
</div>
|
||||
<h3 class="mt-4 text-sm font-medium text-zinc-900 dark:text-zinc-100">No projects found</h3>
|
||||
<p class="mt-1 text-sm text-zinc-500 dark:text-zinc-400">
|
||||
{{ searchQuery ? 'Try a different search term' : 'Get started by creating a new project' }}
|
||||
</p>
|
||||
<button
|
||||
v-if="!searchQuery"
|
||||
class="mt-4 inline-flex items-center gap-2 rounded-md bg-zinc-900 px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-zinc-800 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200"
|
||||
@click="showCreateDialog = true"
|
||||
>
|
||||
<i class="pi pi-plus text-xs" />
|
||||
New Project
|
||||
</button>
|
||||
</div>
|
||||
icon="pi pi-folder"
|
||||
title="No projects found"
|
||||
:description="emptyStateDescription"
|
||||
:action-label="searchQuery ? undefined : 'New Project'"
|
||||
@action="showCreateDialog = true"
|
||||
/>
|
||||
|
||||
<!-- Grid View -->
|
||||
<div
|
||||
@@ -267,73 +201,9 @@ const filteredProjects = computed(() => {
|
||||
</div>
|
||||
|
||||
<!-- Create Dialog -->
|
||||
<Dialog
|
||||
<CreateProjectDialog
|
||||
v-model:visible="showCreateDialog"
|
||||
:modal="true"
|
||||
:draggable="false"
|
||||
:closable="true"
|
||||
:style="{ width: '420px' }"
|
||||
:pt="{
|
||||
root: { class: 'dialog-root' },
|
||||
mask: { class: 'dialog-mask' },
|
||||
header: { class: 'dialog-header' },
|
||||
title: { class: 'dialog-title' },
|
||||
headerActions: { class: 'dialog-header-actions' },
|
||||
content: { class: 'dialog-content' },
|
||||
footer: { class: 'dialog-footer' }
|
||||
}"
|
||||
>
|
||||
<template #header>
|
||||
<span class="dialog-title-text">Create Project</span>
|
||||
</template>
|
||||
|
||||
<div class="dialog-form">
|
||||
<div class="dialog-field">
|
||||
<label class="dialog-label">Name</label>
|
||||
<InputText
|
||||
v-model="newProject.name"
|
||||
placeholder="Project name"
|
||||
class="dialog-input"
|
||||
:pt="{
|
||||
root: { class: 'dialog-input-root' }
|
||||
}"
|
||||
@keyup.enter="createProject"
|
||||
/>
|
||||
</div>
|
||||
<div class="dialog-field">
|
||||
<label class="dialog-label">Description</label>
|
||||
<Textarea
|
||||
v-model="newProject.description"
|
||||
placeholder="Optional description"
|
||||
rows="3"
|
||||
class="dialog-textarea"
|
||||
:pt="{
|
||||
root: { class: 'dialog-textarea-root' }
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="dialog-actions">
|
||||
<button
|
||||
class="dialog-btn dialog-btn-secondary"
|
||||
@click="showCreateDialog = false"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
:disabled="!newProject.name.trim()"
|
||||
:class="[
|
||||
'dialog-btn',
|
||||
newProject.name.trim() ? 'dialog-btn-primary' : 'dialog-btn-disabled'
|
||||
]"
|
||||
@click="createProject"
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
@create="handleCreateProject"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user