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:
orkhanart
2025-11-28 20:59:59 -08:00
parent f7f137dd2e
commit 1c9715de4e
45 changed files with 4601 additions and 2112 deletions

View File

@@ -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']

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View 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'

View File

@@ -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>

View File

@@ -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>

View 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>

View File

@@ -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>

View File

@@ -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>

View 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>

View File

@@ -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>

View 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>

View 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>

View File

@@ -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>

View File

@@ -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>

View 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'

View File

@@ -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>

View File

@@ -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>

View 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>

View File

@@ -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>

View 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>

View 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>

View 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>

View File

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

View 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>

View 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>

View 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View 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'

View 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

View 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,
},
]

View 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 },
},
]

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

View File

@@ -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)

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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>