mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-06 16:10:09 +00:00
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:
@@ -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;
|
||||
}
|
||||
|
||||
1
ComfyUI_vibe/src/components.d.ts
vendored
1
ComfyUI_vibe/src/components.d.ts
vendored
@@ -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']
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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' },
|
||||
]
|
||||
|
||||
|
||||
@@ -77,6 +77,7 @@ export interface NodeBadge {
|
||||
|
||||
export interface NodeFlags {
|
||||
collapsed?: boolean
|
||||
minimized?: boolean
|
||||
pinned?: boolean
|
||||
resizable?: boolean
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user