feat(ui): Add templates sidebar, tooltips styling, and canvas menu improvements

- Add Templates tab to both v1 sidebar and v2 bottom bar
- Add template categories (Official, SDXL, ControlNet, Video, Community)
- Style tooltips with JetBrains Mono, smaller size, uppercase
- Convert native title attributes to v-tooltip directive
- Add workspace/account sections to canvas logo dropdown menu
- Move Settings to icon in sidebar footer near Sign out
- Add sort/filter features across workspace pages

🤖 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 19:33:46 -08:00
parent 508815bc6c
commit 67f8e0aba9
10 changed files with 755 additions and 187 deletions

View File

@@ -7,10 +7,14 @@
/* PrimeIcons */
@import 'primeicons/primeicons.css';
/* ===================== Font Imports ===================== */
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&display=swap');
/* ===================== Design Tokens ===================== */
@theme {
/* Font Families */
--font-sans: 'Inter', system-ui, -apple-system, sans-serif;
--font-mono: 'JetBrains Mono', ui-monospace, monospace;
/* Base Colors */
--color-white: #ffffff;
@@ -304,3 +308,32 @@ body {
color: var(--muted-foreground);
cursor: not-allowed;
}
/* ===================== PrimeVue Tooltip Overrides (v2 Interface) ===================== */
.p-tooltip {
--p-tooltip-padding: 0.25rem 0.5rem;
--p-tooltip-border-radius: 4px;
}
.p-tooltip .p-tooltip-text {
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-size: 0.625rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.25rem 0.5rem;
white-space: nowrap;
}
/* Dark mode tooltip - for canvas/editor */
.dark .p-tooltip,
.dark-theme .p-tooltip {
--p-tooltip-background: #27272a;
--p-tooltip-color: #e4e4e7;
}
/* Light mode tooltip - for workspace */
.p-tooltip {
--p-tooltip-background: #18181b;
--p-tooltip-color: #fafafa;
}

View File

@@ -11,6 +11,7 @@ declare module 'vue' {
CanvasLeftSidebar: typeof import('./components/v2/canvas/CanvasLeftSidebar.vue')['default']
CanvasTabBar: typeof import('./components/v2/canvas/CanvasTabBar.vue')['default']
FlowNode: typeof import('./components/v2/nodes/FlowNode.vue')['default']
FlowNodeMinimized: typeof import('./components/v2/nodes/FlowNodeMinimized.vue')['default']
NodeHeader: typeof import('./components/v2/nodes/NodeHeader.vue')['default']
NodeSlots: typeof import('./components/v2/nodes/NodeSlots.vue')['default']
NodeWidgets: typeof import('./components/v2/nodes/NodeWidgets.vue')['default']

View File

@@ -31,6 +31,15 @@ const mockAssets = [
{ name: 'mask_template.png', type: 'image' },
{ name: 'init_image.jpg', type: 'image' },
]
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' },
]
</script>
<template>
@@ -107,6 +116,31 @@ const mockAssets = [
</div>
</div>
<!-- Templates Tab -->
<div v-else-if="activeBottomTab === 'templates'" class="grid grid-cols-2 gap-2">
<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"
>
<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 }"
>
{{ 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>
<!-- 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" />

View File

@@ -26,6 +26,73 @@ 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 })
@@ -136,17 +203,91 @@ function toggleCategory(categoryId: string): void {
}
}
const mockModels = [
{ name: 'SD 1.5', type: 'Checkpoint' },
{ name: 'SDXL Base', type: 'Checkpoint' },
{ name: 'Realistic Vision', type: 'Checkpoint' },
{ name: 'DreamShaper', type: 'LoRA' },
]
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' },
{ name: 'Img2Img Pipeline', date: '2024-01-14' },
{ name: 'ControlNet Setup', date: '2024-01-13' },
{ 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 = [
@@ -154,6 +295,72 @@ const mockAssets = [
{ 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>
@@ -378,13 +585,103 @@ const mockAssets = [
<!-- Search Box -->
<div class="border-b border-zinc-800 p-2">
<div class="flex 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 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>
@@ -436,27 +733,103 @@ const mockAssets = [
</div>
</div>
<!-- Models Tab -->
<div v-else-if="activeSidebarTab === 'models'" class="space-y-1">
<!-- Models Tab - Tree Structure -->
<div v-else-if="activeSidebarTab === 'models'" class="space-y-0.5">
<div
v-for="model in mockModels"
:key="model.name"
class="cursor-pointer rounded px-2 py-1.5 text-xs transition-colors hover:bg-zinc-800"
v-for="category in modelCategories"
:key="category.id"
class="select-none"
>
<div class="text-zinc-300">{{ model.name }}</div>
<div class="text-[10px] text-zinc-500">{{ model.type }}</div>
<!-- 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-1">
<div v-else-if="activeSidebarTab === 'workflows'" class="space-y-2">
<!-- Workflow Cards -->
<div
v-for="workflow in mockWorkflows"
:key="workflow.name"
class="cursor-pointer rounded px-2 py-1.5 text-xs transition-colors hover:bg-zinc-800"
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"
>
<div class="text-zinc-300">{{ workflow.name }}</div>
<div class="text-[10px] text-zinc-500">{{ workflow.date }}</div>
<!-- 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>
@@ -472,6 +845,58 @@ const mockAssets = [
</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">

View File

@@ -2,6 +2,9 @@
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
@@ -30,6 +33,26 @@ 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 => ({
@@ -62,6 +85,8 @@ function closeTab(tabId: string, event: MouseEvent): void {
<!-- 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>
@@ -77,19 +102,46 @@ function closeTab(tabId: string, event: MouseEvent): void {
<span>Save</span>
<span class="shortcut">Ctrl+S</span>
</button>
<div class="menu-divider" />
<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>
<div class="menu-divider" />
<button class="menu-item" @click="uiStore.toggleInterfaceVersion()">
<i class="pi pi-sparkles menu-item-icon" />
<span>Experimental</span>
<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>
</div>
@@ -97,7 +149,7 @@ function closeTab(tabId: string, event: MouseEvent): void {
<div class="divider" />
<!-- Home Button -->
<button class="home-button" title="Go to Home" @click="handleHomeClick">
<button v-tooltip.bottom="'Home'" class="home-button" @click="handleHomeClick">
<i class="pi pi-home" />
</button>
@@ -122,17 +174,17 @@ function closeTab(tabId: string, event: MouseEvent): void {
</div>
<!-- New Tab Button -->
<button class="new-tab-button" title="New Workflow">
<button v-tooltip.bottom="'New Workflow'" class="new-tab-button">
<i class="pi pi-plus" />
</button>
</div>
<!-- Right Section -->
<div class="right-section">
<button class="action-button" title="Share">
<button v-tooltip.bottom="'Share'" class="action-button">
<i class="pi pi-share-alt" />
</button>
<button class="action-button play" title="Run Workflow">
<button v-tooltip.bottom="'Run Workflow'" class="action-button play">
<i class="pi pi-play" />
</button>
</div>
@@ -190,7 +242,7 @@ function closeTab(tabId: string, event: MouseEvent): void {
position: absolute;
top: calc(100% + 4px);
left: 0;
min-width: 200px;
min-width: 240px;
background: #18181b;
border: 1px solid #27272a;
border-radius: 8px;
@@ -211,6 +263,7 @@ function closeTab(tabId: string, event: MouseEvent): void {
color: #e4e4e7;
font-size: 13px;
text-align: left;
white-space: nowrap;
cursor: pointer;
transition: background 0.15s;
}
@@ -231,12 +284,33 @@ function closeTab(tabId: string, event: MouseEvent): void {
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;

View File

@@ -1,6 +1,9 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import Tooltip from 'primevue/tooltip'
const vTooltip = Tooltip
interface MenuItem {
label: string
@@ -23,28 +26,6 @@ const router = useRouter()
const isTeam = computed(() => props.workspaceId === 'team')
// Workspace dropdown
const showWorkspaceMenu = ref(false)
// Mock workspaces for switching
const workspaces = [
{ id: 'personal', name: 'Personal', type: 'personal' as const },
{ id: 'team', name: 'Team Workspace', type: 'team' as const }
]
function toggleWorkspaceMenu(): void {
showWorkspaceMenu.value = !showWorkspaceMenu.value
}
function closeWorkspaceMenu(): void {
showWorkspaceMenu.value = false
}
function switchWorkspace(workspaceId: string): void {
router.push(`/${workspaceId}`)
closeWorkspaceMenu()
}
const userMenuGroups = computed<MenuGroup[]>(() => [
{
label: 'Overview',
@@ -109,132 +90,24 @@ function signOut(): void {
<aside
class="flex h-full w-60 flex-col border-r border-zinc-200 bg-zinc-50/50 dark:border-zinc-800 dark:bg-zinc-950"
>
<!-- Header with Dropdown -->
<div class="relative border-b border-zinc-200 dark:border-zinc-800">
<button
class="flex h-14 w-full items-center gap-3 px-4 transition-colors hover:bg-zinc-100 dark:hover:bg-zinc-800/50"
@click="toggleWorkspaceMenu"
>
<div
:class="[
'flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-md text-sm font-semibold',
isTeam ? 'bg-blue-600 text-white' : 'bg-zinc-900 text-white dark:bg-zinc-100 dark:text-zinc-900'
]"
>
{{ workspaceId.charAt(0).toUpperCase() }}
</div>
<div class="flex-1 overflow-hidden text-left">
<p class="truncate text-sm font-medium text-zinc-900 dark:text-zinc-100">
{{ workspaceId }}
</p>
<p class="text-xs text-zinc-500 dark:text-zinc-400">
{{ isTeam ? 'Team' : 'Personal' }}
</p>
</div>
<i
:class="[
'pi text-xs text-zinc-400 transition-transform',
showWorkspaceMenu ? 'pi-chevron-up' : 'pi-chevron-down'
]"
/>
</button>
<!-- Dropdown Menu -->
<!-- Header -->
<div class="flex h-14 items-center gap-3 border-b border-zinc-200 px-4 dark:border-zinc-800">
<div
v-if="showWorkspaceMenu"
class="absolute left-2 right-2 top-full z-50 mt-1 rounded-lg border border-zinc-200 bg-white shadow-lg dark:border-zinc-700 dark:bg-zinc-900"
:class="[
'flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-md text-sm font-semibold',
isTeam ? 'bg-blue-600 text-white' : 'bg-zinc-900 text-white dark:bg-zinc-100 dark:text-zinc-900'
]"
>
<!-- Account Section -->
<div class="border-b border-zinc-100 p-2 dark:border-zinc-800">
<p class="px-2 py-1 text-xs font-medium text-zinc-500 dark:text-zinc-400">Account</p>
<RouterLink
to="/account/profile"
class="flex items-center gap-3 rounded-md px-2 py-2 text-sm text-zinc-700 transition-colors hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800"
@click="closeWorkspaceMenu"
>
<i class="pi pi-user text-zinc-400" />
<span>Profile</span>
</RouterLink>
<RouterLink
to="/account/billing"
class="flex items-center gap-3 rounded-md px-2 py-2 text-sm text-zinc-700 transition-colors hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800"
@click="closeWorkspaceMenu"
>
<i class="pi pi-credit-card text-zinc-400" />
<span>Billing</span>
</RouterLink>
</div>
<!-- Workspaces Section -->
<div class="border-b border-zinc-100 p-2 dark:border-zinc-800">
<p class="px-2 py-1 text-xs font-medium text-zinc-500 dark:text-zinc-400">Workspaces</p>
<button
v-for="ws in workspaces"
:key="ws.id"
class="flex w-full items-center gap-3 rounded-md px-2 py-2 text-left text-sm text-zinc-700 transition-colors hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800"
@click="switchWorkspace(ws.id)"
>
<div
:class="[
'flex h-6 w-6 items-center justify-center rounded text-xs font-semibold',
ws.type === 'team' ? 'bg-blue-600 text-white' : 'bg-zinc-200 text-zinc-700 dark:bg-zinc-700 dark:text-zinc-300'
]"
>
{{ ws.name.charAt(0) }}
</div>
<span class="flex-1">{{ ws.name }}</span>
<i v-if="ws.id === workspaceId" class="pi pi-check text-xs text-blue-600 dark:text-blue-400" />
</button>
<button
class="flex w-full items-center gap-3 rounded-md px-2 py-2 text-left text-sm text-zinc-500 transition-colors hover:bg-zinc-100 hover:text-zinc-700 dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-300"
@click="closeWorkspaceMenu"
>
<div class="flex h-6 w-6 items-center justify-center rounded border border-dashed border-zinc-300 dark:border-zinc-600">
<i class="pi pi-plus text-xs" />
</div>
<span>Create workspace</span>
</button>
</div>
<!-- About Section -->
<div class="p-2">
<RouterLink
to="/about"
class="flex items-center gap-3 rounded-md px-2 py-2 text-sm text-zinc-700 transition-colors hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800"
@click="closeWorkspaceMenu"
>
<i class="pi pi-info-circle text-zinc-400" />
<span>About</span>
</RouterLink>
<a
href="https://docs.comfy.org"
target="_blank"
class="flex items-center gap-3 rounded-md px-2 py-2 text-sm text-zinc-700 transition-colors hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800"
@click="closeWorkspaceMenu"
>
<i class="pi pi-book text-zinc-400" />
<span>Documentation</span>
<i class="pi pi-external-link ml-auto text-xs text-zinc-400" />
</a>
<a
href="https://github.com/comfyanonymous/ComfyUI"
target="_blank"
class="flex items-center gap-3 rounded-md px-2 py-2 text-sm text-zinc-700 transition-colors hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800"
@click="closeWorkspaceMenu"
>
<i class="pi pi-github text-zinc-400" />
<span>GitHub</span>
<i class="pi pi-external-link ml-auto text-xs text-zinc-400" />
</a>
</div>
{{ workspaceId.charAt(0).toUpperCase() }}
</div>
<div class="flex-1 overflow-hidden">
<p class="truncate text-sm font-medium text-zinc-900 dark:text-zinc-100">
{{ workspaceId }}
</p>
<p class="text-xs text-zinc-500 dark:text-zinc-400">
{{ isTeam ? 'Team' : 'Personal' }}
</p>
</div>
<!-- Backdrop to close menu -->
<div
v-if="showWorkspaceMenu"
class="fixed inset-0 z-40"
@click="closeWorkspaceMenu"
/>
</div>
<!-- Menu Groups -->
@@ -279,6 +152,7 @@ function signOut(): void {
<!-- Footer -->
<div class="flex items-center justify-end gap-1 border-t border-zinc-200 px-3 py-2 dark:border-zinc-800">
<RouterLink
v-tooltip.top="'Settings'"
:to="`/${workspaceId}/settings`"
:class="[
'flex h-8 w-8 items-center justify-center rounded-md transition-colors',
@@ -286,13 +160,12 @@ function signOut(): void {
? 'bg-zinc-900 text-white dark:bg-zinc-100 dark:text-zinc-900'
: 'text-zinc-500 hover:bg-zinc-100 hover:text-zinc-700 dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-200'
]"
title="Settings"
>
<i class="pi pi-cog text-base" />
</RouterLink>
<button
v-tooltip.top="'Sign out'"
class="flex h-8 w-8 items-center justify-center rounded-md text-zinc-500 transition-colors hover:bg-zinc-100 hover:text-zinc-700 dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-200"
title="Sign out"
@click="signOut"
>
<i class="pi pi-sign-out text-base" />

View File

@@ -2,6 +2,7 @@
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'
@@ -17,10 +18,12 @@ const props = defineProps<Props>()
const emit = defineEmits<{
'update:data': [data: Partial<FlowNodeData>]
collapse: [collapsed: boolean]
minimize: [minimized: boolean]
'update:title': [title: string]
}>()
const isCollapsed = computed(() => props.data.flags.collapsed ?? false)
const isMinimized = computed(() => props.data.flags.minimized ?? false)
const isBypassed = computed(() => props.data.state === 'bypassed')
const isMuted = computed(() => props.data.state === 'muted')
const isExecuting = computed(() => props.data.state === 'executing')
@@ -84,6 +87,20 @@ 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 }
})
emit('minimize', !isMinimized.value)
}
// Handle positioning: header (36px) + optional progress bar (4px) + half slot height (12px)
const HEADER_HEIGHT = 36
const SLOT_HEIGHT = 24
@@ -100,9 +117,11 @@ function getHandleTop(index: number): string {
</script>
<template>
<!-- Minimized View -->
<div
v-if="isMinimized"
:class="[
'flow-node relative min-w-[225px] rounded-2xl',
'flow-node-minimized relative rounded-lg',
'border transition-all duration-150',
'bg-zinc-900',
borderClass,
@@ -115,7 +134,106 @@ function getHandleTop(index: number): string {
>
<div
v-if="isBypassed || isMuted"
class="pointer-events-none absolute inset-0 rounded-2xl"
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>
<!-- Full View -->
<div
v-else
:class="[
'flow-node relative min-w-[225px] 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'"
/>
@@ -230,6 +348,13 @@ 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

@@ -89,7 +89,7 @@ function getBadgeClasses(variant?: string): string {
:class="[
'node-header py-2 pl-2 pr-3 text-sm',
'bg-zinc-800 text-zinc-100',
collapsed ? 'rounded-2xl' : 'rounded-t-2xl',
collapsed ? 'rounded-lg' : 'rounded-t-lg',
]"
@dblclick="handleDoubleClick"
>

View File

@@ -3,7 +3,7 @@ import { ref, computed } from 'vue'
export type InterfaceVersion = 'v1' | 'v2'
export type SidebarTabId = 'nodes' | 'models' | 'workflows' | 'assets' | 'library' | null
export type SidebarTabId = 'nodes' | 'models' | 'workflows' | 'assets' | 'templates' | 'library' | null
export interface SidebarTab {
id: Exclude<SidebarTabId, null>
@@ -213,6 +213,7 @@ export const SIDEBAR_TABS: SidebarTab[] = [
{ 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: 'templates', label: 'Templates', icon: 'pi pi-clone', tooltip: 'Templates' },
{ id: 'library', label: 'Library', icon: 'pi pi-bookmark', tooltip: 'Library' },
]
@@ -221,6 +222,7 @@ export const BOTTOM_BAR_TABS: SidebarTab[] = [
{ 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: 'templates', label: 'Templates', icon: 'pi pi-clone', tooltip: 'Templates' },
{ id: 'library', label: 'Library', icon: 'pi pi-bookmark', tooltip: 'Library' },
]

View File

@@ -77,6 +77,7 @@ export interface NodeBadge {
export interface NodeFlags {
collapsed?: boolean
minimized?: boolean
pinned?: boolean
resizable?: boolean
}