mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-12 00:20:15 +00:00
UI improvements: sidebar enhancements, node tree structure, styling updates
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
3
ComfyUI_vibe/src/components.d.ts
vendored
3
ComfyUI_vibe/src/components.d.ts
vendored
@@ -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']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user