UI improvements: sidebar enhancements, node tree structure, styling updates

This commit is contained in:
orkhanart
2025-11-28 19:16:12 -08:00
parent 67af617868
commit 508815bc6c
15 changed files with 1918 additions and 256 deletions

View File

@@ -1,11 +1,34 @@
<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue'
import Toast from 'primevue/toast'
import ConfirmDialog from 'primevue/confirmdialog'
import { useUiStore } from '@/stores/uiStore'
// Initialize UI store to apply dark mode on app load
useUiStore()
const uiStore = useUiStore()
// Global keyboard shortcut: X to toggle interface version (v1/v2)
function handleKeydown(event: KeyboardEvent): void {
// Ignore if user is typing in an input or textarea
if (
event.target instanceof HTMLInputElement ||
event.target instanceof HTMLTextAreaElement
) {
return
}
if (event.key.toLowerCase() === 'x') {
uiStore.toggleInterfaceVersion()
}
}
onMounted(() => {
window.addEventListener('keydown', handleKeydown)
})
onUnmounted(() => {
window.removeEventListener('keydown', handleKeydown)
})
</script>
<template>

View File

@@ -144,3 +144,163 @@ body {
display: none;
}
}
/* ===================== Dialog Styles ===================== */
.dialog-mask {
background-color: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(2px);
}
.dialog-root {
background-color: var(--interface-panel-surface);
border: 1px solid var(--border-subtle);
border-radius: 0.5rem;
box-shadow:
0 10px 15px -3px rgba(0, 0, 0, 0.1),
0 4px 6px -4px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.dialog-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--border-subtle);
background-color: var(--interface-panel-surface);
}
.dialog-title {
flex: 1;
}
.dialog-title-text {
font-size: 1rem;
font-weight: 600;
color: var(--base-foreground);
}
.dialog-header-actions {
display: flex;
align-items: center;
gap: 0.25rem;
}
.dialog-header-actions button {
display: flex;
align-items: center;
justify-content: center;
width: 1.75rem;
height: 1.75rem;
border-radius: 0.25rem;
color: var(--muted-foreground);
transition: all 0.15s ease;
}
.dialog-header-actions button:hover {
background-color: var(--secondary-background);
color: var(--base-foreground);
}
.dialog-content {
padding: 1.25rem;
background-color: var(--interface-panel-surface);
}
.dialog-footer {
display: flex;
justify-content: flex-end;
padding: 1rem 1.25rem;
border-top: 1px solid var(--border-subtle);
background-color: var(--interface-panel-surface);
}
.dialog-form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.dialog-field {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.dialog-label {
font-size: 0.875rem;
font-weight: 500;
color: var(--base-foreground);
}
.dialog-input-root,
.dialog-textarea-root {
width: 100%;
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
background-color: var(--button-surface);
border: 1px solid var(--border-default);
border-radius: 0.375rem;
color: var(--base-foreground);
transition: all 0.15s ease;
}
.dialog-input-root::placeholder,
.dialog-textarea-root::placeholder {
color: var(--muted-foreground);
}
.dialog-input-root:focus,
.dialog-textarea-root:focus {
outline: none;
border-color: var(--primary-background);
box-shadow: 0 0 0 2px rgba(11, 140, 233, 0.2);
}
.dialog-textarea-root {
resize: vertical;
min-height: 4.5rem;
}
.dialog-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
.dialog-btn {
padding: 0.5rem 1rem;
font-size: 0.875rem;
font-weight: 500;
border-radius: 0.375rem;
transition: all 0.15s ease;
cursor: pointer;
}
.dialog-btn-secondary {
background-color: var(--button-surface);
border: 1px solid var(--border-default);
color: var(--base-foreground);
}
.dialog-btn-secondary:hover {
background-color: var(--button-hover-surface);
}
.dialog-btn-primary {
background-color: var(--primary-background);
border: 1px solid var(--primary-background);
color: var(--color-white);
}
.dialog-btn-primary:hover {
background-color: var(--primary-background-hover);
border-color: var(--primary-background-hover);
}
.dialog-btn-disabled {
background-color: var(--secondary-background);
border: 1px solid var(--border-subtle);
color: var(--muted-foreground);
cursor: not-allowed;
}

View File

@@ -26,4 +26,7 @@ declare module 'vue' {
WorkspaceLayout: typeof import('./components/v2/layout/WorkspaceLayout.vue')['default']
WorkspaceSidebar: typeof import('./components/v2/layout/WorkspaceSidebar.vue')['default']
}
export interface ComponentCustomProperties {
Tooltip: typeof import('primevue/tooltip')['default']
}
}

View File

@@ -1,15 +1,154 @@
<script setup lang="ts">
import { computed } from 'vue'
import Button from 'primevue/button'
import { useUiStore, BOTTOM_BAR_TABS, type SidebarTabId } from '@/stores/uiStore'
const uiStore = useUiStore()
const activeBottomTab = computed(() => uiStore.activeBottomTab)
const bottomPanelExpanded = computed(() => uiStore.bottomPanelExpanded)
function handleTabClick(tabId: Exclude<SidebarTabId, null>): void {
uiStore.toggleBottomTab(tabId)
}
// 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' },
]
const mockWorkflows = [
{ name: 'Basic txt2img', date: '2024-01-15' },
{ name: 'Img2Img Pipeline', date: '2024-01-14' },
{ name: 'ControlNet Setup', date: '2024-01-13' },
]
const mockAssets = [
{ name: 'reference_01.png', type: 'image' },
{ name: 'mask_template.png', type: 'image' },
{ name: 'init_image.jpg', type: 'image' },
]
</script>
<template>
<div class="absolute bottom-4 left-1/2 z-10 -translate-x-1/2">
<div class="flex items-center gap-2 rounded-full bg-zinc-900/90 px-4 py-2 backdrop-blur">
<button class="rounded-full bg-blue-600 px-4 py-1.5 text-sm font-medium text-white hover:bg-blue-500">
Queue Prompt
</button>
<button class="rounded-full bg-zinc-700 px-4 py-1.5 text-sm font-medium text-zinc-300 hover:bg-zinc-600">
Save
<div class="absolute bottom-4 left-1/2 z-10 -translate-x-1/2 flex flex-col items-center gap-2">
<!-- 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"
>
<!-- 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"
/>
</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>
</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>
</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"
>
<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>
</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" />
<span class="text-sm">Bookmarked items will appear here</span>
</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">
<!-- 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="[
activeBottomTab === 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-base']" />
</button>
</div>
</div>
</template>
<style scoped>
.bottom-panel {
animation: slideUp 0.2s ease-out;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>

View File

@@ -1,16 +1,528 @@
<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'
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('')
// 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 mockModels = [
{ name: 'SD 1.5', type: 'Checkpoint' },
{ name: 'SDXL Base', type: 'Checkpoint' },
{ name: 'Realistic Vision', type: 'Checkpoint' },
{ name: 'DreamShaper', type: 'LoRA' },
]
const mockWorkflows = [
{ name: 'Basic txt2img', date: '2024-01-15' },
{ name: 'Img2Img Pipeline', date: '2024-01-14' },
{ name: 'ControlNet Setup', date: '2024-01-13' },
]
const mockAssets = [
{ name: 'reference_01.png', type: 'image' },
{ name: 'mask_template.png', type: 'image' },
{ name: 'init_image.jpg', type: 'image' },
]
</script>
<template>
<aside class="w-56 border-r border-zinc-800 bg-zinc-900/50 p-4">
<h3 class="mb-3 text-xs font-semibold uppercase text-zinc-500">Nodes</h3>
<div class="space-y-1 text-xs text-zinc-400">
<div class="rounded px-2 py-1.5 hover:bg-zinc-800">Load Checkpoint</div>
<div class="rounded px-2 py-1.5 hover:bg-zinc-800">CLIP Text Encode</div>
<div class="rounded px-2 py-1.5 hover:bg-zinc-800">KSampler</div>
<div class="rounded px-2 py-1.5 hover:bg-zinc-800">Empty Latent Image</div>
<div class="rounded px-2 py-1.5 hover:bg-zinc-800">VAE Decode</div>
<div class="rounded px-2 py-1.5 hover:bg-zinc-800">Save Image</div>
</div>
</aside>
<div class="flex h-full">
<!-- ================================================================== -->
<!-- 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>
</template>
<!-- ================================================================== -->
<!-- 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 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>
</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 -->
<div v-else-if="activeSidebarTab === 'models'" class="space-y-1">
<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"
>
<div class="text-zinc-300">{{ model.name }}</div>
<div class="text-[10px] text-zinc-500">{{ model.type }}</div>
</div>
</div>
<!-- Workflows Tab -->
<div v-else-if="activeSidebarTab === 'workflows'" class="space-y-1">
<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"
>
<div class="text-zinc-300">{{ workflow.name }}</div>
<div class="text-[10px] text-zinc-500">{{ workflow.date }}</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>
<!-- 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>
</template>
</div>
</template>
<style scoped>
/* Hide scrollbar but keep functionality */
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
/* Custom scrollbar for the panel */
aside ::-webkit-scrollbar {
width: 4px;
}
aside ::-webkit-scrollbar-track {
background: transparent;
}
aside ::-webkit-scrollbar-thumb {
background: #3f3f46;
border-radius: 2px;
}
aside ::-webkit-scrollbar-thumb:hover {
background: #52525b;
}
/* Fade transition for node preview */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.15s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

View File

@@ -27,7 +27,7 @@ function handleLogoClick(): void {
}
function handleHomeClick(): void {
router.push('/')
router.push({ name: 'workspace-dashboard', params: { workspaceId: 'default' } })
}
function selectTab(tabId: string): void {

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed } from 'vue'
import { ref, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
interface MenuItem {
@@ -23,6 +23,28 @@ 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',
@@ -39,13 +61,6 @@ const userMenuGroups = computed<MenuGroup[]>(() => [
{ label: 'Assets', icon: 'pi pi-images', route: `/${props.workspaceId}/assets` },
{ label: 'Models', icon: 'pi pi-box', route: `/${props.workspaceId}/models` }
]
},
{
label: 'Account',
items: [
{ label: 'Settings', icon: 'pi pi-cog', route: `/${props.workspaceId}/settings` },
{ label: 'API Keys', icon: 'pi pi-key', route: `/${props.workspaceId}/api-keys` }
]
}
])
@@ -72,13 +87,6 @@ const teamMenuGroups = computed<MenuGroup[]>(() => [
{ label: 'Members', icon: 'pi pi-users', route: `/${props.workspaceId}/members`, badge: 8 },
{ label: 'Activity', icon: 'pi pi-history', route: `/${props.workspaceId}/activity` }
]
},
{
label: 'Settings',
items: [
{ label: 'General', icon: 'pi pi-cog', route: `/${props.workspaceId}/settings` },
{ label: 'Billing', icon: 'pi pi-credit-card', route: `/${props.workspaceId}/billing` }
]
}
])
@@ -101,24 +109,132 @@ 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 -->
<div class="flex h-14 items-center gap-3 border-b border-zinc-200 px-4 dark:border-zinc-800">
<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'
]"
<!-- 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"
>
{{ 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
: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 -->
<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"
>
<!-- 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>
</div>
<!-- Backdrop to close menu -->
<div
v-if="showWorkspaceMenu"
class="fixed inset-0 z-40"
@click="closeWorkspaceMenu"
/>
</div>
<!-- Menu Groups -->
@@ -161,13 +277,25 @@ function signOut(): void {
</nav>
<!-- Footer -->
<div class="border-t border-zinc-200 p-3 dark:border-zinc-800">
<div class="flex items-center justify-end gap-1 border-t border-zinc-200 px-3 py-2 dark:border-zinc-800">
<RouterLink
:to="`/${workspaceId}/settings`"
:class="[
'flex h-8 w-8 items-center justify-center rounded-md transition-colors',
isActive(`/${workspaceId}/settings`)
? '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
class="flex w-full items-center gap-3 rounded-md px-2 py-1.5 text-sm text-zinc-600 transition-colors hover:bg-zinc-100 hover:text-zinc-900 dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-100"
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" />
<span>Sign out</span>
</button>
</div>
</aside>

View File

@@ -3,15 +3,261 @@ import { ref, computed } from 'vue'
export type InterfaceVersion = 'v1' | 'v2'
export type SidebarTabId = 'nodes' | 'models' | 'workflows' | 'assets' | 'library' | null
export interface SidebarTab {
id: Exclude<SidebarTabId, null>
label: string
icon: string
tooltip: string
}
// ============================================================================
// NODE CATEGORY SYSTEM (TouchDesigner/Houdini-style 2-level)
// ============================================================================
export type NodeCategoryId =
| 'loaders'
| 'conditioning'
| 'sampling'
| 'latent'
| 'image'
| 'mask'
| 'audio'
| 'video'
| '3d'
| 'advanced'
| 'api'
| null
export interface NodeSubcategory {
id: string
label: string
nodes: string[] // Node names
}
export interface NodeCategory {
id: Exclude<NodeCategoryId, null>
label: string
shortLabel: string // 3-4 char for icon bar
icon: string
color: string
subcategories: NodeSubcategory[]
}
// Main node categories with subcategories and color coding
export const NODE_CATEGORIES: NodeCategory[] = [
{
id: 'loaders',
label: 'Loaders',
shortLabel: 'LOAD',
icon: 'pi pi-download',
color: '#B39DDB', // Purple
subcategories: [
{ id: 'checkpoints', label: 'Checkpoints', nodes: ['CheckpointLoader', 'CheckpointLoaderSimple', 'unCLIPCheckpointLoader'] },
{ id: 'lora', label: 'LoRA', nodes: ['LoraLoader', 'LoraLoaderModelOnly'] },
{ id: 'vae', label: 'VAE', nodes: ['VAELoader'] },
{ id: 'clip', label: 'CLIP', nodes: ['CLIPLoader', 'DualCLIPLoader', 'CLIPVisionLoader', 'TripleCLIPLoader'] },
{ id: 'controlnet', label: 'ControlNet', nodes: ['ControlNetLoader', 'DiffControlNetLoader'] },
{ id: 'unet', label: 'UNET', nodes: ['UNETLoader'] },
{ id: 'images', label: 'Images', nodes: ['LoadImage', 'LoadImageMask', 'LoadImageOutput'] },
{ id: 'other', label: 'Other', nodes: ['GLIGENLoader', 'StyleModelLoader', 'DiffusersLoader'] },
],
},
{
id: 'conditioning',
label: 'Conditioning',
shortLabel: 'COND',
icon: 'pi pi-comment',
color: '#FFAB40', // Orange
subcategories: [
{ id: 'text-encode', label: 'Text Encoding', nodes: ['CLIPTextEncode', 'CLIPTextEncodeSDXL', 'CLIPTextEncodeSD3'] },
{ id: 'clip', label: 'CLIP Operations', nodes: ['CLIPSetLastLayer', 'CLIPVisionEncode'] },
{ id: 'controlnet', label: 'ControlNet', nodes: ['ControlNetApply', 'ControlNetApplyAdvanced'] },
{ id: 'area-mask', label: 'Area & Mask', nodes: ['ConditioningSetArea', 'ConditioningSetAreaPercentage', 'ConditioningSetAreaStrength', 'ConditioningSetMask'] },
{ id: 'combine', label: 'Combine', nodes: ['ConditioningCombine', 'ConditioningConcat', 'ConditioningAverage'] },
{ id: 'style', label: 'Style & GLIGEN', nodes: ['StyleModelApply', 'GLIGENTextBoxApply', 'unCLIPConditioning'] },
{ id: 'other', label: 'Other', nodes: ['ConditioningSetTimestepRange', 'ConditioningZeroOut', 'InpaintModelConditioning'] },
],
},
{
id: 'sampling',
label: 'Sampling',
shortLabel: 'SMPL',
icon: 'pi pi-play',
color: '#64B5F6', // Blue
subcategories: [
{ id: 'basic', label: 'Basic', nodes: ['KSampler', 'KSamplerAdvanced'] },
{ id: 'custom-samplers', label: 'Custom Samplers', nodes: ['SamplerCustom', 'SamplerCustomAdvanced'] },
{ id: 'schedulers', label: 'Schedulers', nodes: ['BasicScheduler', 'KarrasScheduler', 'ExponentialScheduler', 'PolyexponentialScheduler', 'AlignYourStepsScheduler'] },
{ id: 'guiders', label: 'Guiders', nodes: ['BasicGuider', 'CFGGuider', 'DualCFGGuider'] },
{ id: 'noise', label: 'Noise', nodes: ['RandomNoise', 'DisableNoise'] },
{ id: 'sigmas', label: 'Sigmas', nodes: ['SplitSigmas', 'FlipSigmas', 'SplitSigmasDenoise'] },
],
},
{
id: 'latent',
label: 'Latent',
shortLabel: 'LAT',
icon: 'pi pi-th-large',
color: '#FF80AB', // Pink
subcategories: [
{ id: 'create', label: 'Create', nodes: ['EmptyLatentImage', 'EmptySD3LatentImage'] },
{ id: 'encode-decode', label: 'Encode / Decode', nodes: ['VAEEncode', 'VAEDecode', 'VAEEncodeTiled', 'VAEDecodeTiled', 'VAEEncodeForInpaint'] },
{ id: 'transform', label: 'Transform', nodes: ['LatentUpscale', 'LatentUpscaleBy', 'LatentCrop', 'LatentRotate', 'LatentFlip'] },
{ id: 'composite', label: 'Composite', nodes: ['LatentComposite', 'LatentBlend', 'SetLatentNoiseMask'] },
{ id: 'batch', label: 'Batch', nodes: ['LatentFromBatch', 'RepeatLatentBatch'] },
{ id: 'io', label: 'Save / Load', nodes: ['SaveLatent', 'LoadLatent'] },
],
},
{
id: 'image',
label: 'Image',
shortLabel: 'IMG',
icon: 'pi pi-image',
color: '#4DD0E1', // Cyan
subcategories: [
{ id: 'io', label: 'Load & Save', nodes: ['LoadImage', 'SaveImage', 'PreviewImage'] },
{ id: 'transform', label: 'Transform', nodes: ['ImageScale', 'ImageScaleBy', 'ImageCrop', 'ImageRotate', 'ImageFlip'] },
{ id: 'batch', label: 'Batch', nodes: ['ImageBatch', 'ImageFromBatch', 'RepeatImageBatch'] },
{ id: 'composite', label: 'Composite', nodes: ['ImageComposite', 'ImageBlend', 'ImagePadForOutpaint'] },
{ id: 'adjust', label: 'Adjustments', nodes: ['ImageInvert', 'ImageSharpen', 'ImageBlur'] },
{ id: 'upscale', label: 'Upscaling', nodes: ['ImageUpscaleWithModel', 'UpscaleModelLoader'] },
],
},
{
id: 'mask',
label: 'Mask',
shortLabel: 'MASK',
icon: 'pi pi-circle',
color: '#FFD54F', // Yellow
subcategories: [
{ id: 'create', label: 'Create', nodes: ['SolidMask', 'EmptyMask', 'ImageToMask', 'MaskFromColor'] },
{ id: 'composite', label: 'Composite', nodes: ['MaskComposite', 'CombineMasks'] },
{ id: 'transform', label: 'Transform', nodes: ['CropMask', 'FeatherMask', 'GrowMask', 'ThresholdMask'] },
{ id: 'convert', label: 'Convert', nodes: ['MaskToImage', 'ImageToMask', 'InvertMask'] },
],
},
{
id: 'audio',
label: 'Audio',
shortLabel: 'AUD',
icon: 'pi pi-volume-up',
color: '#81C784', // Green
subcategories: [
{ id: 'io', label: 'Load & Save', nodes: ['LoadAudio', 'SaveAudio', 'SaveAudioMP3', 'SaveAudioOpus', 'PreviewAudio', 'RecordAudio'] },
{ id: 'encode-decode', label: 'Encode / Decode', nodes: ['VAEEncodeAudio', 'VAEDecodeAudio'] },
{ id: 'process', label: 'Processing', nodes: ['AudioAdjustVolume', 'AudioConcat', 'AudioMerge', 'TrimAudioDuration', 'SplitAudioChannels'] },
{ id: 'latent', label: 'Latent', nodes: ['EmptyLatentAudio', 'EmptyAudio'] },
],
},
{
id: 'video',
label: 'Video',
shortLabel: 'VID',
icon: 'pi pi-video',
color: '#26A69A', // Teal
subcategories: [
{ id: 'generation', label: 'Generation', nodes: ['SVD_img2vid_Conditioning', 'VideoLinearCFGGuidance'] },
{ id: 'wan', label: 'Wan', nodes: ['WanImageToVideo', 'WanFunInpaintToVideo', 'WanCameraEmbedding'] },
{ id: 'hunyuan', label: 'Hunyuan', nodes: ['HunyuanImageToVideo'] },
{ id: 'ltxv', label: 'LTXV', nodes: ['LTXVImgToVideo', 'LTXVConditioning'] },
{ id: 'mochi', label: 'Mochi', nodes: ['MochiImageEncode'] },
{ id: 'cosmos', label: 'Cosmos', nodes: ['CosmosImageToVideoConditioning'] },
],
},
{
id: '3d',
label: '3D',
shortLabel: '3D',
icon: 'pi pi-box',
color: '#EF5350', // Red
subcategories: [
{ id: 'hunyuan3d', label: 'Hunyuan3D', nodes: ['Hunyuan3Dv2Conditioning', 'Hunyuan3Dv2ConditioningMultiView'] },
{ id: 'mesh', label: 'Mesh', nodes: ['Load3D', 'Load3DAnimation', 'Preview3D'] },
{ id: 'point-cloud', label: 'Point Cloud', nodes: ['StableZero123_Conditioning'] },
],
},
{
id: 'advanced',
label: 'Advanced',
shortLabel: 'ADV',
icon: 'pi pi-cog',
color: '#78909C', // Gray
subcategories: [
{ id: 'model-merging', label: 'Model Merging', nodes: ['ModelMergeSimple', 'ModelMergeBlocks', 'ModelMergeSD1', 'ModelMergeSDXL'] },
{ id: 'model-patches', label: 'Model Patches', nodes: ['PatchModelAddDownscale', 'FreeU', 'FreeU_V2'] },
{ id: 'hooks', label: 'Hooks', nodes: ['CreateHookLora', 'CreateHookModelAsLora', 'SetClipHooks'] },
{ id: 'debug', label: 'Debug', nodes: ['DebugLog', 'DebugPrint'] },
{ id: 'experimental', label: 'Experimental', nodes: ['SamplerEulerCFGpp'] },
],
},
{
id: 'api',
label: 'API',
shortLabel: 'API',
icon: 'pi pi-cloud',
color: '#7E57C2', // Dark Purple
subcategories: [
{ id: 'image-gen', label: 'Image Generation', nodes: ['OpenAI DALL-E', 'Stability AI', 'Recraft', 'Ideogram', 'BFL Flux'] },
{ id: 'video-gen', label: 'Video Generation', nodes: ['Kling', 'Runway', 'Pika', 'Luma', 'MiniMax'] },
{ id: '3d-gen', label: '3D Generation', nodes: ['Rodin', 'Tripo'] },
{ id: 'text', label: 'Text / LLM', nodes: ['OpenAI GPT', 'Gemini', 'Anthropic'] },
],
},
]
// Legacy sidebar tabs (for workspace navigation, not nodes)
export const SIDEBAR_TABS: SidebarTab[] = [
{ id: 'nodes', label: 'Nodes', icon: 'pi pi-sitemap', tooltip: 'Node Library' },
{ 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: 'library', label: 'Library', icon: 'pi pi-bookmark', tooltip: 'Library' },
]
// V2 bottom bar tabs
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: '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 leftSidebarOpen = ref(true)
const rightSidebarOpen = ref(false)
// Sidebar tab state (left sidebar)
const activeSidebarTab = ref<SidebarTabId>(null)
// Bottom bar tab state (v2 only)
const activeBottomTab = ref<SidebarTabId>(null)
// Node category state (TouchDesigner/Houdini-style)
const activeNodeCategory = ref<NodeCategoryId>(null)
const expandedSubcategories = ref<Set<string>>(new Set())
const nodeSearchQuery = ref('')
// Computed for backwards compatibility
const interface2Enabled = computed(() => interfaceVersion.value === 'v2')
// Sidebar panel is expanded when a tab is active
const sidebarPanelExpanded = computed(() => activeSidebarTab.value !== null)
// Bottom panel is expanded when a tab is active
const bottomPanelExpanded = computed(() => activeBottomTab.value !== null)
// Node panel is expanded when a category is active
const nodePanelExpanded = computed(() => activeNodeCategory.value !== null)
// Get active node category data
const activeNodeCategoryData = computed(() =>
NODE_CATEGORIES.find(cat => cat.id === activeNodeCategory.value) ?? null
)
function setInterfaceVersion(version: InterfaceVersion): void {
interfaceVersion.value = version
}
@@ -28,14 +274,85 @@ export const useUiStore = defineStore('ui', () => {
rightSidebarOpen.value = !rightSidebarOpen.value
}
function toggleSidebarTab(tabId: Exclude<SidebarTabId, null>): void {
activeSidebarTab.value = activeSidebarTab.value === tabId ? null : tabId
}
function setSidebarTab(tabId: SidebarTabId): void {
activeSidebarTab.value = tabId
}
function closeSidebarPanel(): void {
activeSidebarTab.value = null
}
function toggleBottomTab(tabId: Exclude<SidebarTabId, null>): void {
activeBottomTab.value = activeBottomTab.value === tabId ? null : tabId
}
function setBottomTab(tabId: SidebarTabId): void {
activeBottomTab.value = tabId
}
function closeBottomPanel(): void {
activeBottomTab.value = null
}
// Node category functions
function toggleNodeCategory(categoryId: Exclude<NodeCategoryId, null>): void {
activeNodeCategory.value = activeNodeCategory.value === categoryId ? null : categoryId
}
function setNodeCategory(categoryId: NodeCategoryId): void {
activeNodeCategory.value = categoryId
}
function closeNodePanel(): void {
activeNodeCategory.value = null
}
function toggleSubcategory(subcategoryId: string): void {
if (expandedSubcategories.value.has(subcategoryId)) {
expandedSubcategories.value.delete(subcategoryId)
} else {
expandedSubcategories.value.add(subcategoryId)
}
}
function setNodeSearchQuery(query: string): void {
nodeSearchQuery.value = query
}
return {
interfaceVersion,
interface2Enabled,
leftSidebarOpen,
rightSidebarOpen,
activeSidebarTab,
sidebarPanelExpanded,
activeBottomTab,
bottomPanelExpanded,
// Node category exports
activeNodeCategory,
activeNodeCategoryData,
nodePanelExpanded,
expandedSubcategories,
nodeSearchQuery,
// Functions
setInterfaceVersion,
toggleInterfaceVersion,
toggleLeftSidebar,
toggleRightSidebar,
toggleSidebarTab,
setSidebarTab,
closeSidebarPanel,
toggleBottomTab,
setBottomTab,
closeBottomPanel,
toggleNodeCategory,
setNodeCategory,
closeNodePanel,
toggleSubcategory,
setNodeSearchQuery,
}
})

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, markRaw } from 'vue'
import { ref, computed, onMounted, markRaw } from 'vue'
import { VueFlow, useVueFlow } from '@vue-flow/core'
import { Background } from '@vue-flow/background'
import '@vue-flow/core/dist/style.css'
@@ -53,28 +53,9 @@ function createNodeData(
}
}
// Keyboard shortcut: X to toggle interface version
function handleKeydown(event: KeyboardEvent): void {
if (
event.target instanceof HTMLInputElement ||
event.target instanceof HTMLTextAreaElement
) {
return
}
if (event.key.toLowerCase() === 'x') {
uiStore.toggleInterfaceVersion()
}
}
onMounted(() => {
workspaceStore.setCurrentIds(props.workspaceId, props.projectId, props.canvasId)
workspaceStore.openCanvas(props.canvasId, props.canvasId, props.projectId)
window.addEventListener('keydown', handleKeydown)
})
onUnmounted(() => {
window.removeEventListener('keydown', handleKeydown)
})
// Vue Flow

View File

@@ -13,29 +13,57 @@ const viewMode = ref<ViewMode>('list')
type AssetType = 'all' | 'image' | 'video' | 'audio'
const filterType = ref<AssetType>('all')
// Sort
type SortOption = 'name' | 'updated' | 'size' | 'type'
const sortBy = ref<SortOption>('updated')
const sortOptions: { value: SortOption; label: string }[] = [
{ value: 'updated', label: 'Last updated' },
{ value: 'name', label: 'Name' },
{ value: 'size', label: 'Size' },
{ value: 'type', label: 'Type' }
]
// Mock assets data
const assets = ref([
{ id: 'asset-1', name: 'input-image.png', type: 'image', size: '2.4 MB', dimensions: '1024x1024', updatedAt: '2 hours ago' },
{ id: 'asset-2', name: 'reference.jpg', type: 'image', size: '1.8 MB', dimensions: '768x768', updatedAt: '1 day ago' },
{ id: 'asset-3', name: 'mask.png', type: 'image', size: '0.5 MB', dimensions: '512x512', updatedAt: '2 days ago' },
{ id: 'asset-4', name: 'output-video.mp4', type: 'video', size: '24.5 MB', dimensions: '1920x1080', updatedAt: '3 days ago' },
{ id: 'asset-5', name: 'background.wav', type: 'audio', size: '8.2 MB', dimensions: '3:24', updatedAt: '1 week ago' }
{ id: 'asset-1', name: 'input-image.png', type: 'image', size: '2.4 MB', sizeBytes: 2516582, dimensions: '1024x1024', updatedAt: '2 hours ago', updatedTimestamp: Date.now() - 2 * 60 * 60 * 1000 },
{ id: 'asset-2', name: 'reference.jpg', type: 'image', size: '1.8 MB', sizeBytes: 1887437, dimensions: '768x768', updatedAt: '1 day ago', updatedTimestamp: Date.now() - 24 * 60 * 60 * 1000 },
{ id: 'asset-3', name: 'mask.png', type: 'image', size: '0.5 MB', sizeBytes: 524288, dimensions: '512x512', updatedAt: '2 days ago', updatedTimestamp: Date.now() - 2 * 24 * 60 * 60 * 1000 },
{ id: 'asset-4', name: 'output-video.mp4', type: 'video', size: '24.5 MB', sizeBytes: 25690112, dimensions: '1920x1080', updatedAt: '3 days ago', updatedTimestamp: Date.now() - 3 * 24 * 60 * 60 * 1000 },
{ id: 'asset-5', name: 'background.wav', type: 'audio', size: '8.2 MB', sizeBytes: 8598323, dimensions: '3:24', updatedAt: '1 week ago', updatedTimestamp: Date.now() - 7 * 24 * 60 * 60 * 1000 }
])
// Search
// Search, filter and sort
const searchQuery = ref('')
const filteredAssets = computed(() => {
let result = assets.value
// Filter by type
if (filterType.value !== 'all') {
result = result.filter((a) => a.type === filterType.value)
}
// Filter by search
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase()
result = result.filter((a) => a.name.toLowerCase().includes(query))
}
// Sort
result = [...result].sort((a, b) => {
switch (sortBy.value) {
case 'name':
return a.name.localeCompare(b.name)
case 'size':
return b.sizeBytes - a.sizeBytes
case 'type':
return a.type.localeCompare(b.type)
case 'updated':
default:
return b.updatedTimestamp - a.updatedTimestamp
}
})
return result
})
@@ -69,8 +97,8 @@ function getAssetIcon(type: string): string {
</button>
</div>
<!-- Search, Filter & View Toggle -->
<div class="mb-6 flex items-center gap-4">
<!-- 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
@@ -98,6 +126,19 @@ function getAssetIcon(type: string): string {
</button>
</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
@@ -142,18 +183,31 @@ function getAssetIcon(type: string): string {
<!-- Grid View -->
<div
v-else-if="viewMode === 'grid'"
class="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6"
class="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5"
>
<div
v-for="asset in filteredAssets"
:key="asset.id"
class="group rounded-lg border border-zinc-200 bg-white p-3 transition-all hover:border-zinc-300 hover:shadow-sm dark:border-zinc-800 dark:bg-zinc-900 dark:hover:border-zinc-700"
class="group aspect-square cursor-pointer rounded-lg border border-zinc-200 bg-white p-4 text-left transition-all hover:border-zinc-300 hover:shadow-sm dark:border-zinc-800 dark:bg-zinc-900 dark:hover:border-zinc-700"
>
<div class="mb-2 flex aspect-square items-center justify-center rounded-md bg-zinc-100 dark:bg-zinc-800">
<i :class="[getAssetIcon(asset.type), 'text-2xl text-zinc-400']" />
<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="[getAssetIcon(asset.type), '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"
@click.stop
>
<i class="pi pi-ellipsis-h text-sm" />
</button>
</div>
<div class="mt-auto">
<h3 class="truncate font-medium text-zinc-900 dark:text-zinc-100">{{ asset.name }}</h3>
<p class="mt-1 text-sm text-zinc-500 dark:text-zinc-400">{{ asset.dimensions }}</p>
<p class="mt-1 text-xs text-zinc-400 dark:text-zinc-500">{{ asset.size }}</p>
</div>
</div>
<p class="truncate text-sm font-medium text-zinc-900 dark:text-zinc-100">{{ asset.name }}</p>
<p class="text-xs text-zinc-500 dark:text-zinc-400">{{ asset.size }}</p>
</div>
</div>

View File

@@ -11,26 +11,72 @@ const workspaceId = computed(() => route.params.workspaceId as string)
type ViewMode = 'grid' | 'list'
const viewMode = ref<ViewMode>('grid')
// Sort
type SortOption = 'name' | 'updated' | 'project'
const sortBy = ref<SortOption>('updated')
const sortOptions: { value: SortOption; label: string }[] = [
{ value: 'updated', label: 'Last updated' },
{ value: 'name', label: 'Name' },
{ value: 'project', label: 'Project' }
]
// Project filter
const filterProject = ref<string>('all')
// Mock canvases data (all canvases across projects)
const canvases = ref([
{ id: 'main-workflow', name: 'Main Workflow', projectId: 'img-gen', projectName: 'Image Generation', updatedAt: '2 hours ago' },
{ id: 'test-canvas', name: 'Test Canvas', projectId: 'img-gen', projectName: 'Image Generation', updatedAt: '1 day ago' },
{ id: 'upscale-4x', name: 'Upscale 4x', projectId: 'upscale', projectName: 'Upscaling', updatedAt: '2 days ago' },
{ id: 'video-enhance', name: 'Video Enhance', projectId: 'video-proc', projectName: 'Video Processing', updatedAt: '3 days ago' },
{ id: 'audio-clean', name: 'Audio Clean', projectId: 'audio-enh', projectName: 'Audio Enhancement', updatedAt: '5 days ago' },
{ id: 'backup', name: 'Backup', projectId: 'img-gen', projectName: 'Image Generation', updatedAt: '1 week ago' }
{ 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 },
{ id: 'upscale-4x', name: 'Upscale 4x', projectId: 'upscale', projectName: 'Upscaling', updatedAt: '2 days ago', updatedTimestamp: Date.now() - 2 * 24 * 60 * 60 * 1000 },
{ id: 'video-enhance', name: 'Video Enhance', projectId: 'video-proc', projectName: 'Video Processing', updatedAt: '3 days ago', updatedTimestamp: Date.now() - 3 * 24 * 60 * 60 * 1000 },
{ id: 'audio-clean', name: 'Audio Clean', projectId: 'audio-enh', projectName: 'Audio Enhancement', updatedAt: '5 days ago', updatedTimestamp: Date.now() - 5 * 24 * 60 * 60 * 1000 },
{ id: 'backup', name: 'Backup', projectId: 'img-gen', projectName: 'Image Generation', updatedAt: '1 week ago', updatedTimestamp: Date.now() - 7 * 24 * 60 * 60 * 1000 }
])
// Search
// Get unique projects for filter
const projectOptions = computed(() => {
const projects = new Map<string, string>()
canvases.value.forEach((c) => {
projects.set(c.projectId, c.projectName)
})
return Array.from(projects.entries()).map(([id, name]) => ({ id, name }))
})
// Search, filter and sort
const searchQuery = ref('')
const filteredCanvases = computed(() => {
if (!searchQuery.value) return canvases.value
const query = searchQuery.value.toLowerCase()
return canvases.value.filter(
(c) =>
c.name.toLowerCase().includes(query) ||
c.projectName.toLowerCase().includes(query)
)
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(
(c) =>
c.name.toLowerCase().includes(query) ||
c.projectName.toLowerCase().includes(query)
)
}
// Sort
result = [...result].sort((a, b) => {
switch (sortBy.value) {
case 'name':
return a.name.localeCompare(b.name)
case 'project':
return a.projectName.localeCompare(b.projectName)
case 'updated':
default:
return b.updatedTimestamp - a.updatedTimestamp
}
})
return result
})
function openCanvas(canvas: { id: string; projectId: string }): void {
@@ -63,8 +109,8 @@ function createCanvas(): void {
</button>
</div>
<!-- Search & View Toggle -->
<div class="mb-6 flex items-center gap-4">
<!-- 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
@@ -74,6 +120,35 @@ function createCanvas(): void {
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="[
@@ -125,26 +200,38 @@ function createCanvas(): void {
<!-- Grid View -->
<div
v-else-if="viewMode === 'grid'"
class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
class="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5"
>
<button
<div
v-for="canvas in filteredCanvases"
:key="canvas.id"
class="group rounded-lg border border-zinc-200 bg-white p-4 text-left transition-all hover:border-zinc-300 hover:shadow-sm dark:border-zinc-800 dark:bg-zinc-900 dark:hover:border-zinc-700"
class="group aspect-square cursor-pointer rounded-lg border border-zinc-200 bg-white p-4 text-left transition-all hover:border-zinc-300 hover:shadow-sm dark:border-zinc-800 dark:bg-zinc-900 dark:hover:border-zinc-700"
@click="openCanvas(canvas)"
>
<div class="mb-3 flex h-24 items-center justify-center rounded-md bg-zinc-100 dark:bg-zinc-800">
<i class="pi pi-objects-column text-2xl text-zinc-400" />
<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" />
</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"
@click.stop
>
<i class="pi pi-ellipsis-h text-sm" />
</button>
</div>
<div class="mt-auto">
<h3 class="font-medium text-zinc-900 dark:text-zinc-100">{{ canvas.name }}</h3>
<p class="mt-1 text-sm text-zinc-500 dark:text-zinc-400">
<span class="inline-flex items-center gap-1">
<i class="pi pi-folder text-xs" />
{{ canvas.projectName }}
</span>
</p>
<p class="mt-1 text-xs text-zinc-400 dark:text-zinc-500">{{ canvas.updatedAt }}</p>
</div>
</div>
<p class="font-medium text-zinc-900 dark:text-zinc-100">{{ canvas.name }}</p>
<p class="mt-1 text-xs text-zinc-500 dark:text-zinc-400">
<span class="inline-flex items-center gap-1">
<i class="pi pi-folder text-[10px]" />
{{ canvas.projectName }}
</span>
</p>
<p class="mt-1 text-xs text-zinc-400 dark:text-zinc-500">Updated {{ canvas.updatedAt }}</p>
</button>
</div>
</div>
<!-- List View -->

View File

@@ -13,30 +13,58 @@ const viewMode = ref<ViewMode>('grid')
type ModelType = 'all' | 'checkpoint' | 'lora' | 'vae' | 'controlnet'
const filterType = ref<ModelType>('all')
// Sort
type SortOption = 'name' | 'updated' | 'size' | 'type'
const sortBy = ref<SortOption>('updated')
const sortOptions: { value: SortOption; label: string }[] = [
{ value: 'updated', label: 'Last updated' },
{ value: 'name', label: 'Name' },
{ value: 'size', label: 'Size' },
{ value: 'type', label: 'Type' }
]
// Mock models data
const models = ref([
{ id: 'model-1', name: 'SDXL Base 1.0', type: 'checkpoint', size: '6.94 GB', version: '1.0', updatedAt: '2 weeks ago' },
{ id: 'model-2', name: 'SDXL Refiner 1.0', type: 'checkpoint', size: '6.08 GB', version: '1.0', updatedAt: '2 weeks ago' },
{ id: 'model-3', name: 'SDXL Lightning', type: 'lora', size: '393 MB', version: '4-step', updatedAt: '1 week ago' },
{ id: 'model-4', name: 'Detail Tweaker', type: 'lora', size: '144 MB', version: '1.0', updatedAt: '3 days ago' },
{ id: 'model-5', name: 'SDXL VAE', type: 'vae', size: '335 MB', version: 'fp16', updatedAt: '1 month ago' },
{ id: 'model-6', name: 'ControlNet Canny', type: 'controlnet', size: '2.5 GB', version: '1.1', updatedAt: '2 weeks ago' }
{ id: 'model-1', name: 'SDXL Base 1.0', type: 'checkpoint', size: '6.94 GB', sizeBytes: 7452139315, version: '1.0', updatedAt: '2 weeks ago', updatedTimestamp: Date.now() - 14 * 24 * 60 * 60 * 1000 },
{ id: 'model-2', name: 'SDXL Refiner 1.0', type: 'checkpoint', size: '6.08 GB', sizeBytes: 6529336320, version: '1.0', updatedAt: '2 weeks ago', updatedTimestamp: Date.now() - 14 * 24 * 60 * 60 * 1000 },
{ id: 'model-3', name: 'SDXL Lightning', type: 'lora', size: '393 MB', sizeBytes: 412090368, version: '4-step', updatedAt: '1 week ago', updatedTimestamp: Date.now() - 7 * 24 * 60 * 60 * 1000 },
{ id: 'model-4', name: 'Detail Tweaker', type: 'lora', size: '144 MB', sizeBytes: 150994944, version: '1.0', updatedAt: '3 days ago', updatedTimestamp: Date.now() - 3 * 24 * 60 * 60 * 1000 },
{ id: 'model-5', name: 'SDXL VAE', type: 'vae', size: '335 MB', sizeBytes: 351272960, version: 'fp16', updatedAt: '1 month ago', updatedTimestamp: Date.now() - 30 * 24 * 60 * 60 * 1000 },
{ id: 'model-6', name: 'ControlNet Canny', type: 'controlnet', size: '2.5 GB', sizeBytes: 2684354560, version: '1.1', updatedAt: '2 weeks ago', updatedTimestamp: Date.now() - 14 * 24 * 60 * 60 * 1000 }
])
// Search
// Search, filter and sort
const searchQuery = ref('')
const filteredModels = computed(() => {
let result = models.value
// Filter by type
if (filterType.value !== 'all') {
result = result.filter((m) => m.type === filterType.value)
}
// Filter by search
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase()
result = result.filter((m) => m.name.toLowerCase().includes(query))
}
// Sort
result = [...result].sort((a, b) => {
switch (sortBy.value) {
case 'name':
return a.name.localeCompare(b.name)
case 'size':
return b.sizeBytes - a.sizeBytes
case 'type':
return a.type.localeCompare(b.type)
case 'updated':
default:
return b.updatedTimestamp - a.updatedTimestamp
}
})
return result
})
@@ -81,8 +109,8 @@ function getModelColor(type: string): string {
</button>
</div>
<!-- Search, Filter & View Toggle -->
<div class="mb-6 flex items-center gap-4">
<!-- 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
@@ -110,6 +138,19 @@ function getModelColor(type: string): string {
</button>
</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
@@ -154,28 +195,37 @@ function getModelColor(type: string): string {
<!-- Grid View -->
<div
v-else-if="viewMode === 'grid'"
class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"
class="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5"
>
<div
v-for="model in filteredModels"
:key="model.id"
class="group rounded-lg border border-zinc-200 bg-white p-5 transition-all hover:border-zinc-300 hover:shadow-sm dark:border-zinc-800 dark:bg-zinc-900 dark:hover:border-zinc-700"
class="group aspect-square cursor-pointer rounded-lg border border-zinc-200 bg-white p-4 text-left transition-all hover:border-zinc-300 hover:shadow-sm dark:border-zinc-800 dark:bg-zinc-900 dark:hover:border-zinc-700"
>
<div class="flex items-start justify-between">
<div :class="['flex h-10 w-10 items-center justify-center rounded-md', getModelColor(model.type)]">
<i :class="getModelIcon(model.type)" />
<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', getModelColor(model.type)]">
<i :class="getModelIcon(model.type)" />
</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"
@click.stop
>
<i class="pi pi-ellipsis-h text-sm" />
</button>
</div>
<div class="mt-auto">
<h3 class="font-medium text-zinc-900 dark:text-zinc-100">{{ model.name }}</h3>
<p class="mt-1 text-sm text-zinc-500 dark:text-zinc-400">
v{{ model.version }}
</p>
<div class="mt-2 flex items-center justify-between text-xs text-zinc-400 dark:text-zinc-500">
<span :class="['rounded-full px-2 py-0.5 text-xs font-medium capitalize', getModelColor(model.type)]">
{{ model.type }}
</span>
<span>{{ model.size }}</span>
</div>
</div>
<span :class="['rounded-full px-2 py-0.5 text-xs font-medium capitalize', getModelColor(model.type)]">
{{ model.type }}
</span>
</div>
<h3 class="mt-4 font-medium text-zinc-900 dark:text-zinc-100">{{ model.name }}</h3>
<p class="mt-1 text-sm text-zinc-500 dark:text-zinc-400">
Version {{ model.version }}
</p>
<div class="mt-4 flex items-center justify-between text-xs text-zinc-400 dark:text-zinc-500">
<span>{{ model.size }}</span>
<span>{{ model.updatedAt }}</span>
</div>
</div>
</div>

View File

@@ -8,6 +8,10 @@ const router = useRouter()
const workspaceId = computed(() => route.params.workspaceId as string)
const projectId = computed(() => route.params.projectId as string)
// View mode
type ViewMode = 'grid' | 'list'
const viewMode = ref<ViewMode>('grid')
// Mock project data
const project = computed(() => ({
id: projectId.value,
@@ -24,9 +28,9 @@ const canvases = ref([
// Mock assets
const assets = ref([
{ id: 'asset-1', name: 'input-image.png', type: 'image', size: '2.4 MB' },
{ id: 'asset-2', name: 'reference.jpg', type: 'image', size: '1.8 MB' },
{ id: 'asset-3', name: 'mask.png', type: 'image', size: '0.5 MB' }
{ id: 'asset-1', name: 'input-image.png', type: 'image', size: '2.4 MB', dimensions: '1024x1024' },
{ id: 'asset-2', name: 'reference.jpg', type: 'image', size: '1.8 MB', dimensions: '768x768' },
{ id: 'asset-3', name: 'mask.png', type: 'image', size: '0.5 MB', dimensions: '512x512' }
])
// Tabs
@@ -40,6 +44,15 @@ function openCanvas(canvasId: string): void {
function createCanvas(): void {
router.push(`/${workspaceId.value}/${projectId.value}/untitled`)
}
function getAssetIcon(type: string): string {
switch (type) {
case 'image': return 'pi pi-image'
case 'video': return 'pi pi-video'
case 'audio': return 'pi pi-volume-up'
default: return 'pi pi-file'
}
}
</script>
<template>
@@ -78,41 +91,68 @@ function createCanvas(): void {
</div>
</div>
<!-- Tabs -->
<div class="mb-6 flex gap-1 border-b border-zinc-200 dark:border-zinc-800">
<button
:class="[
'px-4 py-2 text-sm font-medium transition-colors',
activeTab === 'canvases'
? 'border-b-2 border-zinc-900 text-zinc-900 dark:border-zinc-100 dark:text-zinc-100'
: 'text-zinc-500 hover:text-zinc-700 dark:text-zinc-400 dark:hover:text-zinc-300'
]"
@click="activeTab = 'canvases'"
>
Canvases
<span class="ml-1.5 rounded-full bg-zinc-100 px-1.5 py-0.5 text-xs dark:bg-zinc-800">
{{ canvases.length }}
</span>
</button>
<button
:class="[
'px-4 py-2 text-sm font-medium transition-colors',
activeTab === 'assets'
? 'border-b-2 border-zinc-900 text-zinc-900 dark:border-zinc-100 dark:text-zinc-100'
: 'text-zinc-500 hover:text-zinc-700 dark:text-zinc-400 dark:hover:text-zinc-300'
]"
@click="activeTab = 'assets'"
>
Assets
<span class="ml-1.5 rounded-full bg-zinc-100 px-1.5 py-0.5 text-xs dark:bg-zinc-800">
{{ assets.length }}
</span>
</button>
<!-- Tabs & View Toggle -->
<div class="mb-6 flex items-center justify-between border-b border-zinc-200 dark:border-zinc-800">
<div class="flex gap-1">
<button
:class="[
'px-4 py-2 text-sm font-medium transition-colors',
activeTab === 'canvases'
? 'border-b-2 border-zinc-900 text-zinc-900 dark:border-zinc-100 dark:text-zinc-100'
: 'text-zinc-500 hover:text-zinc-700 dark:text-zinc-400 dark:hover:text-zinc-300'
]"
@click="activeTab = 'canvases'"
>
Canvases
<span class="ml-1.5 rounded-full bg-zinc-100 px-1.5 py-0.5 text-xs dark:bg-zinc-800">
{{ canvases.length }}
</span>
</button>
<button
:class="[
'px-4 py-2 text-sm font-medium transition-colors',
activeTab === 'assets'
? 'border-b-2 border-zinc-900 text-zinc-900 dark:border-zinc-100 dark:text-zinc-100'
: 'text-zinc-500 hover:text-zinc-700 dark:text-zinc-400 dark:hover:text-zinc-300'
]"
@click="activeTab = 'assets'"
>
Assets
<span class="ml-1.5 rounded-full bg-zinc-100 px-1.5 py-0.5 text-xs dark:bg-zinc-800">
{{ assets.length }}
</span>
</button>
</div>
<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>
</div>
<!-- Canvases Tab -->
<div v-if="activeTab === 'canvases'">
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
<!-- Grid View -->
<div v-if="viewMode === 'grid'" class="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
<!-- New Canvas Card -->
<button
class="flex aspect-square flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed border-zinc-300 text-zinc-500 transition-colors hover:border-zinc-400 hover:bg-zinc-50 hover:text-zinc-700 dark:border-zinc-700 dark:hover:border-zinc-600 dark:hover:bg-zinc-800/50 dark:hover:text-zinc-300"
@@ -123,28 +163,91 @@ function createCanvas(): void {
</button>
<!-- Canvas Cards -->
<button
<div
v-for="canvas in canvases"
:key="canvas.id"
class="group aspect-square rounded-lg border border-zinc-200 bg-white p-4 text-left transition-all hover:border-zinc-300 hover:shadow-sm dark:border-zinc-800 dark:bg-zinc-900 dark:hover:border-zinc-700"
class="group aspect-square cursor-pointer rounded-lg border border-zinc-200 bg-white p-4 text-left transition-all hover:border-zinc-300 hover:shadow-sm dark:border-zinc-800 dark:bg-zinc-900 dark:hover:border-zinc-700"
@click="openCanvas(canvas.id)"
>
<div class="flex h-full flex-col">
<div class="flex flex-1 items-center justify-center rounded-md bg-zinc-100 dark:bg-zinc-800">
<i class="pi pi-objects-column text-2xl text-zinc-400" />
<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" />
</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"
@click.stop
>
<i class="pi pi-ellipsis-h text-sm" />
</button>
</div>
<div class="mt-3">
<p class="font-medium text-zinc-900 dark:text-zinc-100">{{ canvas.name }}</p>
<p class="mt-1 text-xs text-zinc-500 dark:text-zinc-400">{{ canvas.updatedAt }}</p>
<div class="mt-auto">
<h3 class="font-medium text-zinc-900 dark:text-zinc-100">{{ canvas.name }}</h3>
<p class="mt-1 text-xs text-zinc-400 dark:text-zinc-500">{{ canvas.updatedAt }}</p>
</div>
</div>
</button>
</div>
</div>
<!-- List View -->
<div v-else class="rounded-lg border border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-900">
<div class="divide-y divide-zinc-100 dark:divide-zinc-800">
<div
v-for="canvas in canvases"
:key="canvas.id"
class="flex w-full cursor-pointer items-center gap-4 px-5 py-4 text-left transition-colors hover:bg-zinc-50 dark:hover:bg-zinc-800/50"
@click="openCanvas(canvas.id)"
>
<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" />
</div>
<div class="flex-1 min-w-0">
<p class="font-medium text-zinc-900 dark:text-zinc-100">{{ canvas.name }}</p>
</div>
<span class="text-sm text-zinc-400 dark:text-zinc-500">{{ canvas.updatedAt }}</span>
<button
class="rounded p-1 text-zinc-400 hover:bg-zinc-100 hover:text-zinc-600 dark:hover:bg-zinc-700 dark:hover:text-zinc-300"
@click.stop
>
<i class="pi pi-ellipsis-h text-sm" />
</button>
</div>
</div>
</div>
</div>
<!-- Assets Tab -->
<div v-if="activeTab === 'assets'">
<div class="rounded-lg border border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-900">
<!-- Grid View -->
<div v-if="viewMode === 'grid'" class="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
<div
v-for="asset in assets"
:key="asset.id"
class="group aspect-square cursor-pointer rounded-lg border border-zinc-200 bg-white p-4 text-left transition-all hover:border-zinc-300 hover:shadow-sm dark:border-zinc-800 dark:bg-zinc-900 dark:hover:border-zinc-700"
>
<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="[getAssetIcon(asset.type), '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"
@click.stop
>
<i class="pi pi-ellipsis-h text-sm" />
</button>
</div>
<div class="mt-auto">
<h3 class="truncate font-medium text-zinc-900 dark:text-zinc-100">{{ asset.name }}</h3>
<p class="mt-1 text-sm text-zinc-500 dark:text-zinc-400">{{ asset.dimensions }}</p>
<p class="mt-1 text-xs text-zinc-400 dark:text-zinc-500">{{ asset.size }}</p>
</div>
</div>
</div>
</div>
<!-- List View -->
<div v-else class="rounded-lg border border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-900">
<div class="divide-y divide-zinc-100 dark:divide-zinc-800">
<div
v-for="asset in assets"
@@ -152,12 +255,13 @@ function createCanvas(): void {
class="flex items-center gap-4 px-4 py-3 transition-colors hover:bg-zinc-50 dark:hover:bg-zinc-800/50"
>
<div class="flex h-10 w-10 items-center justify-center rounded-md bg-zinc-100 dark:bg-zinc-800">
<i class="pi pi-image text-zinc-500 dark:text-zinc-400" />
<i :class="[getAssetIcon(asset.type), 'text-zinc-500 dark:text-zinc-400']" />
</div>
<div class="flex-1">
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-zinc-900 dark:text-zinc-100">{{ asset.name }}</p>
<p class="text-xs text-zinc-500 dark:text-zinc-400">{{ asset.type }} {{ asset.size }}</p>
<p class="text-xs text-zinc-500 dark:text-zinc-400">{{ asset.type }} - {{ asset.dimensions }}</p>
</div>
<span class="text-sm text-zinc-400 dark:text-zinc-500">{{ asset.size }}</span>
<button class="rounded p-1.5 text-zinc-400 transition-colors hover:bg-zinc-100 hover:text-zinc-600 dark:hover:bg-zinc-700 dark:hover:text-zinc-300">
<i class="pi pi-download text-sm" />
</button>

View File

@@ -15,12 +15,22 @@ const workspaceId = computed(() => route.params.workspaceId as string)
type ViewMode = 'grid' | 'list'
const viewMode = ref<ViewMode>('grid')
// Sort
type SortOption = 'name' | 'updated' | 'canvases'
const sortBy = ref<SortOption>('updated')
const sortOptions: { value: SortOption; label: string }[] = [
{ value: 'updated', label: 'Last updated' },
{ value: 'name', label: 'Name' },
{ value: 'canvases', label: 'Canvas count' }
]
// Projects data
const projects = ref([
{ id: 'img-gen', name: 'Image Generation', description: 'AI image generation workflows', canvasCount: 5, modelCount: 12, updatedAt: '2 hours ago' },
{ id: 'video-proc', name: 'Video Processing', description: 'Video enhancement and editing', canvasCount: 3, modelCount: 8, updatedAt: '1 day ago' },
{ id: 'audio-enh', name: 'Audio Enhancement', description: 'Audio processing pipelines', canvasCount: 2, modelCount: 4, updatedAt: '3 days ago' },
{ id: 'upscale', name: 'Upscaling', description: 'Image and video upscaling', canvasCount: 4, modelCount: 6, updatedAt: '1 week ago' }
{ id: 'img-gen', name: 'Image Generation', description: 'AI image generation workflows', canvasCount: 5, modelCount: 12, updatedAt: '2 hours ago', updatedTimestamp: Date.now() - 2 * 60 * 60 * 1000 },
{ id: 'video-proc', name: 'Video Processing', description: 'Video enhancement and editing', canvasCount: 3, modelCount: 8, updatedAt: '1 day ago', updatedTimestamp: Date.now() - 24 * 60 * 60 * 1000 },
{ id: 'audio-enh', name: 'Audio Enhancement', description: 'Audio processing pipelines', canvasCount: 2, modelCount: 4, updatedAt: '3 days ago', updatedTimestamp: Date.now() - 3 * 24 * 60 * 60 * 1000 },
{ id: 'upscale', name: 'Upscaling', description: 'Image and video upscaling', canvasCount: 4, modelCount: 6, updatedAt: '1 week ago', updatedTimestamp: Date.now() - 7 * 24 * 60 * 60 * 1000 }
])
// Create dialog
@@ -37,7 +47,8 @@ function createProject(): void {
description: newProject.value.description,
canvasCount: 0,
modelCount: 0,
updatedAt: 'Just now'
updatedAt: 'Just now',
updatedTimestamp: Date.now()
})
showCreateDialog.value = false
@@ -48,16 +59,35 @@ function openProject(projectId: string): void {
router.push(`/${workspaceId.value}/${projectId}`)
}
// Search
// Search and sort
const searchQuery = ref('')
const filteredProjects = computed(() => {
if (!searchQuery.value) return projects.value
const query = searchQuery.value.toLowerCase()
return projects.value.filter(
(p) =>
p.name.toLowerCase().includes(query) ||
p.description.toLowerCase().includes(query)
)
let result = projects.value
// Filter
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase()
result = result.filter(
(p) =>
p.name.toLowerCase().includes(query) ||
p.description.toLowerCase().includes(query)
)
}
// Sort
result = [...result].sort((a, b) => {
switch (sortBy.value) {
case 'name':
return a.name.localeCompare(b.name)
case 'canvases':
return b.canvasCount - a.canvasCount
case 'updated':
default:
return b.updatedTimestamp - a.updatedTimestamp
}
})
return result
})
</script>
@@ -82,8 +112,8 @@ const filteredProjects = computed(() => {
</button>
</div>
<!-- Search & View Toggle -->
<div class="mb-6 flex items-center gap-4">
<!-- 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
@@ -93,6 +123,21 @@ const filteredProjects = computed(() => {
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="[
@@ -226,43 +271,53 @@ const filteredProjects = computed(() => {
v-model:visible="showCreateDialog"
:modal="true"
:draggable="false"
:closable="true"
:style="{ width: '420px' }"
:pt="{
root: { class: 'border border-zinc-200 dark:border-zinc-700 rounded-lg shadow-lg' },
header: { class: 'border-b border-zinc-200 dark:border-zinc-700 px-6 py-4' },
content: { class: 'p-6' },
footer: { class: 'border-t border-zinc-200 dark:border-zinc-700 px-6 py-4' }
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="text-lg font-semibold text-zinc-900 dark:text-zinc-100">Create Project</span>
<span class="dialog-title-text">Create Project</span>
</template>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-zinc-700 dark:text-zinc-300">Name</label>
<div class="dialog-form">
<div class="dialog-field">
<label class="dialog-label">Name</label>
<InputText
v-model="newProject.name"
placeholder="Project name"
class="w-full"
class="dialog-input"
:pt="{
root: { class: 'dialog-input-root' }
}"
@keyup.enter="createProject"
/>
</div>
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-zinc-700 dark:text-zinc-300">Description</label>
<div class="dialog-field">
<label class="dialog-label">Description</label>
<Textarea
v-model="newProject.description"
placeholder="Optional description"
rows="3"
class="w-full"
class="dialog-textarea"
:pt="{
root: { class: 'dialog-textarea-root' }
}"
/>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<div class="dialog-actions">
<button
class="rounded-md border border-zinc-200 bg-white px-4 py-2 text-sm font-medium text-zinc-700 transition-colors hover:bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-300 dark:hover:bg-zinc-700"
class="dialog-btn dialog-btn-secondary"
@click="showCreateDialog = false"
>
Cancel
@@ -270,10 +325,8 @@ const filteredProjects = computed(() => {
<button
:disabled="!newProject.name.trim()"
:class="[
'rounded-md px-4 py-2 text-sm font-medium transition-colors',
newProject.name.trim()
? 'bg-zinc-900 text-white hover:bg-zinc-800 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200'
: 'cursor-not-allowed bg-zinc-200 text-zinc-400 dark:bg-zinc-700 dark:text-zinc-500'
'dialog-btn',
newProject.name.trim() ? 'dialog-btn-primary' : 'dialog-btn-disabled'
]"
@click="createProject"
>

View File

@@ -9,24 +9,53 @@ const workspaceId = computed(() => route.params.workspaceId as string)
type ViewMode = 'grid' | 'list'
const viewMode = ref<ViewMode>('grid')
// Sort
type SortOption = 'name' | 'updated' | 'nodes'
const sortBy = ref<SortOption>('updated')
const sortOptions: { value: SortOption; label: string }[] = [
{ value: 'updated', label: 'Last updated' },
{ value: 'name', label: 'Name' },
{ value: 'nodes', label: 'Node count' }
]
// Mock workflows data
const workflows = ref([
{ id: 'txt2img-basic', name: 'Text to Image Basic', description: 'Simple text to image generation', nodeCount: 8, updatedAt: '1 day ago' },
{ id: 'img2img-refine', name: 'Image Refinement', description: 'Refine and enhance images', nodeCount: 12, updatedAt: '2 days ago' },
{ id: 'upscale-4x', name: '4x Upscale', description: 'High quality image upscaling', nodeCount: 5, updatedAt: '3 days ago' },
{ id: 'controlnet-pose', name: 'ControlNet Pose', description: 'Pose-guided generation', nodeCount: 15, updatedAt: '1 week ago' }
{ id: 'txt2img-basic', name: 'Text to Image Basic', description: 'Simple text to image generation', nodeCount: 8, updatedAt: '1 day ago', updatedTimestamp: Date.now() - 24 * 60 * 60 * 1000 },
{ id: 'img2img-refine', name: 'Image Refinement', description: 'Refine and enhance images', nodeCount: 12, updatedAt: '2 days ago', updatedTimestamp: Date.now() - 2 * 24 * 60 * 60 * 1000 },
{ id: 'upscale-4x', name: '4x Upscale', description: 'High quality image upscaling', nodeCount: 5, updatedAt: '3 days ago', updatedTimestamp: Date.now() - 3 * 24 * 60 * 60 * 1000 },
{ id: 'controlnet-pose', name: 'ControlNet Pose', description: 'Pose-guided generation', nodeCount: 15, updatedAt: '1 week ago', updatedTimestamp: Date.now() - 7 * 24 * 60 * 60 * 1000 }
])
// Search
// Search and sort
const searchQuery = ref('')
const filteredWorkflows = computed(() => {
if (!searchQuery.value) return workflows.value
const query = searchQuery.value.toLowerCase()
return workflows.value.filter(
(w) =>
w.name.toLowerCase().includes(query) ||
w.description.toLowerCase().includes(query)
)
let result = workflows.value
// Filter
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase()
result = result.filter(
(w) =>
w.name.toLowerCase().includes(query) ||
w.description.toLowerCase().includes(query)
)
}
// Sort
result = [...result].sort((a, b) => {
switch (sortBy.value) {
case 'name':
return a.name.localeCompare(b.name)
case 'nodes':
return b.nodeCount - a.nodeCount
case 'updated':
default:
return b.updatedTimestamp - a.updatedTimestamp
}
})
return result
})
</script>
@@ -50,8 +79,8 @@ const filteredWorkflows = computed(() => {
</button>
</div>
<!-- Search & View Toggle -->
<div class="mb-6 flex items-center gap-4">
<!-- 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
@@ -61,6 +90,21 @@ const filteredWorkflows = computed(() => {
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="[
@@ -104,31 +148,38 @@ const filteredWorkflows = computed(() => {
<!-- Grid View -->
<div
v-else-if="viewMode === 'grid'"
class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"
class="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5"
>
<div
v-for="workflow in filteredWorkflows"
:key="workflow.id"
class="group rounded-lg border border-zinc-200 bg-white p-5 transition-all hover:border-zinc-300 hover:shadow-sm dark:border-zinc-800 dark:bg-zinc-900 dark:hover:border-zinc-700"
class="group aspect-square cursor-pointer rounded-lg border border-zinc-200 bg-white p-4 text-left transition-all hover:border-zinc-300 hover:shadow-sm dark:border-zinc-800 dark:bg-zinc-900 dark:hover:border-zinc-700"
>
<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-sitemap text-zinc-500 dark:text-zinc-400" />
<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-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"
@click.stop
>
<i class="pi pi-ellipsis-h text-sm" />
</button>
</div>
<div class="mt-auto">
<h3 class="font-medium text-zinc-900 dark:text-zinc-100">{{ workflow.name }}</h3>
<p class="mt-1 line-clamp-2 text-sm text-zinc-500 dark:text-zinc-400">
{{ workflow.description }}
</p>
<div class="mt-2 flex items-center gap-3 text-xs text-zinc-400 dark:text-zinc-500">
<span class="flex items-center gap-1">
<i class="pi pi-stop" />
{{ workflow.nodeCount }}
</span>
<span>{{ workflow.updatedAt }}</span>
</div>
</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">
<i class="pi pi-ellipsis-h text-sm" />
</button>
</div>
<h3 class="mt-4 font-medium text-zinc-900 dark:text-zinc-100">{{ workflow.name }}</h3>
<p class="mt-1 line-clamp-2 text-sm text-zinc-500 dark:text-zinc-400">
{{ workflow.description }}
</p>
<div class="mt-4 flex items-center gap-4 text-xs text-zinc-400 dark:text-zinc-500">
<span class="flex items-center gap-1">
<i class="pi pi-stop" />
{{ workflow.nodeCount }} nodes
</span>
<span class="ml-auto">{{ workflow.updatedAt }}</span>
</div>
</div>
</div>