mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-28 18:22:40 +00:00
feat(sidebar): Unified sidebar panels with 1-column grid layout
- Create LibraryGridCard component for consistent card design - Refactor LibrarySidebar with multi-select checkbox filters - Create AssetsSidebar with same styling as LibrarySidebar - Create TemplatesSidebar with category filters - Add expand icon to all sidebar panels (Library, Assets, Templates) - Add thumbnails to mock data for workflows, models, nodepacks - Remove categorization in favor of filter dropdowns - Add Library Hub view to workspace with route 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -313,6 +313,10 @@ body {
|
|||||||
.p-tooltip {
|
.p-tooltip {
|
||||||
--p-tooltip-padding: 0.25rem 0.5rem;
|
--p-tooltip-padding: 0.25rem 0.5rem;
|
||||||
--p-tooltip-border-radius: 4px;
|
--p-tooltip-border-radius: 4px;
|
||||||
|
--p-tooltip-background: #000000;
|
||||||
|
--p-tooltip-color: #fafafa;
|
||||||
|
--p-tooltip-show-delay: 100ms;
|
||||||
|
--p-tooltip-hide-delay: 0ms;
|
||||||
}
|
}
|
||||||
|
|
||||||
.p-tooltip .p-tooltip-text {
|
.p-tooltip .p-tooltip-text {
|
||||||
@@ -323,18 +327,14 @@ body {
|
|||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
padding: 0.25rem 0.5rem;
|
padding: 0.25rem 0.5rem;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
border: 1px solid #52525b;
|
||||||
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dark mode tooltip - for canvas/editor */
|
/* Dark mode tooltip - for canvas/editor */
|
||||||
.dark .p-tooltip,
|
.dark .p-tooltip,
|
||||||
.dark-theme .p-tooltip {
|
.dark-theme .p-tooltip {
|
||||||
--p-tooltip-background: #27272a;
|
--p-tooltip-background: #000000;
|
||||||
--p-tooltip-color: #e4e4e7;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Light mode tooltip - for workspace */
|
|
||||||
.p-tooltip {
|
|
||||||
--p-tooltip-background: #18181b;
|
|
||||||
--p-tooltip-color: #fafafa;
|
--p-tooltip-color: #fafafa;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
11
ComfyUI_vibe/src/components.d.ts
vendored
11
ComfyUI_vibe/src/components.d.ts
vendored
@@ -7,6 +7,7 @@ export {}
|
|||||||
/* prettier-ignore */
|
/* prettier-ignore */
|
||||||
declare module 'vue' {
|
declare module 'vue' {
|
||||||
export interface GlobalComponents {
|
export interface GlobalComponents {
|
||||||
|
AssetsSidebar: typeof import('./components/v2/canvas/AssetsSidebar.vue')['default']
|
||||||
AssetsTab: typeof import('./components/v2/workspace/AssetsTab.vue')['default']
|
AssetsTab: typeof import('./components/v2/workspace/AssetsTab.vue')['default']
|
||||||
CanvasBottomBar: typeof import('./components/v2/canvas/CanvasBottomBar.vue')['default']
|
CanvasBottomBar: typeof import('./components/v2/canvas/CanvasBottomBar.vue')['default']
|
||||||
CanvasLeftSidebar: typeof import('./components/v2/canvas/CanvasLeftSidebar.vue')['default']
|
CanvasLeftSidebar: typeof import('./components/v2/canvas/CanvasLeftSidebar.vue')['default']
|
||||||
@@ -19,7 +20,10 @@ declare module 'vue' {
|
|||||||
CreateProjectDialog: typeof import('./components/v2/workspace/CreateProjectDialog.vue')['default']
|
CreateProjectDialog: typeof import('./components/v2/workspace/CreateProjectDialog.vue')['default']
|
||||||
FlowNode: typeof import('./components/v2/nodes/FlowNode.vue')['default']
|
FlowNode: typeof import('./components/v2/nodes/FlowNode.vue')['default']
|
||||||
FlowNodeMinimized: typeof import('./components/v2/nodes/FlowNodeMinimized.vue')['default']
|
FlowNodeMinimized: typeof import('./components/v2/nodes/FlowNodeMinimized.vue')['default']
|
||||||
|
FlowNodeTerminal: typeof import('./components/v2/nodes/terminal/FlowNodeTerminal.vue')['default']
|
||||||
|
GlassNode: typeof import('./components/experimental/nodes/GlassNode.vue')['default']
|
||||||
LibraryBrandKitSection: typeof import('./components/v1/sidebar/LibraryBrandKitSection.vue')['default']
|
LibraryBrandKitSection: typeof import('./components/v1/sidebar/LibraryBrandKitSection.vue')['default']
|
||||||
|
LibraryGridCard: typeof import('./components/common/sidebar/LibraryGridCard.vue')['default']
|
||||||
LibraryModelsSection: typeof import('./components/v1/sidebar/LibraryModelsSection.vue')['default']
|
LibraryModelsSection: typeof import('./components/v1/sidebar/LibraryModelsSection.vue')['default']
|
||||||
LibraryNodesSection: typeof import('./components/v1/sidebar/LibraryNodesSection.vue')['default']
|
LibraryNodesSection: typeof import('./components/v1/sidebar/LibraryNodesSection.vue')['default']
|
||||||
LibrarySidebar: typeof import('./components/v2/canvas/LibrarySidebar.vue')['default']
|
LibrarySidebar: typeof import('./components/v2/canvas/LibrarySidebar.vue')['default']
|
||||||
@@ -37,12 +41,17 @@ declare module 'vue' {
|
|||||||
LinearTopNavbar: typeof import('./components/linear/LinearTopNavbar.vue')['default']
|
LinearTopNavbar: typeof import('./components/linear/LinearTopNavbar.vue')['default']
|
||||||
LinearWorkflowSidebar: typeof import('./components/linear/LinearWorkflowSidebar.vue')['default']
|
LinearWorkflowSidebar: typeof import('./components/linear/LinearWorkflowSidebar.vue')['default']
|
||||||
LinearWorkspace: typeof import('./components/linear/LinearWorkspace.vue')['default']
|
LinearWorkspace: typeof import('./components/linear/LinearWorkspace.vue')['default']
|
||||||
|
MinimalNode: typeof import('./components/experimental/nodes/MinimalNode.vue')['default']
|
||||||
ModelsTab: typeof import('./components/v2/workspace/ModelsTab.vue')['default']
|
ModelsTab: typeof import('./components/v2/workspace/ModelsTab.vue')['default']
|
||||||
NodeHeader: typeof import('./components/v2/nodes/NodeHeader.vue')['default']
|
NodeHeader: typeof import('./components/v2/nodes/NodeHeader.vue')['default']
|
||||||
|
NodeHeaderTerminal: typeof import('./components/v2/nodes/terminal/NodeHeaderTerminal.vue')['default']
|
||||||
NodePropertiesPanel: typeof import('./components/v2/canvas/NodePropertiesPanel.vue')['default']
|
NodePropertiesPanel: typeof import('./components/v2/canvas/NodePropertiesPanel.vue')['default']
|
||||||
NodeSlots: typeof import('./components/v2/nodes/NodeSlots.vue')['default']
|
NodeSlots: typeof import('./components/v2/nodes/NodeSlots.vue')['default']
|
||||||
|
NodeSlotsTerminal: typeof import('./components/v2/nodes/terminal/NodeSlotsTerminal.vue')['default']
|
||||||
NodeWidgets: typeof import('./components/v2/nodes/NodeWidgets.vue')['default']
|
NodeWidgets: typeof import('./components/v2/nodes/NodeWidgets.vue')['default']
|
||||||
|
NodeWidgetsTerminal: typeof import('./components/v2/nodes/terminal/NodeWidgetsTerminal.vue')['default']
|
||||||
PackagesTab: typeof import('./components/v2/workspace/PackagesTab.vue')['default']
|
PackagesTab: typeof import('./components/v2/workspace/PackagesTab.vue')['default']
|
||||||
|
PillNode: typeof import('./components/experimental/nodes/PillNode.vue')['default']
|
||||||
RouterLink: typeof import('vue-router')['RouterLink']
|
RouterLink: typeof import('vue-router')['RouterLink']
|
||||||
RouterView: typeof import('vue-router')['RouterView']
|
RouterView: typeof import('vue-router')['RouterView']
|
||||||
SidebarGridCard: typeof import('./components/common/sidebar/SidebarGridCard.vue')['default']
|
SidebarGridCard: typeof import('./components/common/sidebar/SidebarGridCard.vue')['default']
|
||||||
@@ -51,6 +60,8 @@ declare module 'vue' {
|
|||||||
SidebarTreeItem: typeof import('./components/common/sidebar/SidebarTreeItem.vue')['default']
|
SidebarTreeItem: typeof import('./components/common/sidebar/SidebarTreeItem.vue')['default']
|
||||||
SidebarViewToggle: typeof import('./components/common/sidebar/SidebarViewToggle.vue')['default']
|
SidebarViewToggle: typeof import('./components/common/sidebar/SidebarViewToggle.vue')['default']
|
||||||
SlotDot: typeof import('./components/v2/nodes/SlotDot.vue')['default']
|
SlotDot: typeof import('./components/v2/nodes/SlotDot.vue')['default']
|
||||||
|
TemplatesSidebar: typeof import('./components/v2/canvas/TemplatesSidebar.vue')['default']
|
||||||
|
TerminalNode: typeof import('./components/experimental/nodes/TerminalNode.vue')['default']
|
||||||
V1SidebarAssetsTab: typeof import('./components/v1/sidebar/V1SidebarAssetsTab.vue')['default']
|
V1SidebarAssetsTab: typeof import('./components/v1/sidebar/V1SidebarAssetsTab.vue')['default']
|
||||||
V1SidebarIconBar: typeof import('./components/v1/sidebar/V1SidebarIconBar.vue')['default']
|
V1SidebarIconBar: typeof import('./components/v1/sidebar/V1SidebarIconBar.vue')['default']
|
||||||
V1SidebarModelsTab: typeof import('./components/v1/sidebar/V1SidebarModelsTab.vue')['default']
|
V1SidebarModelsTab: typeof import('./components/v1/sidebar/V1SidebarModelsTab.vue')['default']
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
interface Props {
|
||||||
|
title: string
|
||||||
|
subtitle?: string
|
||||||
|
thumbnail?: string
|
||||||
|
icon?: string
|
||||||
|
iconClass?: string
|
||||||
|
badge?: string
|
||||||
|
badgeClass?: string
|
||||||
|
starred?: boolean
|
||||||
|
draggable?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
subtitle: undefined,
|
||||||
|
thumbnail: undefined,
|
||||||
|
icon: undefined,
|
||||||
|
iconClass: 'text-zinc-400',
|
||||||
|
badge: undefined,
|
||||||
|
badgeClass: 'bg-zinc-700 text-zinc-400',
|
||||||
|
starred: false,
|
||||||
|
draggable: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
click: []
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="group cursor-pointer overflow-hidden rounded-lg border border-zinc-800 bg-zinc-900/80 transition-all hover:border-zinc-600 hover:bg-zinc-800/80"
|
||||||
|
:draggable="props.draggable"
|
||||||
|
@click="emit('click')"
|
||||||
|
>
|
||||||
|
<!-- Thumbnail -->
|
||||||
|
<div class="relative aspect-[4/3] overflow-hidden bg-zinc-800">
|
||||||
|
<img
|
||||||
|
v-if="props.thumbnail"
|
||||||
|
:src="props.thumbnail"
|
||||||
|
:alt="props.title"
|
||||||
|
class="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="flex h-full w-full items-center justify-center"
|
||||||
|
>
|
||||||
|
<i :class="[props.icon || 'pi pi-file', 'text-2xl text-zinc-600']" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Gradient overlay -->
|
||||||
|
<div class="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent" />
|
||||||
|
|
||||||
|
<!-- Starred indicator -->
|
||||||
|
<div
|
||||||
|
v-if="props.starred"
|
||||||
|
class="absolute left-1.5 top-1.5 flex h-5 w-5 items-center justify-center rounded bg-amber-500/20 backdrop-blur-sm"
|
||||||
|
>
|
||||||
|
<i class="pi pi-star-fill text-[10px] text-amber-400" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Badge (top-right) -->
|
||||||
|
<div
|
||||||
|
v-if="props.badge"
|
||||||
|
class="absolute right-1.5 top-1.5"
|
||||||
|
>
|
||||||
|
<span :class="['rounded px-1.5 py-0.5 text-[9px] font-medium backdrop-blur-sm', props.badgeClass]">
|
||||||
|
{{ props.badge }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Icon badge (bottom-left) -->
|
||||||
|
<div
|
||||||
|
v-if="props.icon"
|
||||||
|
class="absolute bottom-1.5 left-1.5 flex h-6 w-6 items-center justify-center rounded bg-black/40 backdrop-blur-sm"
|
||||||
|
>
|
||||||
|
<i :class="[props.icon, 'text-xs', props.iconClass]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add button (bottom-right, on hover) -->
|
||||||
|
<button
|
||||||
|
class="absolute bottom-1.5 right-1.5 flex h-6 w-6 items-center justify-center rounded bg-white/90 text-zinc-800 opacity-0 transition-all hover:bg-white group-hover:opacity-100"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
<i class="pi pi-plus text-xs" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div class="p-2">
|
||||||
|
<div class="truncate text-xs font-medium text-zinc-200 group-hover:text-white">
|
||||||
|
{{ props.title }}
|
||||||
|
</div>
|
||||||
|
<div v-if="props.subtitle" class="mt-0.5 truncate text-[10px] text-zinc-500">
|
||||||
|
{{ props.subtitle }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
export { default as SidebarTreeCategory } from './SidebarTreeCategory.vue'
|
export { default as SidebarTreeCategory } from './SidebarTreeCategory.vue'
|
||||||
export { default as SidebarTreeItem } from './SidebarTreeItem.vue'
|
export { default as SidebarTreeItem } from './SidebarTreeItem.vue'
|
||||||
export { default as SidebarGridCard } from './SidebarGridCard.vue'
|
export { default as SidebarGridCard } from './SidebarGridCard.vue'
|
||||||
|
export { default as LibraryGridCard } from './LibraryGridCard.vue'
|
||||||
export { default as SidebarViewToggle } from './SidebarViewToggle.vue'
|
export { default as SidebarViewToggle } from './SidebarViewToggle.vue'
|
||||||
export { default as SidebarSearchBox } from './SidebarSearchBox.vue'
|
export { default as SidebarSearchBox } from './SidebarSearchBox.vue'
|
||||||
|
|||||||
@@ -21,6 +21,16 @@ function getAssetIcon(type: BrandAsset['type']): string {
|
|||||||
default: return 'pi pi-file'
|
default: return 'pi pi-file'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getAssetTypeLabel(type: BrandAsset['type']): string {
|
||||||
|
switch (type) {
|
||||||
|
case 'logo': return 'Logo'
|
||||||
|
case 'font': return 'Font'
|
||||||
|
case 'template': return 'Template'
|
||||||
|
case 'guideline': return 'Guide'
|
||||||
|
default: return type
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -38,30 +48,12 @@ function getAssetIcon(type: BrandAsset['type']): string {
|
|||||||
<i class="pi pi-palette text-xs text-amber-400" />
|
<i class="pi pi-palette text-xs text-amber-400" />
|
||||||
<span class="flex-1 text-xs font-medium text-zinc-300">Brand Kit</span>
|
<span class="flex-1 text-xs font-medium text-zinc-300">Brand Kit</span>
|
||||||
<span class="rounded bg-zinc-800 px-1.5 py-0.5 text-[10px] text-zinc-500">
|
<span class="rounded bg-zinc-800 px-1.5 py-0.5 text-[10px] text-zinc-500">
|
||||||
{{ assets.length }}
|
{{ assets.filter(a => a.type !== 'color').length }}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Items -->
|
<!-- Items -->
|
||||||
<div v-if="expanded" class="ml-4 space-y-0.5 border-l border-zinc-800 pl-2">
|
<div v-if="expanded" class="ml-4 space-y-0.5 border-l border-zinc-800 pl-2">
|
||||||
<!-- Colors Row -->
|
|
||||||
<div class="px-2 py-1.5">
|
|
||||||
<div class="mb-1 text-[10px] font-medium uppercase tracking-wider text-zinc-500">Colors</div>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<div
|
|
||||||
v-for="asset in assets.filter(a => a.type === 'color')"
|
|
||||||
:key="asset.id"
|
|
||||||
v-tooltip.top="{ value: asset.name, showDelay: 50 }"
|
|
||||||
class="group relative cursor-pointer"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="h-6 w-6 rounded border border-zinc-700 transition-transform group-hover:scale-110"
|
|
||||||
:style="{ backgroundColor: asset.value }"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- Other Assets -->
|
|
||||||
<div
|
<div
|
||||||
v-for="asset in assets.filter(a => a.type !== 'color')"
|
v-for="asset in assets.filter(a => a.type !== 'color')"
|
||||||
:key="asset.id"
|
:key="asset.id"
|
||||||
@@ -77,29 +69,49 @@ function getAssetIcon(type: BrandAsset['type']): string {
|
|||||||
|
|
||||||
<!-- Grid View -->
|
<!-- Grid View -->
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="mb-1.5 flex items-center gap-2 px-1">
|
<div class="mb-2 flex items-center justify-between px-1">
|
||||||
<i class="pi pi-palette text-xs text-amber-400" />
|
<div class="flex items-center gap-2">
|
||||||
<span class="text-[10px] font-medium uppercase tracking-wider text-zinc-500">Brand Kit</span>
|
<i class="pi pi-palette text-xs text-amber-400" />
|
||||||
</div>
|
<span class="text-[10px] font-medium uppercase tracking-wider text-zinc-500">Brand Kit</span>
|
||||||
<div class="grid grid-cols-3 gap-1.5">
|
|
||||||
<div
|
|
||||||
v-for="asset in assets.filter(a => a.type === 'color')"
|
|
||||||
:key="asset.id"
|
|
||||||
v-tooltip.top="{ value: asset.name, showDelay: 50 }"
|
|
||||||
class="group cursor-pointer rounded-lg border border-zinc-800 bg-zinc-900 p-2 transition-all hover:border-zinc-700"
|
|
||||||
>
|
|
||||||
<div class="mb-1.5 h-8 w-full rounded" :style="{ backgroundColor: asset.value }" />
|
|
||||||
<div class="truncate text-[10px] text-zinc-500">{{ asset.name }}</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<span class="rounded bg-zinc-800 px-1.5 py-0.5 text-[9px] text-zinc-500">
|
||||||
|
{{ assets.filter(a => a.type !== 'color').length }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Assets Grid -->
|
||||||
|
<div class="grid grid-cols-2 gap-2">
|
||||||
<div
|
<div
|
||||||
v-for="asset in assets.filter(a => a.type !== 'color')"
|
v-for="asset in assets.filter(a => a.type !== 'color')"
|
||||||
:key="asset.id"
|
:key="asset.id"
|
||||||
class="group cursor-pointer rounded-lg border border-zinc-800 bg-zinc-900 p-2 transition-all hover:border-zinc-700"
|
class="group cursor-pointer overflow-hidden rounded-lg border border-zinc-800 bg-zinc-900/80 transition-all hover:border-zinc-600 hover:bg-zinc-800/80"
|
||||||
>
|
>
|
||||||
<div class="mb-1.5 flex h-8 items-center justify-center rounded bg-zinc-800">
|
<!-- Icon Thumbnail -->
|
||||||
<i :class="[getAssetIcon(asset.type), 'text-base text-zinc-600']" />
|
<div class="relative flex aspect-[4/3] items-center justify-center bg-zinc-800">
|
||||||
|
<i :class="[getAssetIcon(asset.type), 'text-3xl text-zinc-600 transition-colors group-hover:text-amber-400']" />
|
||||||
|
<!-- Type Badge -->
|
||||||
|
<div class="absolute right-1.5 top-1.5">
|
||||||
|
<span class="rounded bg-amber-500/30 px-1.5 py-0.5 text-[9px] font-medium text-amber-300 backdrop-blur-sm">
|
||||||
|
{{ getAssetTypeLabel(asset.type) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<!-- Add button -->
|
||||||
|
<button
|
||||||
|
class="absolute bottom-1.5 right-1.5 flex h-6 w-6 items-center justify-center rounded bg-white/90 text-zinc-800 opacity-0 transition-all hover:bg-white group-hover:opacity-100"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
<i class="pi pi-plus text-xs" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<!-- Content -->
|
||||||
|
<div class="p-2">
|
||||||
|
<div class="truncate text-xs font-medium text-zinc-200 group-hover:text-white">
|
||||||
|
{{ asset.name }}
|
||||||
|
</div>
|
||||||
|
<div v-if="asset.description" class="mt-0.5 truncate text-[10px] text-zinc-500">
|
||||||
|
{{ asset.description }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="truncate text-[10px] text-zinc-400">{{ asset.name }}</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { TeamModel } from '@/data/sidebarMockData'
|
import type { TeamModel } from '@/data/sidebarMockData'
|
||||||
|
import { LibraryGridCard } from '@/components/common/sidebar'
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
models: TeamModel[]
|
models: TeamModel[]
|
||||||
@@ -20,6 +21,16 @@ function getModelTypeLabel(type: TeamModel['type']): string {
|
|||||||
default: return type
|
default: return type
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getModelBadgeClass(type: TeamModel['type']): string {
|
||||||
|
switch (type) {
|
||||||
|
case 'checkpoint': return 'bg-purple-500/30 text-purple-300'
|
||||||
|
case 'lora': return 'bg-green-500/30 text-green-300'
|
||||||
|
case 'embedding': return 'bg-amber-500/30 text-amber-300'
|
||||||
|
case 'controlnet': return 'bg-cyan-500/30 text-cyan-300'
|
||||||
|
default: return 'bg-zinc-700 text-zinc-400'
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -69,30 +80,27 @@ function getModelTypeLabel(type: TeamModel['type']): string {
|
|||||||
|
|
||||||
<!-- Grid View -->
|
<!-- Grid View -->
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="mb-1.5 flex items-center gap-2 px-1">
|
<div class="mb-2 flex items-center justify-between px-1">
|
||||||
<i class="pi pi-box text-xs text-green-400" />
|
<div class="flex items-center gap-2">
|
||||||
<span class="text-[10px] font-medium uppercase tracking-wider text-zinc-500">Team Models</span>
|
<i class="pi pi-box text-xs text-green-400" />
|
||||||
|
<span class="text-[10px] font-medium uppercase tracking-wider text-zinc-500">Models</span>
|
||||||
|
</div>
|
||||||
|
<span class="rounded bg-zinc-800 px-1.5 py-0.5 text-[9px] text-zinc-500">
|
||||||
|
{{ models.length }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-2 gap-1.5">
|
<div class="grid grid-cols-2 gap-2">
|
||||||
<div
|
<LibraryGridCard
|
||||||
v-for="model in models"
|
v-for="model in models"
|
||||||
:key="model.id"
|
:key="model.id"
|
||||||
class="group cursor-pointer rounded-lg border border-zinc-800 bg-zinc-900 p-2 transition-all hover:border-zinc-700 hover:bg-zinc-800/50"
|
:title="model.name"
|
||||||
draggable="true"
|
:subtitle="`${model.size} · ${model.downloads} downloads`"
|
||||||
>
|
:thumbnail="model.thumbnail"
|
||||||
<div class="mb-1 flex items-center justify-between">
|
icon="pi pi-box"
|
||||||
<span class="rounded bg-zinc-800 px-1 py-0.5 text-[9px] text-zinc-500">
|
icon-class="text-green-400"
|
||||||
{{ getModelTypeLabel(model.type) }}
|
:badge="getModelTypeLabel(model.type)"
|
||||||
</span>
|
:badge-class="getModelBadgeClass(model.type)"
|
||||||
<span class="text-[9px] text-zinc-600">{{ model.size }}</span>
|
/>
|
||||||
</div>
|
|
||||||
<div class="truncate text-xs text-zinc-400 group-hover:text-zinc-200">
|
|
||||||
{{ model.name }}
|
|
||||||
</div>
|
|
||||||
<div class="mt-0.5 truncate text-[10px] text-zinc-600">
|
|
||||||
{{ model.downloads }} downloads
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { NodePack } from '@/data/sidebarMockData'
|
import type { NodePack } from '@/data/sidebarMockData'
|
||||||
|
import { LibraryGridCard } from '@/components/common/sidebar'
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
packs: NodePack[]
|
packs: NodePack[]
|
||||||
@@ -68,36 +69,27 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
<!-- Grid View -->
|
<!-- Grid View -->
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="mb-1.5 flex items-center gap-2 px-1">
|
<div class="mb-2 flex items-center justify-between px-1">
|
||||||
<i class="pi pi-code text-xs text-purple-400" />
|
<div class="flex items-center gap-2">
|
||||||
<span class="text-[10px] font-medium uppercase tracking-wider text-zinc-500">Custom Nodes</span>
|
<i class="pi pi-code text-xs text-purple-400" />
|
||||||
|
<span class="text-[10px] font-medium uppercase tracking-wider text-zinc-500">Nodepacks</span>
|
||||||
|
</div>
|
||||||
|
<span class="rounded bg-zinc-800 px-1.5 py-0.5 text-[9px] text-zinc-500">
|
||||||
|
{{ packs.length }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-2 gap-1.5">
|
<div class="grid grid-cols-2 gap-2">
|
||||||
<div
|
<LibraryGridCard
|
||||||
v-for="pack in packs"
|
v-for="pack in packs"
|
||||||
:key="pack.id"
|
:key="pack.id"
|
||||||
class="group cursor-pointer rounded-lg border border-zinc-800 bg-zinc-900 p-2 transition-all hover:border-zinc-700 hover:bg-zinc-800/50"
|
:title="pack.name"
|
||||||
>
|
:subtitle="`${pack.nodes} nodes · v${pack.version}`"
|
||||||
<div class="mb-1 flex items-center justify-between">
|
:thumbnail="pack.thumbnail"
|
||||||
<span class="rounded bg-zinc-800 px-1 py-0.5 text-[9px] text-zinc-500">
|
icon="pi pi-code"
|
||||||
v{{ pack.version }}
|
icon-class="text-purple-400"
|
||||||
</span>
|
:badge="pack.installed ? 'Installed' : 'Available'"
|
||||||
<span
|
:badge-class="pack.installed ? 'bg-green-500/30 text-green-300' : 'bg-zinc-700 text-zinc-400'"
|
||||||
:class="[
|
/>
|
||||||
'rounded px-1 py-0.5 text-[9px]',
|
|
||||||
pack.installed ? 'bg-green-500/20 text-green-400' : 'bg-zinc-800 text-zinc-500'
|
|
||||||
]"
|
|
||||||
>
|
|
||||||
{{ pack.installed ? 'Installed' : pack.nodes + ' nodes' }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="truncate text-xs text-zinc-400 group-hover:text-zinc-200">
|
|
||||||
{{ pack.name }}
|
|
||||||
</div>
|
|
||||||
<div class="mt-0.5 truncate text-[10px] text-zinc-600">
|
|
||||||
{{ pack.author }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { SharedWorkflow } from '@/data/sidebarMockData'
|
import type { SharedWorkflow } from '@/data/sidebarMockData'
|
||||||
|
import { LibraryGridCard } from '@/components/common/sidebar'
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
workflows: SharedWorkflow[]
|
workflows: SharedWorkflow[]
|
||||||
@@ -57,31 +58,28 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
<!-- Grid View -->
|
<!-- Grid View -->
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="mb-1.5 flex items-center gap-2 px-1">
|
<div class="mb-2 flex items-center justify-between px-1">
|
||||||
<i class="pi pi-sitemap text-xs text-blue-400" />
|
<div class="flex items-center gap-2">
|
||||||
<span class="text-[10px] font-medium uppercase tracking-wider text-zinc-500">Shared Workflows</span>
|
<i class="pi pi-sitemap text-xs text-blue-400" />
|
||||||
|
<span class="text-[10px] font-medium uppercase tracking-wider text-zinc-500">Workflows</span>
|
||||||
|
</div>
|
||||||
|
<span class="rounded bg-zinc-800 px-1.5 py-0.5 text-[9px] text-zinc-500">
|
||||||
|
{{ workflows.length }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-2 gap-1.5">
|
<div class="grid grid-cols-2 gap-2">
|
||||||
<div
|
<LibraryGridCard
|
||||||
v-for="workflow in workflows"
|
v-for="workflow in workflows"
|
||||||
:key="workflow.id"
|
:key="workflow.id"
|
||||||
class="group cursor-pointer rounded-lg border border-zinc-800 bg-zinc-900 p-2 transition-all hover:border-zinc-700 hover:bg-zinc-800/50"
|
:title="workflow.name"
|
||||||
draggable="true"
|
:subtitle="`${workflow.nodes} nodes · ${workflow.updatedAt}`"
|
||||||
>
|
:thumbnail="workflow.thumbnail"
|
||||||
<div class="mb-1 flex items-center justify-between">
|
icon="pi pi-sitemap"
|
||||||
<i v-if="workflow.starred" class="pi pi-star-fill text-[10px] text-amber-400" />
|
icon-class="text-blue-400"
|
||||||
<i v-else class="pi pi-sitemap text-[10px] text-zinc-600" />
|
:badge="workflow.category"
|
||||||
<span class="rounded bg-zinc-800 px-1 py-0.5 text-[9px] text-zinc-600">
|
badge-class="bg-blue-500/30 text-blue-300"
|
||||||
{{ workflow.nodes }}
|
:starred="workflow.starred"
|
||||||
</span>
|
/>
|
||||||
</div>
|
|
||||||
<div class="truncate text-xs text-zinc-400 group-hover:text-zinc-200">
|
|
||||||
{{ workflow.name }}
|
|
||||||
</div>
|
|
||||||
<div class="mt-0.5 truncate text-[10px] text-zinc-600">
|
|
||||||
{{ workflow.updatedAt }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ import { SidebarSearchBox, SidebarViewToggle } from '@/components/common/sidebar
|
|||||||
import V1SidebarNodesTab from './V1SidebarNodesTab.vue'
|
import V1SidebarNodesTab from './V1SidebarNodesTab.vue'
|
||||||
import V1SidebarModelsTab from './V1SidebarModelsTab.vue'
|
import V1SidebarModelsTab from './V1SidebarModelsTab.vue'
|
||||||
import V1SidebarWorkflowsTab from './V1SidebarWorkflowsTab.vue'
|
import V1SidebarWorkflowsTab from './V1SidebarWorkflowsTab.vue'
|
||||||
import V1SidebarAssetsTab from './V1SidebarAssetsTab.vue'
|
|
||||||
import V1SidebarTemplatesTab from './V1SidebarTemplatesTab.vue'
|
|
||||||
import LibrarySidebar from '@/components/v2/canvas/LibrarySidebar.vue'
|
import LibrarySidebar from '@/components/v2/canvas/LibrarySidebar.vue'
|
||||||
|
import AssetsSidebar from '@/components/v2/canvas/AssetsSidebar.vue'
|
||||||
|
import TemplatesSidebar from '@/components/v2/canvas/TemplatesSidebar.vue'
|
||||||
|
|
||||||
const uiStore = useUiStore()
|
const uiStore = useUiStore()
|
||||||
|
|
||||||
@@ -92,6 +92,18 @@ function setFilter(value: string): void {
|
|||||||
@close="uiStore.closeSidebarPanel()"
|
@close="uiStore.closeSidebarPanel()"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- Assets Tab - Full custom layout -->
|
||||||
|
<AssetsSidebar
|
||||||
|
v-else-if="sidebarPanelExpanded && activeSidebarTab === 'assets'"
|
||||||
|
@close="uiStore.closeSidebarPanel()"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Templates Tab - Full custom layout -->
|
||||||
|
<TemplatesSidebar
|
||||||
|
v-else-if="sidebarPanelExpanded && activeSidebarTab === 'templates'"
|
||||||
|
@close="uiStore.closeSidebarPanel()"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- Other Tabs - Standard layout -->
|
<!-- Other Tabs - Standard layout -->
|
||||||
<div v-else-if="sidebarPanelExpanded" class="flex h-full w-80 flex-col">
|
<div v-else-if="sidebarPanelExpanded" class="flex h-full w-80 flex-col">
|
||||||
<!-- Panel Header -->
|
<!-- Panel Header -->
|
||||||
@@ -184,8 +196,6 @@ function setFilter(value: string): void {
|
|||||||
<V1SidebarNodesTab v-if="activeSidebarTab === 'nodes'" :view-mode="viewMode" />
|
<V1SidebarNodesTab v-if="activeSidebarTab === 'nodes'" :view-mode="viewMode" />
|
||||||
<V1SidebarModelsTab v-else-if="activeSidebarTab === 'models'" :view-mode="viewMode" />
|
<V1SidebarModelsTab v-else-if="activeSidebarTab === 'models'" :view-mode="viewMode" />
|
||||||
<V1SidebarWorkflowsTab v-else-if="activeSidebarTab === 'workflows'" :view-mode="viewMode" />
|
<V1SidebarWorkflowsTab v-else-if="activeSidebarTab === 'workflows'" :view-mode="viewMode" />
|
||||||
<V1SidebarAssetsTab v-else-if="activeSidebarTab === 'assets'" :view-mode="viewMode" />
|
|
||||||
<V1SidebarTemplatesTab v-else-if="activeSidebarTab === 'templates'" :view-mode="viewMode" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
314
ComfyUI_vibe/src/components/v2/canvas/AssetsSidebar.vue
Normal file
314
ComfyUI_vibe/src/components/v2/canvas/AssetsSidebar.vue
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import Button from 'primevue/button'
|
||||||
|
import { SidebarSearchBox, SidebarViewToggle, LibraryGridCard } from '@/components/common/sidebar'
|
||||||
|
|
||||||
|
interface AssetItem {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
type: 'image' | 'video' | 'audio' | 'mask' | '3d'
|
||||||
|
size: string
|
||||||
|
dimensions?: string
|
||||||
|
thumbnail?: string
|
||||||
|
icon: string
|
||||||
|
iconClass: string
|
||||||
|
badge?: string
|
||||||
|
badgeClass?: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
close: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const searchQuery = ref('')
|
||||||
|
const viewMode = ref<'list' | 'grid'>('grid')
|
||||||
|
const sortBy = ref('recent')
|
||||||
|
const showFilterMenu = ref(false)
|
||||||
|
const showSortMenu = ref(false)
|
||||||
|
const activeFilters = ref<Set<string>>(new Set())
|
||||||
|
|
||||||
|
const sortOptions = [
|
||||||
|
{ label: 'Recent', value: 'recent' },
|
||||||
|
{ label: 'Name', value: 'name' },
|
||||||
|
{ label: 'Size', value: 'size' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const filterOptions = [
|
||||||
|
{ label: 'Images', value: 'image', icon: 'pi pi-image', color: 'text-blue-400' },
|
||||||
|
{ label: 'Videos', value: 'video', icon: 'pi pi-video', color: 'text-purple-400' },
|
||||||
|
{ label: 'Audio', value: 'audio', icon: 'pi pi-volume-up', color: 'text-green-400' },
|
||||||
|
{ label: 'Masks', value: 'mask', icon: 'pi pi-circle', color: 'text-amber-400' },
|
||||||
|
{ label: '3D', value: '3d', icon: 'pi pi-box', color: 'text-cyan-400' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function setSort(value: string): void {
|
||||||
|
sortBy.value = value
|
||||||
|
showSortMenu.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleFilter(value: string): void {
|
||||||
|
const newFilters = new Set(activeFilters.value)
|
||||||
|
if (newFilters.has(value)) {
|
||||||
|
newFilters.delete(value)
|
||||||
|
} else {
|
||||||
|
newFilters.add(value)
|
||||||
|
}
|
||||||
|
activeFilters.value = newFilters
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearFilters(): void {
|
||||||
|
activeFilters.value = new Set()
|
||||||
|
}
|
||||||
|
|
||||||
|
const filterLabel = computed(() => {
|
||||||
|
if (activeFilters.value.size === 0) return 'All'
|
||||||
|
if (activeFilters.value.size === 1) {
|
||||||
|
const value = [...activeFilters.value][0]
|
||||||
|
return filterOptions.find(o => o.value === value)?.label || 'All'
|
||||||
|
}
|
||||||
|
return `${activeFilters.value.size} selected`
|
||||||
|
})
|
||||||
|
|
||||||
|
// Mock assets data
|
||||||
|
const allAssets = computed<AssetItem[]>(() => [
|
||||||
|
{ id: '1', name: 'reference_portrait.png', type: 'image', size: '2.4 MB', dimensions: '1024x1024', thumbnail: '/assets/card_images/workflow_01.webp', icon: 'pi pi-image', iconClass: 'text-blue-400', badge: 'PNG', badgeClass: 'bg-blue-500/30 text-blue-300', updatedAt: '2 hours ago' },
|
||||||
|
{ id: '2', name: 'depth_map_01.png', type: 'mask', size: '512 KB', dimensions: '512x512', thumbnail: '/assets/card_images/2690a78c-c210-4a52-8c37-3cb5bc4d9e71.webp', icon: 'pi pi-circle', iconClass: 'text-amber-400', badge: 'Mask', badgeClass: 'bg-amber-500/30 text-amber-300', updatedAt: '1 day ago' },
|
||||||
|
{ id: '3', name: 'hero_background.jpg', type: 'image', size: '3.8 MB', dimensions: '1920x1080', thumbnail: '/assets/card_images/bacb46ea-7e63-4f19-a253-daf41461e98f.webp', icon: 'pi pi-image', iconClass: 'text-blue-400', badge: 'JPG', badgeClass: 'bg-blue-500/30 text-blue-300', updatedAt: '3 days ago' },
|
||||||
|
{ id: '4', name: 'animation_loop.mp4', type: 'video', size: '12.5 MB', dimensions: '1080x1920', thumbnail: '/assets/card_images/228616f4-12ad-426d-84fb-f20e488ba7ee.webp', icon: 'pi pi-video', iconClass: 'text-purple-400', badge: 'MP4', badgeClass: 'bg-purple-500/30 text-purple-300', updatedAt: '1 week ago' },
|
||||||
|
{ id: '5', name: 'controlnet_pose.png', type: 'mask', size: '890 KB', dimensions: '768x1024', thumbnail: '/assets/card_images/683255d3-1d10-43d9-a6ff-ef142061e88a.webp', icon: 'pi pi-circle', iconClass: 'text-amber-400', badge: 'Pose', badgeClass: 'bg-amber-500/30 text-amber-300', updatedAt: '2 days ago' },
|
||||||
|
{ id: '6', name: 'product_shot.png', type: 'image', size: '1.8 MB', dimensions: '2048x2048', thumbnail: '/assets/card_images/91f1f589-ddb4-4c4f-b3a7-ba30fc271987.webp', icon: 'pi pi-image', iconClass: 'text-blue-400', badge: 'PNG', badgeClass: 'bg-blue-500/30 text-blue-300', updatedAt: '5 days ago' },
|
||||||
|
{ id: '7', name: 'ambient_audio.wav', type: 'audio', size: '4.2 MB', icon: 'pi pi-volume-up', iconClass: 'text-green-400', badge: 'WAV', badgeClass: 'bg-green-500/30 text-green-300', updatedAt: '1 week ago' },
|
||||||
|
{ id: '8', name: 'canny_edges.png', type: 'mask', size: '320 KB', dimensions: '1024x1024', thumbnail: '/assets/card_images/28e9f7ea-ef00-48e8-849d-8752a34939c7.webp', icon: 'pi pi-circle', iconClass: 'text-amber-400', badge: 'Canny', badgeClass: 'bg-amber-500/30 text-amber-300', updatedAt: '4 days ago' },
|
||||||
|
{ id: '9', name: 'style_reference.webp', type: 'image', size: '680 KB', dimensions: '512x768', thumbnail: '/assets/card_images/comfyui_workflow.jpg', icon: 'pi pi-image', iconClass: 'text-blue-400', badge: 'WEBP', badgeClass: 'bg-blue-500/30 text-blue-300', updatedAt: '6 days ago' },
|
||||||
|
{ id: '10', name: 'promo_video.mp4', type: 'video', size: '28.4 MB', dimensions: '1920x1080', thumbnail: '/assets/card_images/dda28581-37c8-44da-8822-57d1ccc2118c_2130x1658.png', icon: 'pi pi-video', iconClass: 'text-purple-400', badge: 'MP4', badgeClass: 'bg-purple-500/30 text-purple-300', updatedAt: '2 weeks ago' },
|
||||||
|
])
|
||||||
|
|
||||||
|
// Filter and search
|
||||||
|
const filteredAssets = computed(() => {
|
||||||
|
let items = allAssets.value
|
||||||
|
|
||||||
|
// Apply type filters (multi-select)
|
||||||
|
if (activeFilters.value.size > 0) {
|
||||||
|
items = items.filter(i => activeFilters.value.has(i.type))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply search
|
||||||
|
if (searchQuery.value) {
|
||||||
|
const query = searchQuery.value.toLowerCase()
|
||||||
|
items = items.filter(i => i.name.toLowerCase().includes(query))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply sort
|
||||||
|
if (sortBy.value === 'name') {
|
||||||
|
items = [...items].sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
} else if (sortBy.value === 'size') {
|
||||||
|
items = [...items].sort((a, b) => {
|
||||||
|
const sizeA = parseFloat(a.size)
|
||||||
|
const sizeB = parseFloat(b.size)
|
||||||
|
return sizeB - sizeA
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return items
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex h-full w-80 flex-col">
|
||||||
|
<!-- Panel Header -->
|
||||||
|
<div class="flex items-center justify-between border-b border-zinc-800 px-3 py-2">
|
||||||
|
<span class="text-xs font-semibold uppercase tracking-wide text-zinc-400">
|
||||||
|
ASSETS
|
||||||
|
</span>
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
icon="pi pi-window-maximize"
|
||||||
|
text
|
||||||
|
severity="secondary"
|
||||||
|
size="small"
|
||||||
|
class="!h-6 !w-6"
|
||||||
|
v-tooltip.top="'Expand'"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
icon="pi pi-times"
|
||||||
|
text
|
||||||
|
severity="secondary"
|
||||||
|
size="small"
|
||||||
|
class="!h-6 !w-6"
|
||||||
|
@click="emit('close')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search & Controls -->
|
||||||
|
<div class="border-b border-zinc-800 p-2">
|
||||||
|
<SidebarSearchBox
|
||||||
|
v-model="searchQuery"
|
||||||
|
placeholder="Search assets..."
|
||||||
|
:show-action="true"
|
||||||
|
action-tooltip="Upload Asset"
|
||||||
|
action-icon="pi pi-upload"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- View Controls -->
|
||||||
|
<div class="mt-2 flex items-center justify-between">
|
||||||
|
<SidebarViewToggle v-model="viewMode" />
|
||||||
|
|
||||||
|
<!-- Filter & Sort -->
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<!-- Filter Dropdown -->
|
||||||
|
<div class="relative">
|
||||||
|
<button
|
||||||
|
:class="[
|
||||||
|
'flex h-6 items-center gap-1 rounded px-2 text-[10px] transition-colors',
|
||||||
|
activeFilters.size > 0
|
||||||
|
? 'bg-blue-600/20 text-blue-400 hover:bg-blue-600/30'
|
||||||
|
: 'bg-zinc-800 text-zinc-400 hover:bg-zinc-700 hover:text-zinc-200'
|
||||||
|
]"
|
||||||
|
@click="showFilterMenu = !showFilterMenu"
|
||||||
|
>
|
||||||
|
<i class="pi pi-filter text-[10px]" />
|
||||||
|
<span>{{ filterLabel }}</span>
|
||||||
|
<i class="pi pi-chevron-down text-[8px]" />
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
v-if="showFilterMenu"
|
||||||
|
class="absolute left-0 top-full z-50 mt-1 w-36 rounded-lg border border-zinc-700 bg-black py-1 shadow-xl"
|
||||||
|
>
|
||||||
|
<!-- Clear all -->
|
||||||
|
<button
|
||||||
|
v-if="activeFilters.size > 0"
|
||||||
|
class="flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs text-zinc-500 transition-colors hover:bg-zinc-800 hover:text-zinc-300"
|
||||||
|
@click="clearFilters"
|
||||||
|
>
|
||||||
|
<i class="pi pi-times text-[10px]" />
|
||||||
|
Clear all
|
||||||
|
</button>
|
||||||
|
<div v-if="activeFilters.size > 0" class="mx-2 my-1 h-px bg-zinc-800" />
|
||||||
|
<!-- Filter options -->
|
||||||
|
<button
|
||||||
|
v-for="option in filterOptions"
|
||||||
|
:key="option.value"
|
||||||
|
class="flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs transition-colors hover:bg-zinc-800"
|
||||||
|
@click="toggleFilter(option.value)"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
:class="[
|
||||||
|
'flex h-3.5 w-3.5 items-center justify-center rounded border transition-colors',
|
||||||
|
activeFilters.has(option.value)
|
||||||
|
? 'border-blue-500 bg-blue-500'
|
||||||
|
: 'border-zinc-600 bg-transparent'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<i v-if="activeFilters.has(option.value)" class="pi pi-check text-[8px] text-white" />
|
||||||
|
</div>
|
||||||
|
<i :class="[option.icon, 'text-[10px]', option.color]" />
|
||||||
|
<span :class="activeFilters.has(option.value) ? 'text-zinc-200' : 'text-zinc-400'">
|
||||||
|
{{ option.label }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sort Dropdown -->
|
||||||
|
<div class="relative">
|
||||||
|
<button
|
||||||
|
class="flex h-6 items-center gap-1 rounded bg-zinc-800 px-2 text-[10px] text-zinc-400 transition-colors hover:bg-zinc-700 hover:text-zinc-200"
|
||||||
|
@click="showSortMenu = !showSortMenu"
|
||||||
|
>
|
||||||
|
<i class="pi pi-sort-alt text-[10px]" />
|
||||||
|
<span>{{ sortOptions.find(o => o.value === sortBy)?.label }}</span>
|
||||||
|
<i class="pi pi-chevron-down text-[8px]" />
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
v-if="showSortMenu"
|
||||||
|
class="absolute right-0 top-full z-50 mt-1 min-w-[100px] rounded-lg border border-zinc-700 bg-black py-1 shadow-xl"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-for="option in sortOptions"
|
||||||
|
:key="option.value"
|
||||||
|
class="flex w-full items-center px-3 py-1.5 text-left text-xs transition-colors"
|
||||||
|
:class="sortBy === option.value ? 'bg-zinc-800 text-zinc-200' : 'text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200'"
|
||||||
|
@click="setSort(option.value)"
|
||||||
|
>
|
||||||
|
{{ option.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div class="flex-1 overflow-y-auto p-2">
|
||||||
|
<!-- Empty State -->
|
||||||
|
<div
|
||||||
|
v-if="filteredAssets.length === 0"
|
||||||
|
class="flex flex-col items-center justify-center py-8 text-center"
|
||||||
|
>
|
||||||
|
<i class="pi pi-images mb-2 text-2xl text-zinc-600" />
|
||||||
|
<p class="text-xs text-zinc-500">No assets found</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- List View -->
|
||||||
|
<div v-else-if="viewMode === 'list'" class="select-none space-y-0.5">
|
||||||
|
<div
|
||||||
|
v-for="asset in filteredAssets"
|
||||||
|
:key="asset.id"
|
||||||
|
class="group flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 transition-colors hover:bg-zinc-800"
|
||||||
|
draggable="true"
|
||||||
|
>
|
||||||
|
<i :class="[asset.icon, 'text-xs', asset.iconClass]" />
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<span class="truncate text-xs text-zinc-300 group-hover:text-zinc-100">{{ asset.name }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 text-[10px] text-zinc-600">
|
||||||
|
<span v-if="asset.badge" :class="['rounded px-1 py-0.5 text-[9px]', asset.badgeClass]">
|
||||||
|
{{ asset.badge }}
|
||||||
|
</span>
|
||||||
|
<span>{{ asset.size }}</span>
|
||||||
|
<span v-if="asset.dimensions">{{ asset.dimensions }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<i class="pi pi-plus text-[10px] text-zinc-600 opacity-0 transition-opacity group-hover:opacity-100" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Grid View -->
|
||||||
|
<div v-else class="grid grid-cols-1 gap-2">
|
||||||
|
<LibraryGridCard
|
||||||
|
v-for="asset in filteredAssets"
|
||||||
|
:key="asset.id"
|
||||||
|
:title="asset.name"
|
||||||
|
:subtitle="`${asset.size}${asset.dimensions ? ' · ' + asset.dimensions : ''}`"
|
||||||
|
:thumbnail="asset.thumbnail"
|
||||||
|
:icon="asset.icon"
|
||||||
|
:icon-class="asset.iconClass"
|
||||||
|
:badge="asset.badge"
|
||||||
|
:badge-class="asset.badgeClass"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
div::-webkit-scrollbar {
|
||||||
|
width: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
div::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
div::-webkit-scrollbar-thumb {
|
||||||
|
background: #3f3f46;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
div::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #52525b;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,13 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import Button from 'primevue/button'
|
import Button from 'primevue/button'
|
||||||
import Avatar from 'primevue/avatar'
|
import { SidebarSearchBox, SidebarViewToggle, LibraryGridCard } from '@/components/common/sidebar'
|
||||||
import AvatarGroup from 'primevue/avatargroup'
|
|
||||||
import { SidebarSearchBox, SidebarViewToggle } from '@/components/common/sidebar'
|
|
||||||
import LibraryBrandKitSection from '@/components/v1/sidebar/LibraryBrandKitSection.vue'
|
|
||||||
import LibraryWorkflowsSection from '@/components/v1/sidebar/LibraryWorkflowsSection.vue'
|
|
||||||
import LibraryModelsSection from '@/components/v1/sidebar/LibraryModelsSection.vue'
|
|
||||||
import LibraryNodesSection from '@/components/v1/sidebar/LibraryNodesSection.vue'
|
|
||||||
import {
|
import {
|
||||||
TEAM_MEMBERS_DATA,
|
TEAM_MEMBERS_DATA,
|
||||||
BRAND_ASSETS_DATA,
|
BRAND_ASSETS_DATA,
|
||||||
@@ -16,7 +10,22 @@ import {
|
|||||||
NODE_PACKS_DATA,
|
NODE_PACKS_DATA,
|
||||||
} from '@/data/sidebarMockData'
|
} from '@/data/sidebarMockData'
|
||||||
|
|
||||||
const props = defineProps<{
|
interface LibraryItem {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description?: string
|
||||||
|
type: 'workflow' | 'model' | 'nodepack' | 'brand'
|
||||||
|
subtype?: string
|
||||||
|
thumbnail?: string
|
||||||
|
icon: string
|
||||||
|
iconClass: string
|
||||||
|
badge?: string
|
||||||
|
badgeClass?: string
|
||||||
|
starred?: boolean
|
||||||
|
meta?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
teamName?: string
|
teamName?: string
|
||||||
teamLogo?: string
|
teamLogo?: string
|
||||||
}>()
|
}>()
|
||||||
@@ -26,63 +35,168 @@ const emit = defineEmits<{
|
|||||||
}>()
|
}>()
|
||||||
|
|
||||||
const searchQuery = ref('')
|
const searchQuery = ref('')
|
||||||
const viewMode = ref<'list' | 'grid'>('list')
|
const viewMode = ref<'list' | 'grid'>('grid')
|
||||||
const sortBy = ref('name')
|
const sortBy = ref('name')
|
||||||
const showFilterMenu = ref(false)
|
const showFilterMenu = ref(false)
|
||||||
const showSortMenu = ref(false)
|
const showSortMenu = ref(false)
|
||||||
const activeFilter = ref('All')
|
const activeFilters = ref<Set<string>>(new Set())
|
||||||
|
|
||||||
const sortOptions = [
|
const sortOptions = [
|
||||||
{ label: 'Name', value: 'name' },
|
{ label: 'Name', value: 'name' },
|
||||||
{ label: 'Recently Added', value: 'recent' },
|
{ label: 'Recently Added', value: 'recent' },
|
||||||
{ label: 'Author', value: 'author' },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
const filterOptions = ['All', 'Brand Kit', 'Workflows', 'Models', 'Nodes']
|
const filterOptions = [
|
||||||
|
{ label: 'Workflows', value: 'workflow', icon: 'pi pi-sitemap', color: 'text-blue-400' },
|
||||||
|
{ label: 'Models', value: 'model', icon: 'pi pi-box', color: 'text-green-400' },
|
||||||
|
{ label: 'Nodepacks', value: 'nodepack', icon: 'pi pi-code', color: 'text-purple-400' },
|
||||||
|
{ label: 'Brand Kit', value: 'brand', icon: 'pi pi-palette', color: 'text-amber-400' },
|
||||||
|
]
|
||||||
|
|
||||||
function setSort(value: string): void {
|
function setSort(value: string): void {
|
||||||
sortBy.value = value
|
sortBy.value = value
|
||||||
showSortMenu.value = false
|
showSortMenu.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
function setFilter(value: string): void {
|
function toggleFilter(value: string): void {
|
||||||
activeFilter.value = value
|
const newFilters = new Set(activeFilters.value)
|
||||||
showFilterMenu.value = false
|
if (newFilters.has(value)) {
|
||||||
|
newFilters.delete(value)
|
||||||
|
} else {
|
||||||
|
newFilters.add(value)
|
||||||
|
}
|
||||||
|
activeFilters.value = newFilters
|
||||||
}
|
}
|
||||||
|
|
||||||
// Current team info
|
function clearFilters(): void {
|
||||||
const currentTeam = computed(() => ({
|
activeFilters.value = new Set()
|
||||||
name: props.teamName || 'Netflix',
|
}
|
||||||
logo: props.teamLogo,
|
|
||||||
plan: 'Enterprise',
|
|
||||||
members: 24,
|
|
||||||
}))
|
|
||||||
|
|
||||||
// Collapsible sections
|
const filterLabel = computed(() => {
|
||||||
const sections = ref({
|
if (activeFilters.value.size === 0) return 'All'
|
||||||
brand: true,
|
if (activeFilters.value.size === 1) {
|
||||||
workflows: true,
|
const value = [...activeFilters.value][0]
|
||||||
models: false,
|
return filterOptions.find(o => o.value === value)?.label || 'All'
|
||||||
nodes: false,
|
}
|
||||||
|
return `${activeFilters.value.size} selected`
|
||||||
})
|
})
|
||||||
|
|
||||||
function toggleSection(sectionId: keyof typeof sections.value): void {
|
// Combine all items into a unified list
|
||||||
sections.value[sectionId] = !sections.value[sectionId]
|
const allItems = computed<LibraryItem[]>(() => {
|
||||||
}
|
const items: LibraryItem[] = []
|
||||||
|
|
||||||
// Data
|
// Add workflows
|
||||||
const teamMembers = TEAM_MEMBERS_DATA
|
const workflows = createSharedWorkflowsData(TEAM_MEMBERS_DATA)
|
||||||
const brandAssets = BRAND_ASSETS_DATA
|
workflows.forEach(w => {
|
||||||
const sharedWorkflows = computed(() => createSharedWorkflowsData(teamMembers))
|
items.push({
|
||||||
const teamModels = computed(() => createTeamModelsData(teamMembers))
|
id: `workflow-${w.id}`,
|
||||||
const nodePacks = NODE_PACKS_DATA
|
name: w.name,
|
||||||
|
description: w.description,
|
||||||
|
type: 'workflow',
|
||||||
|
thumbnail: w.thumbnail,
|
||||||
|
icon: 'pi pi-sitemap',
|
||||||
|
iconClass: 'text-blue-400',
|
||||||
|
badge: `${w.nodes} nodes`,
|
||||||
|
badgeClass: 'bg-blue-500/30 text-blue-300',
|
||||||
|
starred: w.starred,
|
||||||
|
meta: w.updatedAt,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
const filteredWorkflows = computed(() => {
|
// Add models
|
||||||
if (!searchQuery.value) return sharedWorkflows.value
|
const models = createTeamModelsData(TEAM_MEMBERS_DATA)
|
||||||
const query = searchQuery.value.toLowerCase()
|
models.forEach(m => {
|
||||||
return sharedWorkflows.value.filter(
|
const typeLabels: Record<string, string> = {
|
||||||
w => w.name.toLowerCase().includes(query) || w.description.toLowerCase().includes(query)
|
checkpoint: 'Checkpoint',
|
||||||
)
|
lora: 'LoRA',
|
||||||
|
embedding: 'Embedding',
|
||||||
|
controlnet: 'ControlNet',
|
||||||
|
}
|
||||||
|
const typeColors: Record<string, string> = {
|
||||||
|
checkpoint: 'bg-purple-500/30 text-purple-300',
|
||||||
|
lora: 'bg-green-500/30 text-green-300',
|
||||||
|
embedding: 'bg-amber-500/30 text-amber-300',
|
||||||
|
controlnet: 'bg-cyan-500/30 text-cyan-300',
|
||||||
|
}
|
||||||
|
items.push({
|
||||||
|
id: `model-${m.id}`,
|
||||||
|
name: m.name,
|
||||||
|
description: m.description,
|
||||||
|
type: 'model',
|
||||||
|
subtype: m.type,
|
||||||
|
thumbnail: m.thumbnail,
|
||||||
|
icon: 'pi pi-box',
|
||||||
|
iconClass: 'text-green-400',
|
||||||
|
badge: typeLabels[m.type] || m.type,
|
||||||
|
badgeClass: typeColors[m.type] || 'bg-zinc-700 text-zinc-400',
|
||||||
|
meta: m.size,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Add nodepacks
|
||||||
|
NODE_PACKS_DATA.forEach(p => {
|
||||||
|
items.push({
|
||||||
|
id: `nodepack-${p.id}`,
|
||||||
|
name: p.name,
|
||||||
|
description: p.description,
|
||||||
|
type: 'nodepack',
|
||||||
|
thumbnail: p.thumbnail,
|
||||||
|
icon: 'pi pi-code',
|
||||||
|
iconClass: 'text-purple-400',
|
||||||
|
badge: p.installed ? 'Installed' : `${p.nodes} nodes`,
|
||||||
|
badgeClass: p.installed ? 'bg-green-500/30 text-green-300' : 'bg-zinc-700 text-zinc-400',
|
||||||
|
meta: `v${p.version}`,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Add brand assets (excluding colors)
|
||||||
|
BRAND_ASSETS_DATA.filter(a => a.type !== 'color').forEach(a => {
|
||||||
|
const typeLabels: Record<string, string> = {
|
||||||
|
logo: 'Logo',
|
||||||
|
font: 'Font',
|
||||||
|
template: 'Template',
|
||||||
|
guideline: 'Guide',
|
||||||
|
}
|
||||||
|
items.push({
|
||||||
|
id: `brand-${a.id}`,
|
||||||
|
name: a.name,
|
||||||
|
description: a.description,
|
||||||
|
type: 'brand',
|
||||||
|
subtype: a.type,
|
||||||
|
icon: a.type === 'logo' ? 'pi pi-image' : a.type === 'font' ? 'pi pi-align-left' : a.type === 'template' ? 'pi pi-clone' : 'pi pi-book',
|
||||||
|
iconClass: 'text-amber-400',
|
||||||
|
badge: typeLabels[a.type] || a.type,
|
||||||
|
badgeClass: 'bg-amber-500/30 text-amber-300',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return items
|
||||||
|
})
|
||||||
|
|
||||||
|
// Filter and search
|
||||||
|
const filteredItems = computed(() => {
|
||||||
|
let items = allItems.value
|
||||||
|
|
||||||
|
// Apply type filters (multi-select)
|
||||||
|
if (activeFilters.value.size > 0) {
|
||||||
|
items = items.filter(i => activeFilters.value.has(i.type))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply search
|
||||||
|
if (searchQuery.value) {
|
||||||
|
const query = searchQuery.value.toLowerCase()
|
||||||
|
items = items.filter(i =>
|
||||||
|
i.name.toLowerCase().includes(query) ||
|
||||||
|
i.description?.toLowerCase().includes(query)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply sort
|
||||||
|
if (sortBy.value === 'name') {
|
||||||
|
items = [...items].sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
}
|
||||||
|
|
||||||
|
return items
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -93,14 +207,24 @@ const filteredWorkflows = computed(() => {
|
|||||||
<span class="text-xs font-semibold uppercase tracking-wide text-zinc-400">
|
<span class="text-xs font-semibold uppercase tracking-wide text-zinc-400">
|
||||||
TEAM LIBRARY
|
TEAM LIBRARY
|
||||||
</span>
|
</span>
|
||||||
<Button
|
<div class="flex items-center gap-1">
|
||||||
icon="pi pi-times"
|
<Button
|
||||||
text
|
icon="pi pi-window-maximize"
|
||||||
severity="secondary"
|
text
|
||||||
size="small"
|
severity="secondary"
|
||||||
class="!h-6 !w-6"
|
size="small"
|
||||||
@click="emit('close')"
|
class="!h-6 !w-6"
|
||||||
/>
|
v-tooltip.top="'Expand'"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
icon="pi pi-times"
|
||||||
|
text
|
||||||
|
severity="secondary"
|
||||||
|
size="small"
|
||||||
|
class="!h-6 !w-6"
|
||||||
|
@click="emit('close')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Search & Controls -->
|
<!-- Search & Controls -->
|
||||||
@@ -122,25 +246,53 @@ const filteredWorkflows = computed(() => {
|
|||||||
<!-- Filter Dropdown -->
|
<!-- Filter Dropdown -->
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<button
|
<button
|
||||||
class="flex h-6 items-center gap-1 rounded bg-zinc-800 px-2 text-[10px] text-zinc-400 transition-colors hover:bg-zinc-700 hover:text-zinc-200"
|
:class="[
|
||||||
|
'flex h-6 items-center gap-1 rounded px-2 text-[10px] transition-colors',
|
||||||
|
activeFilters.size > 0
|
||||||
|
? 'bg-blue-600/20 text-blue-400 hover:bg-blue-600/30'
|
||||||
|
: 'bg-zinc-800 text-zinc-400 hover:bg-zinc-700 hover:text-zinc-200'
|
||||||
|
]"
|
||||||
@click="showFilterMenu = !showFilterMenu"
|
@click="showFilterMenu = !showFilterMenu"
|
||||||
>
|
>
|
||||||
<i class="pi pi-filter text-[10px]" />
|
<i class="pi pi-filter text-[10px]" />
|
||||||
<span>{{ activeFilter }}</span>
|
<span>{{ filterLabel }}</span>
|
||||||
<i class="pi pi-chevron-down text-[8px]" />
|
<i class="pi pi-chevron-down text-[8px]" />
|
||||||
</button>
|
</button>
|
||||||
<div
|
<div
|
||||||
v-if="showFilterMenu"
|
v-if="showFilterMenu"
|
||||||
class="absolute left-0 top-full z-50 mt-1 min-w-[120px] rounded-lg border border-zinc-700 bg-black py-1 shadow-xl"
|
class="absolute left-0 top-full z-50 mt-1 w-40 rounded-lg border border-zinc-700 bg-black py-1 shadow-xl"
|
||||||
>
|
>
|
||||||
|
<!-- Clear all -->
|
||||||
|
<button
|
||||||
|
v-if="activeFilters.size > 0"
|
||||||
|
class="flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs text-zinc-500 transition-colors hover:bg-zinc-800 hover:text-zinc-300"
|
||||||
|
@click="clearFilters"
|
||||||
|
>
|
||||||
|
<i class="pi pi-times text-[10px]" />
|
||||||
|
Clear all
|
||||||
|
</button>
|
||||||
|
<div v-if="activeFilters.size > 0" class="mx-2 my-1 h-px bg-zinc-800" />
|
||||||
|
<!-- Filter options -->
|
||||||
<button
|
<button
|
||||||
v-for="option in filterOptions"
|
v-for="option in filterOptions"
|
||||||
:key="option"
|
:key="option.value"
|
||||||
class="flex w-full items-center px-3 py-1.5 text-left text-xs transition-colors"
|
class="flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs transition-colors hover:bg-zinc-800"
|
||||||
:class="activeFilter === option ? 'bg-zinc-800 text-zinc-200' : 'text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200'"
|
@click="toggleFilter(option.value)"
|
||||||
@click="setFilter(option)"
|
|
||||||
>
|
>
|
||||||
{{ option }}
|
<div
|
||||||
|
:class="[
|
||||||
|
'flex h-3.5 w-3.5 items-center justify-center rounded border transition-colors',
|
||||||
|
activeFilters.has(option.value)
|
||||||
|
? 'border-blue-500 bg-blue-500'
|
||||||
|
: 'border-zinc-600 bg-transparent'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<i v-if="activeFilters.has(option.value)" class="pi pi-check text-[8px] text-white" />
|
||||||
|
</div>
|
||||||
|
<i :class="[option.icon, 'text-[10px]', option.color]" />
|
||||||
|
<span :class="activeFilters.has(option.value) ? 'text-zinc-200' : 'text-zinc-400'">
|
||||||
|
{{ option.label }}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -176,75 +328,54 @@ const filteredWorkflows = computed(() => {
|
|||||||
|
|
||||||
<!-- Content -->
|
<!-- Content -->
|
||||||
<div class="flex-1 overflow-y-auto p-2">
|
<div class="flex-1 overflow-y-auto p-2">
|
||||||
<!-- Team Header Card -->
|
<!-- Empty State -->
|
||||||
<div class="mb-3 rounded-lg border border-zinc-800 bg-black p-2.5">
|
<div
|
||||||
<div class="flex items-center gap-3">
|
v-if="filteredItems.length === 0"
|
||||||
<div
|
class="flex flex-col items-center justify-center py-8 text-center"
|
||||||
class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg text-lg font-bold"
|
>
|
||||||
:style="{ backgroundColor: '#E50914' }"
|
<i class="pi pi-inbox mb-2 text-2xl text-zinc-600" />
|
||||||
>
|
<p class="text-xs text-zinc-500">No items found</p>
|
||||||
<span class="text-white">N</span>
|
|
||||||
</div>
|
|
||||||
<div class="min-w-0 flex-1">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span class="truncate text-sm font-semibold text-zinc-100">{{ currentTeam.name }}</span>
|
|
||||||
<span class="rounded bg-blue-500/20 px-1.5 py-0.5 text-[10px] font-medium text-blue-400">
|
|
||||||
{{ currentTeam.plan }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="mt-0.5 flex items-center gap-2">
|
|
||||||
<AvatarGroup class="!gap-0">
|
|
||||||
<Avatar
|
|
||||||
v-for="member in teamMembers.slice(0, 3)"
|
|
||||||
:key="member.name"
|
|
||||||
:label="member.initials"
|
|
||||||
shape="circle"
|
|
||||||
size="small"
|
|
||||||
class="!h-5 !w-5 !border !border-zinc-900 !bg-zinc-700 !text-[9px] !text-zinc-300"
|
|
||||||
/>
|
|
||||||
</AvatarGroup>
|
|
||||||
<span class="text-[10px] text-zinc-500">
|
|
||||||
{{ currentTeam.members }} members
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- List View -->
|
<!-- List View -->
|
||||||
<div v-if="viewMode === 'list'" class="select-none space-y-0.5">
|
<div v-else-if="viewMode === 'list'" class="select-none space-y-0.5">
|
||||||
<LibraryBrandKitSection
|
<div
|
||||||
:assets="brandAssets"
|
v-for="item in filteredItems"
|
||||||
:view-mode="viewMode"
|
:key="item.id"
|
||||||
:expanded="sections.brand"
|
class="group flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 transition-colors hover:bg-zinc-800"
|
||||||
@toggle="toggleSection('brand')"
|
draggable="true"
|
||||||
/>
|
>
|
||||||
<LibraryWorkflowsSection
|
<i :class="[item.icon, 'text-xs', item.iconClass]" />
|
||||||
:workflows="filteredWorkflows"
|
<div class="min-w-0 flex-1">
|
||||||
:view-mode="viewMode"
|
<div class="flex items-center gap-1.5">
|
||||||
:expanded="sections.workflows"
|
<span class="truncate text-xs text-zinc-300 group-hover:text-zinc-100">{{ item.name }}</span>
|
||||||
@toggle="toggleSection('workflows')"
|
<i v-if="item.starred" class="pi pi-star-fill text-[8px] text-amber-400" />
|
||||||
/>
|
</div>
|
||||||
<LibraryModelsSection
|
<div class="flex items-center gap-2 text-[10px] text-zinc-600">
|
||||||
:models="teamModels"
|
<span v-if="item.badge" :class="['rounded px-1 py-0.5 text-[9px]', item.badgeClass]">
|
||||||
:view-mode="viewMode"
|
{{ item.badge }}
|
||||||
:expanded="sections.models"
|
</span>
|
||||||
@toggle="toggleSection('models')"
|
<span v-if="item.meta">{{ item.meta }}</span>
|
||||||
/>
|
</div>
|
||||||
<LibraryNodesSection
|
</div>
|
||||||
:packs="nodePacks"
|
<i class="pi pi-plus text-[10px] text-zinc-600 opacity-0 transition-opacity group-hover:opacity-100" />
|
||||||
:view-mode="viewMode"
|
</div>
|
||||||
:expanded="sections.nodes"
|
|
||||||
@toggle="toggleSection('nodes')"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Grid View -->
|
<!-- Grid View -->
|
||||||
<div v-else class="space-y-3">
|
<div v-else class="grid grid-cols-1 gap-2">
|
||||||
<LibraryBrandKitSection :assets="brandAssets" :view-mode="viewMode" :expanded="true" />
|
<LibraryGridCard
|
||||||
<LibraryWorkflowsSection :workflows="filteredWorkflows" :view-mode="viewMode" :expanded="true" />
|
v-for="item in filteredItems"
|
||||||
<LibraryModelsSection :models="teamModels" :view-mode="viewMode" :expanded="true" />
|
:key="item.id"
|
||||||
<LibraryNodesSection :packs="nodePacks" :view-mode="viewMode" :expanded="true" />
|
:title="item.name"
|
||||||
|
:subtitle="item.meta"
|
||||||
|
:thumbnail="item.thumbnail"
|
||||||
|
:icon="item.icon"
|
||||||
|
:icon-class="item.iconClass"
|
||||||
|
:badge="item.badge"
|
||||||
|
:badge-class="item.badgeClass"
|
||||||
|
:starred="item.starred"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
326
ComfyUI_vibe/src/components/v2/canvas/TemplatesSidebar.vue
Normal file
326
ComfyUI_vibe/src/components/v2/canvas/TemplatesSidebar.vue
Normal file
@@ -0,0 +1,326 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import Button from 'primevue/button'
|
||||||
|
import { SidebarSearchBox, SidebarViewToggle, LibraryGridCard } from '@/components/common/sidebar'
|
||||||
|
import { TEMPLATE_CATEGORIES_DATA } from '@/data/sidebarMockData'
|
||||||
|
|
||||||
|
interface TemplateItem {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
category: string
|
||||||
|
categoryIcon: string
|
||||||
|
nodes: number
|
||||||
|
thumbnail?: string
|
||||||
|
badge?: string
|
||||||
|
badgeClass?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
close: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const searchQuery = ref('')
|
||||||
|
const viewMode = ref<'list' | 'grid'>('grid')
|
||||||
|
const sortBy = ref('name')
|
||||||
|
const showFilterMenu = ref(false)
|
||||||
|
const showSortMenu = ref(false)
|
||||||
|
const activeFilters = ref<Set<string>>(new Set())
|
||||||
|
|
||||||
|
const sortOptions = [
|
||||||
|
{ label: 'Name', value: 'name' },
|
||||||
|
{ label: 'Node Count', value: 'nodes' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const filterOptions = [
|
||||||
|
{ label: 'Official', value: 'official', icon: 'pi pi-verified', color: 'text-blue-400' },
|
||||||
|
{ label: 'SDXL', value: 'sdxl', icon: 'pi pi-star', color: 'text-purple-400' },
|
||||||
|
{ label: 'ControlNet', value: 'controlnet', icon: 'pi pi-sliders-v', color: 'text-amber-400' },
|
||||||
|
{ label: 'Video', value: 'video', icon: 'pi pi-video', color: 'text-green-400' },
|
||||||
|
{ label: 'Community', value: 'community', icon: 'pi pi-users', color: 'text-cyan-400' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function setSort(value: string): void {
|
||||||
|
sortBy.value = value
|
||||||
|
showSortMenu.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleFilter(value: string): void {
|
||||||
|
const newFilters = new Set(activeFilters.value)
|
||||||
|
if (newFilters.has(value)) {
|
||||||
|
newFilters.delete(value)
|
||||||
|
} else {
|
||||||
|
newFilters.add(value)
|
||||||
|
}
|
||||||
|
activeFilters.value = newFilters
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearFilters(): void {
|
||||||
|
activeFilters.value = new Set()
|
||||||
|
}
|
||||||
|
|
||||||
|
const filterLabel = computed(() => {
|
||||||
|
if (activeFilters.value.size === 0) return 'All'
|
||||||
|
if (activeFilters.value.size === 1) {
|
||||||
|
const value = [...activeFilters.value][0]
|
||||||
|
return filterOptions.find(o => o.value === value)?.label || 'All'
|
||||||
|
}
|
||||||
|
return `${activeFilters.value.size} selected`
|
||||||
|
})
|
||||||
|
|
||||||
|
// Combine all templates into a flat list
|
||||||
|
const allTemplates = computed<TemplateItem[]>(() => {
|
||||||
|
const items: TemplateItem[] = []
|
||||||
|
|
||||||
|
const categoryColors: Record<string, { badge: string; badgeClass: string }> = {
|
||||||
|
official: { badge: 'Official', badgeClass: 'bg-blue-500/30 text-blue-300' },
|
||||||
|
sdxl: { badge: 'SDXL', badgeClass: 'bg-purple-500/30 text-purple-300' },
|
||||||
|
controlnet: { badge: 'ControlNet', badgeClass: 'bg-amber-500/30 text-amber-300' },
|
||||||
|
video: { badge: 'Video', badgeClass: 'bg-green-500/30 text-green-300' },
|
||||||
|
community: { badge: 'Community', badgeClass: 'bg-cyan-500/30 text-cyan-300' },
|
||||||
|
}
|
||||||
|
|
||||||
|
TEMPLATE_CATEGORIES_DATA.forEach(category => {
|
||||||
|
category.templates.forEach(template => {
|
||||||
|
const colors = categoryColors[category.id] || { badge: category.label, badgeClass: 'bg-zinc-700 text-zinc-400' }
|
||||||
|
items.push({
|
||||||
|
id: `${category.id}-${template.name}`,
|
||||||
|
name: template.display,
|
||||||
|
description: template.description,
|
||||||
|
category: category.id,
|
||||||
|
categoryIcon: category.icon,
|
||||||
|
nodes: template.nodes,
|
||||||
|
badge: colors.badge,
|
||||||
|
badgeClass: colors.badgeClass,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return items
|
||||||
|
})
|
||||||
|
|
||||||
|
// Filter and search
|
||||||
|
const filteredTemplates = computed(() => {
|
||||||
|
let items = allTemplates.value
|
||||||
|
|
||||||
|
// Apply category filters (multi-select)
|
||||||
|
if (activeFilters.value.size > 0) {
|
||||||
|
items = items.filter(i => activeFilters.value.has(i.category))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply search
|
||||||
|
if (searchQuery.value) {
|
||||||
|
const query = searchQuery.value.toLowerCase()
|
||||||
|
items = items.filter(i =>
|
||||||
|
i.name.toLowerCase().includes(query) ||
|
||||||
|
i.description.toLowerCase().includes(query)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply sort
|
||||||
|
if (sortBy.value === 'name') {
|
||||||
|
items = [...items].sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
} else if (sortBy.value === 'nodes') {
|
||||||
|
items = [...items].sort((a, b) => b.nodes - a.nodes)
|
||||||
|
}
|
||||||
|
|
||||||
|
return items
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex h-full w-80 flex-col">
|
||||||
|
<!-- Panel Header -->
|
||||||
|
<div class="flex items-center justify-between border-b border-zinc-800 px-3 py-2">
|
||||||
|
<span class="text-xs font-semibold uppercase tracking-wide text-zinc-400">
|
||||||
|
TEMPLATES
|
||||||
|
</span>
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
icon="pi pi-window-maximize"
|
||||||
|
text
|
||||||
|
severity="secondary"
|
||||||
|
size="small"
|
||||||
|
class="!h-6 !w-6"
|
||||||
|
v-tooltip.top="'Expand'"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
icon="pi pi-times"
|
||||||
|
text
|
||||||
|
severity="secondary"
|
||||||
|
size="small"
|
||||||
|
class="!h-6 !w-6"
|
||||||
|
@click="emit('close')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search & Controls -->
|
||||||
|
<div class="border-b border-zinc-800 p-2">
|
||||||
|
<SidebarSearchBox
|
||||||
|
v-model="searchQuery"
|
||||||
|
placeholder="Search templates..."
|
||||||
|
:show-action="true"
|
||||||
|
action-tooltip="Browse Templates"
|
||||||
|
action-icon="pi pi-external-link"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- View Controls -->
|
||||||
|
<div class="mt-2 flex items-center justify-between">
|
||||||
|
<SidebarViewToggle v-model="viewMode" />
|
||||||
|
|
||||||
|
<!-- Filter & Sort -->
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<!-- Filter Dropdown -->
|
||||||
|
<div class="relative">
|
||||||
|
<button
|
||||||
|
:class="[
|
||||||
|
'flex h-6 items-center gap-1 rounded px-2 text-[10px] transition-colors',
|
||||||
|
activeFilters.size > 0
|
||||||
|
? 'bg-blue-600/20 text-blue-400 hover:bg-blue-600/30'
|
||||||
|
: 'bg-zinc-800 text-zinc-400 hover:bg-zinc-700 hover:text-zinc-200'
|
||||||
|
]"
|
||||||
|
@click="showFilterMenu = !showFilterMenu"
|
||||||
|
>
|
||||||
|
<i class="pi pi-filter text-[10px]" />
|
||||||
|
<span>{{ filterLabel }}</span>
|
||||||
|
<i class="pi pi-chevron-down text-[8px]" />
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
v-if="showFilterMenu"
|
||||||
|
class="absolute left-0 top-full z-50 mt-1 w-36 rounded-lg border border-zinc-700 bg-black py-1 shadow-xl"
|
||||||
|
>
|
||||||
|
<!-- Clear all -->
|
||||||
|
<button
|
||||||
|
v-if="activeFilters.size > 0"
|
||||||
|
class="flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs text-zinc-500 transition-colors hover:bg-zinc-800 hover:text-zinc-300"
|
||||||
|
@click="clearFilters"
|
||||||
|
>
|
||||||
|
<i class="pi pi-times text-[10px]" />
|
||||||
|
Clear all
|
||||||
|
</button>
|
||||||
|
<div v-if="activeFilters.size > 0" class="mx-2 my-1 h-px bg-zinc-800" />
|
||||||
|
<!-- Filter options -->
|
||||||
|
<button
|
||||||
|
v-for="option in filterOptions"
|
||||||
|
:key="option.value"
|
||||||
|
class="flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs transition-colors hover:bg-zinc-800"
|
||||||
|
@click="toggleFilter(option.value)"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
:class="[
|
||||||
|
'flex h-3.5 w-3.5 items-center justify-center rounded border transition-colors',
|
||||||
|
activeFilters.has(option.value)
|
||||||
|
? 'border-blue-500 bg-blue-500'
|
||||||
|
: 'border-zinc-600 bg-transparent'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<i v-if="activeFilters.has(option.value)" class="pi pi-check text-[8px] text-white" />
|
||||||
|
</div>
|
||||||
|
<i :class="[option.icon, 'text-[10px]', option.color]" />
|
||||||
|
<span :class="activeFilters.has(option.value) ? 'text-zinc-200' : 'text-zinc-400'">
|
||||||
|
{{ option.label }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sort Dropdown -->
|
||||||
|
<div class="relative">
|
||||||
|
<button
|
||||||
|
class="flex h-6 items-center gap-1 rounded bg-zinc-800 px-2 text-[10px] text-zinc-400 transition-colors hover:bg-zinc-700 hover:text-zinc-200"
|
||||||
|
@click="showSortMenu = !showSortMenu"
|
||||||
|
>
|
||||||
|
<i class="pi pi-sort-alt text-[10px]" />
|
||||||
|
<span>{{ sortOptions.find(o => o.value === sortBy)?.label }}</span>
|
||||||
|
<i class="pi pi-chevron-down text-[8px]" />
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
v-if="showSortMenu"
|
||||||
|
class="absolute right-0 top-full z-50 mt-1 min-w-[100px] rounded-lg border border-zinc-700 bg-black py-1 shadow-xl"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-for="option in sortOptions"
|
||||||
|
:key="option.value"
|
||||||
|
class="flex w-full items-center px-3 py-1.5 text-left text-xs transition-colors"
|
||||||
|
:class="sortBy === option.value ? 'bg-zinc-800 text-zinc-200' : 'text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200'"
|
||||||
|
@click="setSort(option.value)"
|
||||||
|
>
|
||||||
|
{{ option.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div class="flex-1 overflow-y-auto p-2">
|
||||||
|
<!-- Empty State -->
|
||||||
|
<div
|
||||||
|
v-if="filteredTemplates.length === 0"
|
||||||
|
class="flex flex-col items-center justify-center py-8 text-center"
|
||||||
|
>
|
||||||
|
<i class="pi pi-copy mb-2 text-2xl text-zinc-600" />
|
||||||
|
<p class="text-xs text-zinc-500">No templates found</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- List View -->
|
||||||
|
<div v-else-if="viewMode === 'list'" class="select-none space-y-0.5">
|
||||||
|
<div
|
||||||
|
v-for="template in filteredTemplates"
|
||||||
|
:key="template.id"
|
||||||
|
class="group flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 transition-colors hover:bg-zinc-800"
|
||||||
|
draggable="true"
|
||||||
|
>
|
||||||
|
<i :class="[template.categoryIcon, 'text-xs text-zinc-500']" />
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<span class="truncate text-xs text-zinc-300 group-hover:text-zinc-100">{{ template.name }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 text-[10px] text-zinc-600">
|
||||||
|
<span v-if="template.badge" :class="['rounded px-1 py-0.5 text-[9px]', template.badgeClass]">
|
||||||
|
{{ template.badge }}
|
||||||
|
</span>
|
||||||
|
<span>{{ template.nodes }} nodes</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<i class="pi pi-plus text-[10px] text-zinc-600 opacity-0 transition-opacity group-hover:opacity-100" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Grid View -->
|
||||||
|
<div v-else class="grid grid-cols-1 gap-2">
|
||||||
|
<LibraryGridCard
|
||||||
|
v-for="template in filteredTemplates"
|
||||||
|
:key="template.id"
|
||||||
|
:title="template.name"
|
||||||
|
:subtitle="`${template.nodes} nodes · ${template.description}`"
|
||||||
|
:icon="template.categoryIcon"
|
||||||
|
icon-class="text-zinc-400"
|
||||||
|
:badge="template.badge"
|
||||||
|
:badge-class="template.badgeClass"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
div::-webkit-scrollbar {
|
||||||
|
width: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
div::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
div::-webkit-scrollbar-thumb {
|
||||||
|
background: #3f3f46;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
div::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #52525b;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -45,7 +45,8 @@ const userMenuGroups = computed<MenuGroup[]>(() => [
|
|||||||
label: 'Overview',
|
label: 'Overview',
|
||||||
items: [
|
items: [
|
||||||
{ label: 'Dashboard', icon: 'pi pi-home', route: `/${props.workspaceId}` },
|
{ label: 'Dashboard', icon: 'pi pi-home', route: `/${props.workspaceId}` },
|
||||||
{ label: 'Recents', icon: 'pi pi-clock', route: `/${props.workspaceId}/recents` }
|
{ label: 'Recents', icon: 'pi pi-clock', route: `/${props.workspaceId}/recents` },
|
||||||
|
{ label: 'Library Hub', icon: 'pi pi-database', route: `/${props.workspaceId}/library` }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -81,7 +82,8 @@ const teamMenuGroups = computed<MenuGroup[]>(() => [
|
|||||||
label: 'Overview',
|
label: 'Overview',
|
||||||
items: [
|
items: [
|
||||||
{ label: 'Dashboard', icon: 'pi pi-home', route: `/${props.workspaceId}` },
|
{ label: 'Dashboard', icon: 'pi pi-home', route: `/${props.workspaceId}` },
|
||||||
{ label: 'Recents', icon: 'pi pi-clock', route: `/${props.workspaceId}/recents` }
|
{ label: 'Recents', icon: 'pi pi-clock', route: `/${props.workspaceId}/recents` },
|
||||||
|
{ label: 'Library Hub', icon: 'pi pi-database', route: `/${props.workspaceId}/library` }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -56,10 +56,8 @@ const outlineClass = computed(() => {
|
|||||||
return 'outline outline-2 outline-blue-500/50'
|
return 'outline outline-2 outline-blue-500/50'
|
||||||
})
|
})
|
||||||
|
|
||||||
const headerStyle = computed(() => {
|
// Header color is no longer used in the new compact design
|
||||||
const color = props.data.headerColor || props.data.definition.headerColor
|
const headerStyle = computed(() => ({}))
|
||||||
return color ? { backgroundColor: color } : {}
|
|
||||||
})
|
|
||||||
|
|
||||||
const bodyStyle = computed(() => {
|
const bodyStyle = computed(() => {
|
||||||
const color = props.data.bodyColor || props.data.definition.bodyColor
|
const color = props.data.bodyColor || props.data.definition.bodyColor
|
||||||
@@ -95,9 +93,9 @@ const hasInputs = computed(() => props.data.definition.inputs.length > 0)
|
|||||||
const hasOutputs = computed(() => props.data.definition.outputs.length > 0)
|
const hasOutputs = computed(() => props.data.definition.outputs.length > 0)
|
||||||
|
|
||||||
// Handle positioning constants
|
// Handle positioning constants
|
||||||
const HEADER_HEIGHT = 36
|
const HEADER_HEIGHT = 28
|
||||||
const SLOT_HEIGHT = 24
|
const SLOT_HEIGHT = 22
|
||||||
const PROGRESS_BAR_HEIGHT = 4
|
const PROGRESS_BAR_HEIGHT = 3
|
||||||
|
|
||||||
const handleTopOffset = computed(() => {
|
const handleTopOffset = computed(() => {
|
||||||
const hasProgressBar = isExecuting.value && props.data.progress !== undefined
|
const hasProgressBar = isExecuting.value && props.data.progress !== undefined
|
||||||
@@ -131,13 +129,12 @@ function getHandleTop(index: number): string {
|
|||||||
<div
|
<div
|
||||||
v-else
|
v-else
|
||||||
:class="[
|
:class="[
|
||||||
'flow-node relative min-w-[225px] rounded-lg',
|
'flow-node relative min-w-[280px] rounded-lg',
|
||||||
'border transition-all duration-150',
|
'border transition-all duration-150',
|
||||||
'bg-zinc-900',
|
|
||||||
borderClass,
|
borderClass,
|
||||||
outlineClass,
|
outlineClass,
|
||||||
{
|
{
|
||||||
'ring-4 ring-blue-500/30': selected && !hasError && !isExecuting,
|
'ring-2 ring-blue-500/30': selected && !hasError && !isExecuting,
|
||||||
}
|
}
|
||||||
]"
|
]"
|
||||||
:style="[bodyStyle, { opacity: nodeOpacity }]"
|
:style="[bodyStyle, { opacity: nodeOpacity }]"
|
||||||
@@ -154,14 +151,13 @@ function getHandleTop(index: number): string {
|
|||||||
:pinned="data.flags.pinned"
|
:pinned="data.flags.pinned"
|
||||||
:badges="data.badges"
|
:badges="data.badges"
|
||||||
:state="data.state"
|
:state="data.state"
|
||||||
:style="headerStyle"
|
|
||||||
@collapse="handleCollapse"
|
@collapse="handleCollapse"
|
||||||
@update:title="handleTitleUpdate"
|
@update:title="handleTitleUpdate"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="isExecuting && data.progress !== undefined"
|
v-if="isExecuting && data.progress !== undefined"
|
||||||
class="relative h-1 mx-4"
|
class="relative h-[3px] mx-2"
|
||||||
:class="isCollapsed ? 'absolute bottom-0 left-0 right-0 mx-0' : ''"
|
:class="isCollapsed ? 'absolute bottom-0 left-0 right-0 mx-0' : ''"
|
||||||
>
|
>
|
||||||
<div class="absolute inset-0 bg-blue-500/30 rounded-full" />
|
<div class="absolute inset-0 bg-blue-500/30 rounded-full" />
|
||||||
@@ -175,19 +171,19 @@ function getHandleTop(index: number): string {
|
|||||||
<div class="flex items-center justify-between px-2 py-1">
|
<div class="flex items-center justify-between px-2 py-1">
|
||||||
<div
|
<div
|
||||||
v-if="hasInputs"
|
v-if="hasInputs"
|
||||||
class="h-3 w-3 rounded-full bg-zinc-600 border-2 border-zinc-800"
|
class="h-2.5 w-2.5 rounded-full bg-zinc-600 border-2 border-zinc-800"
|
||||||
/>
|
/>
|
||||||
<div v-else class="w-3" />
|
<div v-else class="w-2.5" />
|
||||||
<div
|
<div
|
||||||
v-if="hasOutputs"
|
v-if="hasOutputs"
|
||||||
class="h-3 w-3 rounded-full bg-zinc-600 border-2 border-zinc-800"
|
class="h-2.5 w-2.5 rounded-full bg-zinc-600 border-2 border-zinc-800"
|
||||||
/>
|
/>
|
||||||
<div v-else class="w-3" />
|
<div v-else class="w-2.5" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="flex flex-col gap-1 pb-2">
|
<div class="flex flex-col pb-2">
|
||||||
<NodeSlots
|
<NodeSlots
|
||||||
:inputs="data.definition.inputs"
|
:inputs="data.definition.inputs"
|
||||||
:outputs="data.definition.outputs"
|
:outputs="data.definition.outputs"
|
||||||
@@ -200,12 +196,15 @@ function getHandleTop(index: number): string {
|
|||||||
@update:value="handleWidgetUpdate"
|
@update:value="handleWidgetUpdate"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div v-if="data.previewUrl" class="px-4 pt-2">
|
<div v-if="data.previewUrl" class="px-2 pt-2">
|
||||||
<img
|
<img
|
||||||
:src="data.previewUrl"
|
:src="data.previewUrl"
|
||||||
alt="Preview"
|
alt="Preview"
|
||||||
class="w-full rounded-lg object-cover max-h-40"
|
class="w-full rounded object-cover"
|
||||||
/>
|
/>
|
||||||
|
<div v-if="data.previewSize" class="text-center text-[10px] text-zinc-500 mt-1">
|
||||||
|
{{ data.previewSize }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -255,13 +254,13 @@ function getHandleTop(index: number): string {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.flow-node {
|
.flow-node {
|
||||||
--node-body-bg: #18181b;
|
--node-body-bg: #1a1a1e;
|
||||||
background-color: var(--node-body-bg);
|
background-color: var(--node-body-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.vue-flow-handle {
|
.vue-flow-handle {
|
||||||
width: 16px !important;
|
width: 14px !important;
|
||||||
height: 16px !important;
|
height: 14px !important;
|
||||||
background: transparent !important;
|
background: transparent !important;
|
||||||
border: none !important;
|
border: none !important;
|
||||||
opacity: 0 !important;
|
opacity: 0 !important;
|
||||||
|
|||||||
@@ -57,11 +57,10 @@ const hasOutputs = computed(() => props.outputs.length > 0)
|
|||||||
:class="[
|
:class="[
|
||||||
'flow-node-minimized relative rounded-lg',
|
'flow-node-minimized relative rounded-lg',
|
||||||
'border transition-all duration-150',
|
'border transition-all duration-150',
|
||||||
'bg-zinc-900',
|
|
||||||
borderClass,
|
borderClass,
|
||||||
outlineClass,
|
outlineClass,
|
||||||
{
|
{
|
||||||
'ring-4 ring-blue-500/30': selected && !hasError && !isExecuting,
|
'ring-2 ring-blue-500/30': selected && !hasError && !isExecuting,
|
||||||
}
|
}
|
||||||
]"
|
]"
|
||||||
:style="[bodyStyle, { opacity: nodeOpacity }]"
|
:style="[bodyStyle, { opacity: nodeOpacity }]"
|
||||||
@@ -73,45 +72,39 @@ const hasOutputs = computed(() => props.outputs.length > 0)
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Compact header -->
|
<!-- Compact header -->
|
||||||
<div
|
<div class="node-header-minimized py-1 px-2 text-zinc-100 rounded-t-lg">
|
||||||
:class="[
|
<div class="flex items-center justify-between gap-1.5 min-w-0">
|
||||||
'node-header-minimized py-1.5 px-2 text-xs',
|
<div class="flex items-center gap-1 min-w-0 flex-1">
|
||||||
'bg-zinc-800 text-zinc-100 rounded-t-lg',
|
|
||||||
]"
|
|
||||||
:style="headerStyle"
|
|
||||||
>
|
|
||||||
<div class="flex items-center justify-between gap-2 min-w-0">
|
|
||||||
<div class="flex items-center gap-1.5 min-w-0 flex-1">
|
|
||||||
<button
|
<button
|
||||||
class="flex h-4 w-4 shrink-0 items-center justify-center rounded text-zinc-400 transition-colors hover:bg-zinc-700 hover:text-zinc-200"
|
class="flex h-4 w-4 shrink-0 items-center justify-center text-zinc-500 transition-colors hover:text-zinc-300"
|
||||||
@click.stop="emit('expand')"
|
@click.stop="emit('expand')"
|
||||||
>
|
>
|
||||||
<i class="pi pi-chevron-down -rotate-90 text-[10px]" />
|
<i class="pi pi-chevron-down -rotate-90 text-[10px]" />
|
||||||
</button>
|
</button>
|
||||||
<span class="truncate font-medium text-[11px]">{{ title }}</span>
|
<span class="truncate font-medium text-xs">{{ title }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex shrink-0 items-center gap-1">
|
<div v-if="isExecuting || hasError" class="flex shrink-0 items-center gap-1">
|
||||||
<i
|
<i
|
||||||
v-if="isExecuting"
|
v-if="isExecuting"
|
||||||
class="pi pi-spin pi-spinner text-[10px] text-blue-400"
|
class="pi pi-spin pi-spinner text-[9px] text-blue-400"
|
||||||
/>
|
/>
|
||||||
<i
|
<i
|
||||||
v-if="hasError"
|
v-if="hasError"
|
||||||
class="pi pi-exclamation-triangle text-[10px] text-red-400"
|
class="pi pi-exclamation-triangle text-[9px] text-red-400"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Compact slots row -->
|
<!-- Compact slots row -->
|
||||||
<div class="flex items-center justify-between px-1 py-1 rounded-b-lg">
|
<div class="flex items-center justify-between px-1 py-0.5 rounded-b-lg">
|
||||||
<!-- Input dots -->
|
<!-- Input dots -->
|
||||||
<div class="flex items-center gap-0.5">
|
<div class="flex items-center gap-0.5">
|
||||||
<div
|
<div
|
||||||
v-for="(input, index) in visibleInputs"
|
v-for="(input, index) in visibleInputs"
|
||||||
:key="`input-${index}`"
|
:key="`input-${index}`"
|
||||||
class="h-2 w-2 rounded-full border border-zinc-900"
|
class="h-2 w-2 rounded-full"
|
||||||
:style="{ backgroundColor: getSlotColor(input.type) }"
|
:style="{ backgroundColor: getSlotColor(input.type) }"
|
||||||
:title="input.label || input.name"
|
:title="input.label || input.name"
|
||||||
/>
|
/>
|
||||||
@@ -123,7 +116,7 @@ const hasOutputs = computed(() => props.outputs.length > 0)
|
|||||||
<div
|
<div
|
||||||
v-for="(output, index) in visibleOutputs"
|
v-for="(output, index) in visibleOutputs"
|
||||||
:key="`output-${index}`"
|
:key="`output-${index}`"
|
||||||
class="h-2 w-2 rounded-full border border-zinc-900"
|
class="h-2 w-2 rounded-full"
|
||||||
:style="{ backgroundColor: getSlotColor(output.type) }"
|
:style="{ backgroundColor: getSlotColor(output.type) }"
|
||||||
:title="output.label || output.name"
|
:title="output.label || output.name"
|
||||||
/>
|
/>
|
||||||
@@ -153,15 +146,15 @@ const hasOutputs = computed(() => props.outputs.length > 0)
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.flow-node-minimized {
|
.flow-node-minimized {
|
||||||
--node-body-bg: #18181b;
|
--node-body-bg: #1a1a1e;
|
||||||
background-color: var(--node-body-bg);
|
background-color: var(--node-body-bg);
|
||||||
min-width: 100px;
|
min-width: 90px;
|
||||||
max-width: 160px;
|
max-width: 150px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.vue-flow-handle {
|
.vue-flow-handle {
|
||||||
width: 16px !important;
|
width: 14px !important;
|
||||||
height: 16px !important;
|
height: 14px !important;
|
||||||
background: transparent !important;
|
background: transparent !important;
|
||||||
border: none !important;
|
border: none !important;
|
||||||
opacity: 0 !important;
|
opacity: 0 !important;
|
||||||
|
|||||||
@@ -87,68 +87,66 @@ function getBadgeClasses(variant?: string): string {
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
:class="[
|
:class="[
|
||||||
'node-header py-2 pl-2 pr-3 text-sm',
|
'node-header px-2 py-1.5',
|
||||||
'bg-zinc-800 text-zinc-100',
|
'text-zinc-100',
|
||||||
collapsed ? 'rounded-lg' : 'rounded-t-lg',
|
collapsed ? 'rounded-lg' : 'rounded-t-lg',
|
||||||
]"
|
]"
|
||||||
@dblclick="handleDoubleClick"
|
@dblclick="handleDoubleClick"
|
||||||
>
|
>
|
||||||
<div class="flex items-center justify-between gap-2 min-w-0">
|
<div class="flex items-center gap-1.5 min-w-0">
|
||||||
<div class="flex items-center gap-2 min-w-0 flex-1">
|
<button
|
||||||
<button
|
class="flex h-4 w-4 shrink-0 items-center justify-center text-zinc-500 transition-colors hover:text-zinc-300"
|
||||||
class="flex h-5 w-5 shrink-0 items-center justify-center rounded text-zinc-400 transition-colors hover:bg-zinc-700 hover:text-zinc-200"
|
@click="handleCollapseClick"
|
||||||
@click="handleCollapseClick"
|
@dblclick.stop
|
||||||
@dblclick.stop
|
>
|
||||||
>
|
<i
|
||||||
<i
|
:class="[
|
||||||
:class="[
|
'pi pi-chevron-down text-[10px] transition-transform duration-200',
|
||||||
'pi pi-chevron-down text-xs transition-transform duration-200',
|
collapsed && '-rotate-90',
|
||||||
collapsed && '-rotate-90',
|
]"
|
||||||
]"
|
/>
|
||||||
/>
|
</button>
|
||||||
</button>
|
|
||||||
|
|
||||||
<div class="flex min-w-0 flex-1 items-center">
|
<div class="flex min-w-0 flex-1 items-center">
|
||||||
<input
|
<input
|
||||||
v-if="isEditing"
|
v-if="isEditing"
|
||||||
v-model="editValue"
|
v-model="editValue"
|
||||||
type="text"
|
type="text"
|
||||||
class="w-full min-w-0 truncate bg-transparent text-sm font-semibold text-zinc-100 outline-none ring-1 ring-blue-500 rounded px-1"
|
class="w-full min-w-0 truncate bg-transparent text-xs font-medium text-zinc-100 outline-none ring-1 ring-blue-500 rounded px-1"
|
||||||
autofocus
|
autofocus
|
||||||
@blur="handleTitleBlur"
|
@blur="handleTitleBlur"
|
||||||
@keydown="handleTitleKeydown"
|
@keydown="handleTitleKeydown"
|
||||||
/>
|
/>
|
||||||
<span v-else class="truncate text-sm font-semibold">
|
<span v-else class="truncate text-xs font-medium">
|
||||||
{{ title }}
|
{{ title }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex shrink-0 items-center gap-1.5">
|
<div v-if="badges?.length || statusBadge || pinned" class="flex shrink-0 items-center gap-1">
|
||||||
<span
|
<span
|
||||||
v-for="badge in badges"
|
v-for="badge in badges"
|
||||||
:key="badge.text"
|
:key="badge.text"
|
||||||
:class="[
|
:class="[
|
||||||
'inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] font-medium',
|
'inline-flex items-center gap-0.5 rounded px-1 py-0.5 text-[9px] font-medium',
|
||||||
getBadgeClasses(badge.variant),
|
getBadgeClasses(badge.variant),
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
<i v-if="badge.icon" :class="['pi', badge.icon, 'text-[9px]']" />
|
<i v-if="badge.icon" :class="['pi', badge.icon, 'text-[8px]']" />
|
||||||
{{ badge.text }}
|
{{ badge.text }}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span
|
<span
|
||||||
v-if="statusBadge"
|
v-if="statusBadge"
|
||||||
:class="[
|
:class="[
|
||||||
'inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] font-medium',
|
'inline-flex items-center gap-0.5 rounded px-1 py-0.5 text-[9px] font-medium',
|
||||||
getBadgeClasses(statusBadge.variant),
|
getBadgeClasses(statusBadge.variant),
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
<i v-if="statusBadge.icon" :class="['pi', statusBadge.icon, 'text-[9px]']" />
|
<i v-if="statusBadge.icon" :class="['pi', statusBadge.icon, 'text-[8px]']" />
|
||||||
{{ statusBadge.text }}
|
{{ statusBadge.text }}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<i v-if="pinned" class="pi pi-thumbtack text-xs text-zinc-500" />
|
<i v-if="pinned" class="pi pi-thumbtack text-[10px] text-zinc-500" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -22,13 +22,13 @@ const visibleOutputs = props.outputs.filter(s => !s.hidden)
|
|||||||
<div
|
<div
|
||||||
v-for="(input, index) in visibleInputs"
|
v-for="(input, index) in visibleInputs"
|
||||||
:key="`input-${index}`"
|
:key="`input-${index}`"
|
||||||
class="flex items-center gap-2 h-6 pr-4 group"
|
class="flex items-center gap-1.5 h-[22px] pr-3 group"
|
||||||
>
|
>
|
||||||
<SlotDot
|
<SlotDot
|
||||||
:color="getSlotColor(input.type)"
|
:color="getSlotColor(input.type)"
|
||||||
side="left"
|
side="left"
|
||||||
/>
|
/>
|
||||||
<span class="text-xs text-zinc-400 truncate">
|
<span class="text-[11px] text-zinc-400 truncate">
|
||||||
{{ input.label || input.name }}
|
{{ input.label || input.name }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -41,9 +41,9 @@ const visibleOutputs = props.outputs.filter(s => !s.hidden)
|
|||||||
<div
|
<div
|
||||||
v-for="(output, index) in visibleOutputs"
|
v-for="(output, index) in visibleOutputs"
|
||||||
:key="`output-${index}`"
|
:key="`output-${index}`"
|
||||||
class="flex items-center gap-2 h-6 pl-4 group"
|
class="flex items-center gap-1.5 h-[22px] pl-3 group"
|
||||||
>
|
>
|
||||||
<span class="text-xs text-zinc-400 truncate">
|
<span class="text-[11px] text-zinc-400 truncate uppercase tracking-wide">
|
||||||
{{ output.label || output.name }}
|
{{ output.label || output.name }}
|
||||||
</span>
|
</span>
|
||||||
<SlotDot
|
<SlotDot
|
||||||
|
|||||||
@@ -28,65 +28,67 @@ function getWidgetValue(widget: WidgetDefinition): unknown {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="node-widgets px-3 pt-1 pb-1 flex flex-col gap-2">
|
<div class="node-widgets px-2 flex flex-col gap-1">
|
||||||
<div
|
<div
|
||||||
v-for="widget in widgets"
|
v-for="widget in widgets"
|
||||||
:key="widget.name"
|
:key="widget.name"
|
||||||
class="widget-row"
|
class="widget-row flex items-center gap-3 min-h-[28px]"
|
||||||
>
|
>
|
||||||
<label class="widget-label text-[10px] text-zinc-500 mb-0.5 block">
|
<label class="widget-label text-[11px] text-zinc-400 shrink-0 min-w-[70px]">
|
||||||
{{ widget.label || widget.name }}
|
{{ widget.label || widget.name }}
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<WidgetSlider
|
<div class="flex-1 min-w-0">
|
||||||
v-if="widget.type === 'slider'"
|
<WidgetSlider
|
||||||
:widget="widget as WidgetDefinition<number>"
|
v-if="widget.type === 'slider'"
|
||||||
:model-value="getWidgetValue(widget) as number"
|
:widget="widget as WidgetDefinition<number>"
|
||||||
@update:model-value="(v) => handleUpdate(widget.name, v)"
|
:model-value="getWidgetValue(widget) as number"
|
||||||
/>
|
@update:model-value="(v) => handleUpdate(widget.name, v)"
|
||||||
|
/>
|
||||||
|
|
||||||
<WidgetNumber
|
<WidgetNumber
|
||||||
v-else-if="widget.type === 'number'"
|
v-else-if="widget.type === 'number'"
|
||||||
:widget="widget as WidgetDefinition<number>"
|
:widget="widget as WidgetDefinition<number>"
|
||||||
:model-value="getWidgetValue(widget) as number"
|
:model-value="getWidgetValue(widget) as number"
|
||||||
@update:model-value="(v) => handleUpdate(widget.name, v)"
|
@update:model-value="(v) => handleUpdate(widget.name, v)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<WidgetText
|
<WidgetText
|
||||||
v-else-if="widget.type === 'text'"
|
v-else-if="widget.type === 'text'"
|
||||||
:widget="widget as WidgetDefinition<string>"
|
:widget="widget as WidgetDefinition<string>"
|
||||||
:model-value="getWidgetValue(widget) as string"
|
:model-value="getWidgetValue(widget) as string"
|
||||||
@update:model-value="(v) => handleUpdate(widget.name, v)"
|
@update:model-value="(v) => handleUpdate(widget.name, v)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<WidgetText
|
<WidgetText
|
||||||
v-else-if="widget.type === 'textarea'"
|
v-else-if="widget.type === 'textarea'"
|
||||||
:widget="widget as WidgetDefinition<string>"
|
:widget="widget as WidgetDefinition<string>"
|
||||||
:model-value="getWidgetValue(widget) as string"
|
:model-value="getWidgetValue(widget) as string"
|
||||||
:multiline="true"
|
:multiline="true"
|
||||||
@update:model-value="(v) => handleUpdate(widget.name, v)"
|
@update:model-value="(v) => handleUpdate(widget.name, v)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<WidgetSelect
|
<WidgetSelect
|
||||||
v-else-if="widget.type === 'select'"
|
v-else-if="widget.type === 'select'"
|
||||||
:widget="widget as WidgetDefinition<string | number>"
|
:widget="widget as WidgetDefinition<string | number>"
|
||||||
:model-value="getWidgetValue(widget) as string | number"
|
:model-value="getWidgetValue(widget) as string | number"
|
||||||
@update:model-value="(v) => handleUpdate(widget.name, v)"
|
@update:model-value="(v) => handleUpdate(widget.name, v)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<WidgetToggle
|
<WidgetToggle
|
||||||
v-else-if="widget.type === 'toggle'"
|
v-else-if="widget.type === 'toggle'"
|
||||||
:widget="widget as WidgetDefinition<boolean>"
|
:widget="widget as WidgetDefinition<boolean>"
|
||||||
:model-value="getWidgetValue(widget) as boolean"
|
:model-value="getWidgetValue(widget) as boolean"
|
||||||
@update:model-value="(v) => handleUpdate(widget.name, v)"
|
@update:model-value="(v) => handleUpdate(widget.name, v)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<WidgetColor
|
<WidgetColor
|
||||||
v-else-if="widget.type === 'color'"
|
v-else-if="widget.type === 'color'"
|
||||||
:widget="widget as WidgetDefinition<string>"
|
:widget="widget as WidgetDefinition<string>"
|
||||||
:model-value="getWidgetValue(widget) as string"
|
:model-value="getWidgetValue(widget) as string"
|
||||||
@update:model-value="(v) => handleUpdate(widget.name, v)"
|
@update:model-value="(v) => handleUpdate(widget.name, v)"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -14,32 +14,27 @@ withDefaults(defineProps<Props>(), {
|
|||||||
<div
|
<div
|
||||||
:class="[
|
:class="[
|
||||||
'slot-dot relative flex items-center justify-center',
|
'slot-dot relative flex items-center justify-center',
|
||||||
side === 'left' ? '-ml-1.5' : '-mr-1.5',
|
side === 'left' ? '-ml-1' : '-mr-1',
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
<!-- Outer ring on hover -->
|
<!-- Outer ring on hover -->
|
||||||
<div
|
<div
|
||||||
class="absolute h-5 w-5 rounded-full opacity-0 transition-opacity duration-150 group-hover:opacity-100"
|
class="absolute h-4 w-4 rounded-full opacity-0 transition-opacity duration-150 group-hover:opacity-100"
|
||||||
:style="{ backgroundColor: `${color}30` }"
|
:style="{ backgroundColor: `${color}30` }"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Main dot -->
|
<!-- Main dot -->
|
||||||
<div
|
<div
|
||||||
:class="[
|
:class="[
|
||||||
'relative h-3 w-3 rounded-full border-2 border-zinc-900 transition-all duration-150',
|
'relative h-2.5 w-2.5 rounded-full border-[1.5px] transition-all duration-150',
|
||||||
'cursor-crosshair',
|
'cursor-crosshair',
|
||||||
]"
|
]"
|
||||||
:style="{
|
:style="{
|
||||||
backgroundColor: color,
|
backgroundColor: color,
|
||||||
boxShadow: connected ? `0 0 6px ${color}` : undefined,
|
borderColor: '#1a1a1e',
|
||||||
|
boxShadow: connected ? `0 0 4px ${color}` : undefined,
|
||||||
}"
|
}"
|
||||||
>
|
/>
|
||||||
<!-- Inner highlight -->
|
|
||||||
<div
|
|
||||||
class="absolute inset-0.5 rounded-full opacity-40"
|
|
||||||
:style="{ background: `linear-gradient(135deg, white 0%, transparent 50%)` }"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -49,16 +49,16 @@ function handleInput(event: Event): void {
|
|||||||
.widget-color {
|
.widget-color {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 6px;
|
||||||
padding: 2px 0;
|
height: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.color-preview {
|
.color-preview {
|
||||||
width: 32px;
|
width: 24px;
|
||||||
height: 20px;
|
height: 18px;
|
||||||
border-radius: 4px;
|
border-radius: 3px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid #3f3f46;
|
border: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.color-input {
|
.color-input {
|
||||||
@@ -76,21 +76,22 @@ function handleInput(event: Event): void {
|
|||||||
|
|
||||||
.color-input::-webkit-color-swatch {
|
.color-input::-webkit-color-swatch {
|
||||||
border: none;
|
border: none;
|
||||||
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.color-input::-moz-color-swatch {
|
.color-input::-moz-color-swatch {
|
||||||
border: none;
|
border: none;
|
||||||
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.color-input:disabled {
|
.color-input:disabled {
|
||||||
opacity: 0.5;
|
opacity: 0.4;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.color-value {
|
.color-value {
|
||||||
font-size: 11px;
|
font-size: 10px;
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
color: #a1a1aa;
|
color: #71717a;
|
||||||
min-width: 60px;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -105,41 +105,40 @@ function decrement(): void {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 24px;
|
width: 26px;
|
||||||
height: 26px;
|
height: 24px;
|
||||||
background: #3f3f46;
|
background: #2a2a2e;
|
||||||
border: 1px solid #3f3f46;
|
border: none;
|
||||||
color: #a1a1aa;
|
color: #71717a;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.15s;
|
transition: all 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.number-btn:first-child {
|
.number-btn:first-child {
|
||||||
border-radius: 6px 0 0 6px;
|
border-radius: 4px 0 0 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.number-btn:last-child {
|
.number-btn:last-child {
|
||||||
border-radius: 0 6px 6px 0;
|
border-radius: 0 4px 4px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.number-btn:hover:not(:disabled) {
|
.number-btn:hover:not(:disabled) {
|
||||||
background: #52525b;
|
background: #3f3f46;
|
||||||
color: #fafafa;
|
color: #a1a1aa;
|
||||||
}
|
}
|
||||||
|
|
||||||
.number-btn:disabled {
|
.number-btn:disabled {
|
||||||
opacity: 0.5;
|
opacity: 0.4;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.number-input {
|
.number-input {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
background: #27272a;
|
height: 24px;
|
||||||
border: 1px solid #3f3f46;
|
background: #2a2a2e;
|
||||||
border-left: none;
|
border: none;
|
||||||
border-right: none;
|
color: #e4e4e7;
|
||||||
color: #fafafa;
|
padding: 0 8px;
|
||||||
padding: 4px 8px;
|
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
outline: none;
|
outline: none;
|
||||||
@@ -154,12 +153,11 @@ function decrement(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.number-input:focus {
|
.number-input:focus {
|
||||||
border-color: #3b82f6;
|
background: #323238;
|
||||||
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.number-input:disabled {
|
.number-input:disabled {
|
||||||
opacity: 0.5;
|
opacity: 0.4;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -48,32 +48,36 @@ function handleChange(event: Event): void {
|
|||||||
|
|
||||||
.custom-select {
|
.custom-select {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background: #27272a;
|
height: 24px;
|
||||||
border: 1px solid #3f3f46;
|
background: #2a2a2e;
|
||||||
border-radius: 6px;
|
border: none;
|
||||||
color: #fafafa;
|
border-radius: 4px;
|
||||||
padding: 6px 28px 6px 10px;
|
color: #e4e4e7;
|
||||||
|
padding: 0 24px 0 10px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
outline: none;
|
outline: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
appearance: none;
|
appearance: none;
|
||||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23a1a1aa' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 24 24' fill='none' stroke='%2371717a' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
|
||||||
background-repeat: no-repeat;
|
background-repeat: no-repeat;
|
||||||
background-position: right 8px center;
|
background-position: right 8px center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.custom-select:hover {
|
||||||
|
background: #323238;
|
||||||
|
}
|
||||||
|
|
||||||
.custom-select:focus {
|
.custom-select:focus {
|
||||||
border-color: #3b82f6;
|
background: #323238;
|
||||||
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.custom-select:disabled {
|
.custom-select:disabled {
|
||||||
opacity: 0.5;
|
opacity: 0.4;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.custom-select option {
|
.custom-select option {
|
||||||
background: #27272a;
|
background: #2a2a2e;
|
||||||
color: #fafafa;
|
color: #e4e4e7;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -94,13 +94,13 @@ function handleNumberBlur(event: Event): void {
|
|||||||
.widget-slider {
|
.widget-slider {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 6px;
|
||||||
padding: 2px 0;
|
height: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.slider-container {
|
.slider-container {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
height: 20px;
|
height: 24px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
@@ -109,7 +109,7 @@ function handleNumberBlur(event: Event): void {
|
|||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
appearance: none;
|
appearance: none;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 4px;
|
height: 3px;
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
to right,
|
to right,
|
||||||
#3b82f6 0%,
|
#3b82f6 0%,
|
||||||
@@ -117,30 +117,30 @@ function handleNumberBlur(event: Event): void {
|
|||||||
#3f3f46 var(--fill-percent),
|
#3f3f46 var(--fill-percent),
|
||||||
#3f3f46 100%
|
#3f3f46 100%
|
||||||
);
|
);
|
||||||
border-radius: 4px;
|
border-radius: 2px;
|
||||||
outline: none;
|
outline: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.custom-slider:disabled {
|
.custom-slider:disabled {
|
||||||
opacity: 0.5;
|
opacity: 0.4;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.custom-slider::-webkit-slider-thumb {
|
.custom-slider::-webkit-slider-thumb {
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
appearance: none;
|
appearance: none;
|
||||||
width: 14px;
|
width: 12px;
|
||||||
height: 14px;
|
height: 12px;
|
||||||
background: #fafafa;
|
background: #e4e4e7;
|
||||||
border: 2px solid #3b82f6;
|
border: none;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
transition: background-color 0.15s, transform 0.15s;
|
transition: background-color 0.15s, transform 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.custom-slider::-webkit-slider-thumb:hover {
|
.custom-slider::-webkit-slider-thumb:hover {
|
||||||
background: #3b82f6;
|
background: #fafafa;
|
||||||
transform: scale(1.1);
|
transform: scale(1.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,17 +150,17 @@ function handleNumberBlur(event: Event): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.custom-slider::-moz-range-thumb {
|
.custom-slider::-moz-range-thumb {
|
||||||
width: 14px;
|
width: 12px;
|
||||||
height: 14px;
|
height: 12px;
|
||||||
background: #fafafa;
|
background: #e4e4e7;
|
||||||
border: 2px solid #3b82f6;
|
border: none;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
transition: background-color 0.15s, transform 0.15s;
|
transition: background-color 0.15s, transform 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.custom-slider::-moz-range-thumb:hover {
|
.custom-slider::-moz-range-thumb:hover {
|
||||||
background: #3b82f6;
|
background: #fafafa;
|
||||||
transform: scale(1.1);
|
transform: scale(1.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,12 +174,13 @@ function handleNumberBlur(event: Event): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.number-input {
|
.number-input {
|
||||||
width: 56px;
|
width: 50px;
|
||||||
background: #27272a;
|
height: 24px;
|
||||||
border: 1px solid #3f3f46;
|
background: #2a2a2e;
|
||||||
border-radius: 6px;
|
border: none;
|
||||||
color: #fafafa;
|
border-radius: 4px;
|
||||||
padding: 4px 6px;
|
color: #e4e4e7;
|
||||||
|
padding: 0 6px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
outline: none;
|
outline: none;
|
||||||
@@ -193,12 +194,11 @@ function handleNumberBlur(event: Event): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.number-input:focus {
|
.number-input:focus {
|
||||||
border-color: #3b82f6;
|
background: #323238;
|
||||||
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.number-input:disabled {
|
.number-input:disabled {
|
||||||
opacity: 0.5;
|
opacity: 0.4;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -53,33 +53,48 @@ function handleInput(event: Event): void {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.custom-input,
|
.custom-input {
|
||||||
|
width: 100%;
|
||||||
|
height: 24px;
|
||||||
|
background: #2a2a2e;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #e4e4e7;
|
||||||
|
padding: 0 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
.custom-textarea {
|
.custom-textarea {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background: #27272a;
|
background: #2a2a2e;
|
||||||
border: 1px solid #3f3f46;
|
border: none;
|
||||||
border-radius: 6px;
|
border-radius: 4px;
|
||||||
color: #fafafa;
|
color: #e4e4e7;
|
||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
outline: none;
|
outline: none;
|
||||||
resize: none;
|
resize: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.custom-input:hover,
|
||||||
|
.custom-textarea:hover {
|
||||||
|
background: #323238;
|
||||||
|
}
|
||||||
|
|
||||||
.custom-input:focus,
|
.custom-input:focus,
|
||||||
.custom-textarea:focus {
|
.custom-textarea:focus {
|
||||||
border-color: #3b82f6;
|
background: #323238;
|
||||||
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.custom-input:disabled,
|
.custom-input:disabled,
|
||||||
.custom-textarea:disabled {
|
.custom-textarea:disabled {
|
||||||
opacity: 0.5;
|
opacity: 0.4;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.custom-input::placeholder,
|
.custom-input::placeholder,
|
||||||
.custom-textarea::placeholder {
|
.custom-textarea::placeholder {
|
||||||
color: #71717a;
|
color: #52525b;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ function toggle(): void {
|
|||||||
.widget-toggle {
|
.widget-toggle {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
height: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toggle-button {
|
.toggle-button {
|
||||||
@@ -54,16 +55,16 @@ function toggle(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.toggle-button:disabled {
|
.toggle-button:disabled {
|
||||||
opacity: 0.5;
|
opacity: 0.4;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toggle-track {
|
.toggle-track {
|
||||||
display: block;
|
display: block;
|
||||||
width: 36px;
|
width: 32px;
|
||||||
height: 20px;
|
height: 16px;
|
||||||
background: #3f3f46;
|
background: #3f3f46;
|
||||||
border-radius: 10px;
|
border-radius: 8px;
|
||||||
position: relative;
|
position: relative;
|
||||||
transition: background-color 0.2s;
|
transition: background-color 0.2s;
|
||||||
}
|
}
|
||||||
@@ -76,12 +77,11 @@ function toggle(): void {
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
top: 2px;
|
top: 2px;
|
||||||
left: 2px;
|
left: 2px;
|
||||||
width: 16px;
|
width: 12px;
|
||||||
height: 16px;
|
height: 12px;
|
||||||
background: #fafafa;
|
background: #e4e4e7;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
transition: transform 0.2s;
|
transition: transform 0.2s;
|
||||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.toggle-button.active .toggle-thumb {
|
.toggle-button.active .toggle-thumb {
|
||||||
|
|||||||
@@ -244,6 +244,7 @@ export interface SharedWorkflow {
|
|||||||
nodes: number
|
nodes: number
|
||||||
category: string
|
category: string
|
||||||
starred: boolean
|
starred: boolean
|
||||||
|
thumbnail?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TeamModel {
|
export interface TeamModel {
|
||||||
@@ -254,6 +255,7 @@ export interface TeamModel {
|
|||||||
size: string
|
size: string
|
||||||
author: TeamMember
|
author: TeamMember
|
||||||
downloads: number
|
downloads: number
|
||||||
|
thumbnail?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NodePack {
|
export interface NodePack {
|
||||||
@@ -264,6 +266,7 @@ export interface NodePack {
|
|||||||
nodes: number
|
nodes: number
|
||||||
author: string
|
author: string
|
||||||
installed: boolean
|
installed: boolean
|
||||||
|
thumbnail?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TEMPLATE_CATEGORIES_DATA: TemplateCategory[] = [
|
export const TEMPLATE_CATEGORIES_DATA: TemplateCategory[] = [
|
||||||
@@ -354,6 +357,7 @@ export function createSharedWorkflowsData(members: TeamMember[]): SharedWorkflow
|
|||||||
nodes: 12,
|
nodes: 12,
|
||||||
category: 'Production',
|
category: 'Production',
|
||||||
starred: true,
|
starred: true,
|
||||||
|
thumbnail: '/assets/card_images/workflow_01.webp',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '2',
|
id: '2',
|
||||||
@@ -364,6 +368,7 @@ export function createSharedWorkflowsData(members: TeamMember[]): SharedWorkflow
|
|||||||
nodes: 18,
|
nodes: 18,
|
||||||
category: 'Marketing',
|
category: 'Marketing',
|
||||||
starred: true,
|
starred: true,
|
||||||
|
thumbnail: '/assets/card_images/2690a78c-c210-4a52-8c37-3cb5bc4d9e71.webp',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '3',
|
id: '3',
|
||||||
@@ -374,6 +379,7 @@ export function createSharedWorkflowsData(members: TeamMember[]): SharedWorkflow
|
|||||||
nodes: 24,
|
nodes: 24,
|
||||||
category: 'Production',
|
category: 'Production',
|
||||||
starred: false,
|
starred: false,
|
||||||
|
thumbnail: '/assets/card_images/bacb46ea-7e63-4f19-a253-daf41461e98f.webp',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '4',
|
id: '4',
|
||||||
@@ -384,6 +390,7 @@ export function createSharedWorkflowsData(members: TeamMember[]): SharedWorkflow
|
|||||||
nodes: 8,
|
nodes: 8,
|
||||||
category: 'Marketing',
|
category: 'Marketing',
|
||||||
starred: false,
|
starred: false,
|
||||||
|
thumbnail: '/assets/card_images/228616f4-12ad-426d-84fb-f20e488ba7ee.webp',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -398,6 +405,7 @@ export function createTeamModelsData(members: TeamMember[]): TeamModel[] {
|
|||||||
size: '144 MB',
|
size: '144 MB',
|
||||||
author: members[0]!,
|
author: members[0]!,
|
||||||
downloads: 156,
|
downloads: 156,
|
||||||
|
thumbnail: '/assets/card_images/683255d3-1d10-43d9-a6ff-ef142061e88a.webp',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '2',
|
id: '2',
|
||||||
@@ -407,6 +415,7 @@ export function createTeamModelsData(members: TeamMember[]): TeamModel[] {
|
|||||||
size: '6.94 GB',
|
size: '6.94 GB',
|
||||||
author: members[1]!,
|
author: members[1]!,
|
||||||
downloads: 89,
|
downloads: 89,
|
||||||
|
thumbnail: '/assets/card_images/91f1f589-ddb4-4c4f-b3a7-ba30fc271987.webp',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '3',
|
id: '3',
|
||||||
@@ -416,6 +425,7 @@ export function createTeamModelsData(members: TeamMember[]): TeamModel[] {
|
|||||||
size: '72 MB',
|
size: '72 MB',
|
||||||
author: members[2]!,
|
author: members[2]!,
|
||||||
downloads: 234,
|
downloads: 234,
|
||||||
|
thumbnail: '/assets/card_images/28e9f7ea-ef00-48e8-849d-8752a34939c7.webp',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '4',
|
id: '4',
|
||||||
@@ -425,6 +435,7 @@ export function createTeamModelsData(members: TeamMember[]): TeamModel[] {
|
|||||||
size: '24 KB',
|
size: '24 KB',
|
||||||
author: members[0]!,
|
author: members[0]!,
|
||||||
downloads: 312,
|
downloads: 312,
|
||||||
|
thumbnail: '/assets/card_images/comfyui_workflow.jpg',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -438,6 +449,7 @@ export const NODE_PACKS_DATA: NodePack[] = [
|
|||||||
nodes: 8,
|
nodes: 8,
|
||||||
author: 'Netflix Creative Tech',
|
author: 'Netflix Creative Tech',
|
||||||
installed: true,
|
installed: true,
|
||||||
|
thumbnail: '/assets/card_images/can-you-rate-my-comfyui-workflow-v0-o9clchhji39c1.webp',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '2',
|
id: '2',
|
||||||
@@ -447,6 +459,7 @@ export const NODE_PACKS_DATA: NodePack[] = [
|
|||||||
nodes: 4,
|
nodes: 4,
|
||||||
author: 'Netflix Creative Tech',
|
author: 'Netflix Creative Tech',
|
||||||
installed: true,
|
installed: true,
|
||||||
|
thumbnail: '/assets/card_images/dda28581-37c8-44da-8822-57d1ccc2118c_2130x1658.png',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '3',
|
id: '3',
|
||||||
@@ -456,5 +469,6 @@ export const NODE_PACKS_DATA: NodePack[] = [
|
|||||||
nodes: 6,
|
nodes: 6,
|
||||||
author: 'Netflix Creative Tech',
|
author: 'Netflix Creative Tech',
|
||||||
installed: false,
|
installed: false,
|
||||||
|
thumbnail: '/assets/card_images/workflow_01.webp',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -84,7 +84,24 @@ app.use(PrimeVue, {
|
|||||||
app.use(ToastService)
|
app.use(ToastService)
|
||||||
app.use(ConfirmationService)
|
app.use(ConfirmationService)
|
||||||
|
|
||||||
// PrimeVue directives
|
// PrimeVue directives with custom defaults
|
||||||
app.directive('tooltip', Tooltip)
|
app.directive('tooltip', {
|
||||||
|
...Tooltip,
|
||||||
|
getSSRProps() {
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
mounted(el, binding) {
|
||||||
|
// Set fast show delay (100ms) as default
|
||||||
|
const value = binding.value
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
binding.value = { value, showDelay: 100, hideDelay: 0 }
|
||||||
|
} else if (typeof value === 'object' && value !== null) {
|
||||||
|
binding.value = { showDelay: 100, hideDelay: 0, ...value }
|
||||||
|
}
|
||||||
|
Tooltip.mounted(el, binding)
|
||||||
|
},
|
||||||
|
updated: Tooltip.updated,
|
||||||
|
unmounted: Tooltip.unmounted
|
||||||
|
})
|
||||||
|
|
||||||
app.mount('#app')
|
app.mount('#app')
|
||||||
|
|||||||
@@ -59,6 +59,11 @@ const v2Routes: RouteRecordRaw[] = [
|
|||||||
name: 'workspace-templates',
|
name: 'workspace-templates',
|
||||||
component: () => import('./views/v2/workspace/TemplatesView.vue')
|
component: () => import('./views/v2/workspace/TemplatesView.vue')
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'library',
|
||||||
|
name: 'workspace-library',
|
||||||
|
component: () => import('./views/v2/workspace/LibraryView.vue')
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'trash',
|
path: 'trash',
|
||||||
name: 'workspace-trash',
|
name: 'workspace-trash',
|
||||||
|
|||||||
409
ComfyUI_vibe/src/views/v2/workspace/LibraryView.vue
Normal file
409
ComfyUI_vibe/src/views/v2/workspace/LibraryView.vue
Normal file
@@ -0,0 +1,409 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import Popover from 'primevue/popover'
|
||||||
|
import {
|
||||||
|
WorkspaceSearchInput,
|
||||||
|
WorkspaceViewToggle,
|
||||||
|
WorkspaceSortSelect,
|
||||||
|
WorkspaceFilterSelect,
|
||||||
|
WorkspaceCard,
|
||||||
|
} from '@/components/v2/workspace'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const workspaceId = computed(() => route.params.workspaceId as string)
|
||||||
|
|
||||||
|
// Library/Brand switcher
|
||||||
|
interface Library {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
icon: string
|
||||||
|
color: string
|
||||||
|
itemCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const libraries = ref<Library[]>([
|
||||||
|
{ id: 'netflix', name: 'Netflix', icon: 'pi pi-play', color: 'bg-red-600', itemCount: 248 },
|
||||||
|
{ id: 'adobe', name: 'Adobe Creative', icon: 'pi pi-palette', color: 'bg-rose-600', itemCount: 156 },
|
||||||
|
{ id: 'personal', name: 'My Library', icon: 'pi pi-user', color: 'bg-zinc-600', itemCount: 89 },
|
||||||
|
{ id: 'community', name: 'Community Hub', icon: 'pi pi-users', color: 'bg-violet-600', itemCount: 1240 },
|
||||||
|
])
|
||||||
|
|
||||||
|
const currentLibrary = ref<Library>(libraries.value[0])
|
||||||
|
const libraryMenu = ref<InstanceType<typeof Popover> | null>(null)
|
||||||
|
|
||||||
|
function toggleLibraryMenu(event: Event): void {
|
||||||
|
libraryMenu.value?.toggle(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectLibrary(library: Library): void {
|
||||||
|
currentLibrary.value = library
|
||||||
|
libraryMenu.value?.hide()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Category tabs
|
||||||
|
type CategoryId = 'all' | 'workflows' | 'models' | 'nodepacks' | 'assets' | 'brand-kit'
|
||||||
|
|
||||||
|
interface Category {
|
||||||
|
id: CategoryId
|
||||||
|
label: string
|
||||||
|
icon: string
|
||||||
|
count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const categories = ref<Category[]>([
|
||||||
|
{ id: 'all', label: 'All', icon: 'pi pi-th-large', count: 248 },
|
||||||
|
{ id: 'workflows', label: 'Workflows', icon: 'pi pi-sitemap', count: 64 },
|
||||||
|
{ id: 'models', label: 'Models', icon: 'pi pi-box', count: 38 },
|
||||||
|
{ id: 'nodepacks', label: 'Nodepacks', icon: 'pi pi-th-large', count: 24 },
|
||||||
|
{ id: 'assets', label: 'Assets', icon: 'pi pi-images', count: 89 },
|
||||||
|
{ id: 'brand-kit', label: 'Brand Kit', icon: 'pi pi-palette', count: 33 },
|
||||||
|
])
|
||||||
|
|
||||||
|
const activeCategory = ref<CategoryId>('all')
|
||||||
|
|
||||||
|
// View mode & filters
|
||||||
|
type ViewMode = 'grid' | 'list'
|
||||||
|
const viewMode = ref<ViewMode>('grid')
|
||||||
|
const searchQuery = ref('')
|
||||||
|
const sortBy = ref('recent')
|
||||||
|
const filterBy = ref('all')
|
||||||
|
|
||||||
|
const sortOptions = [
|
||||||
|
{ value: 'recent', label: 'Recently Added' },
|
||||||
|
{ value: 'name', label: 'Name' },
|
||||||
|
{ value: 'popular', label: 'Most Used' },
|
||||||
|
{ value: 'updated', label: 'Last Updated' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const filterOptions = [
|
||||||
|
{ value: 'all', label: 'All Items' },
|
||||||
|
{ value: 'shared', label: 'Shared with me' },
|
||||||
|
{ value: 'owned', label: 'Created by me' },
|
||||||
|
{ value: 'favorited', label: 'Favorited' },
|
||||||
|
]
|
||||||
|
|
||||||
|
// Mock library items
|
||||||
|
interface LibraryItem {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
type: CategoryId
|
||||||
|
thumbnail: string
|
||||||
|
icon: string
|
||||||
|
author: string
|
||||||
|
updatedAt: string
|
||||||
|
updatedTimestamp: number
|
||||||
|
uses: number
|
||||||
|
isShared: boolean
|
||||||
|
isFavorited: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = ref<LibraryItem[]>([
|
||||||
|
{ id: '1', name: 'SDXL Base Pipeline', description: 'Standard text-to-image workflow with SDXL', type: 'workflows', thumbnail: '/assets/card_images/workflow_01.webp', icon: 'pi pi-sitemap', author: 'Netflix Design', updatedAt: '2 hours ago', updatedTimestamp: Date.now() - 2 * 60 * 60 * 1000, uses: 1250, isShared: true, isFavorited: true },
|
||||||
|
{ id: '2', name: 'ControlNet Canny', description: 'Edge-guided image generation', type: 'workflows', thumbnail: '/assets/card_images/comfyui_workflow.jpg', icon: 'pi pi-sitemap', author: 'Team', updatedAt: '1 day ago', updatedTimestamp: Date.now() - 24 * 60 * 60 * 1000, uses: 890, isShared: true, isFavorited: false },
|
||||||
|
{ id: '3', name: 'SDXL Lightning v1.0', description: '4-step fast generation checkpoint', type: 'models', thumbnail: '/assets/card_images/2690a78c-c210-4a52-8c37-3cb5bc4d9e71.webp', icon: 'pi pi-box', author: 'ByteDance', updatedAt: '3 days ago', updatedTimestamp: Date.now() - 3 * 24 * 60 * 60 * 1000, uses: 3420, isShared: false, isFavorited: true },
|
||||||
|
{ id: '4', name: 'Flux.1 Dev', description: 'High quality diffusion model', type: 'models', thumbnail: '/assets/card_images/bacb46ea-7e63-4f19-a253-daf41461e98f.webp', icon: 'pi pi-box', author: 'Black Forest', updatedAt: '1 week ago', updatedTimestamp: Date.now() - 7 * 24 * 60 * 60 * 1000, uses: 5670, isShared: false, isFavorited: false },
|
||||||
|
{ id: '5', name: 'ComfyUI Manager', description: 'Install and manage custom nodes', type: 'nodepacks', thumbnail: '/assets/card_images/228616f4-12ad-426d-84fb-f20e488ba7ee.webp', icon: 'pi pi-th-large', author: 'Comfy Org', updatedAt: '2 weeks ago', updatedTimestamp: Date.now() - 14 * 24 * 60 * 60 * 1000, uses: 12400, isShared: false, isFavorited: true },
|
||||||
|
{ id: '6', name: 'Impact Pack', description: 'Advanced sampling and detailing', type: 'nodepacks', thumbnail: '/assets/card_images/683255d3-1d10-43d9-a6ff-ef142061e88a.webp', icon: 'pi pi-th-large', author: 'Dr.Lt.Data', updatedAt: '5 days ago', updatedTimestamp: Date.now() - 5 * 24 * 60 * 60 * 1000, uses: 8900, isShared: false, isFavorited: false },
|
||||||
|
{ id: '7', name: 'Brand Logo Pack', description: 'Netflix brand logos in various formats', type: 'brand-kit', thumbnail: '/assets/card_images/91f1f589-ddb4-4c4f-b3a7-ba30fc271987.webp', icon: 'pi pi-palette', author: 'Brand Team', updatedAt: '1 month ago', updatedTimestamp: Date.now() - 30 * 24 * 60 * 60 * 1000, uses: 450, isShared: true, isFavorited: false },
|
||||||
|
{ id: '8', name: 'Color Guidelines', description: 'Official brand color palette', type: 'brand-kit', thumbnail: '/assets/card_images/28e9f7ea-ef00-48e8-849d-8752a34939c7.webp', icon: 'pi pi-palette', author: 'Brand Team', updatedAt: '2 months ago', updatedTimestamp: Date.now() - 60 * 24 * 60 * 60 * 1000, uses: 890, isShared: true, isFavorited: true },
|
||||||
|
{ id: '9', name: 'Hero Images Q4', description: 'Generated hero images for campaigns', type: 'assets', thumbnail: '/assets/card_images/dda28581-37c8-44da-8822-57d1ccc2118c_2130x1658.png', icon: 'pi pi-images', author: 'Creative Team', updatedAt: '3 days ago', updatedTimestamp: Date.now() - 3 * 24 * 60 * 60 * 1000, uses: 67, isShared: true, isFavorited: false },
|
||||||
|
{ id: '10', name: 'Social Media Assets', description: 'Generated social media graphics', type: 'assets', thumbnail: '/assets/card_images/can-you-rate-my-comfyui-workflow-v0-o9clchhji39c1.webp', icon: 'pi pi-images', author: 'Marketing', updatedAt: '1 week ago', updatedTimestamp: Date.now() - 7 * 24 * 60 * 60 * 1000, uses: 234, isShared: true, isFavorited: false },
|
||||||
|
{ id: '11', name: 'Video Upscale 4K', description: 'AI-powered video upscaling workflow', type: 'workflows', thumbnail: '/assets/card_images/workflow_01.webp', icon: 'pi pi-sitemap', author: 'Video Team', updatedAt: '4 days ago', updatedTimestamp: Date.now() - 4 * 24 * 60 * 60 * 1000, uses: 567, isShared: true, isFavorited: false },
|
||||||
|
{ id: '12', name: 'Typography Set', description: 'Brand approved fonts and styles', type: 'brand-kit', thumbnail: '/assets/card_images/2690a78c-c210-4a52-8c37-3cb5bc4d9e71.webp', icon: 'pi pi-palette', author: 'Brand Team', updatedAt: '2 weeks ago', updatedTimestamp: Date.now() - 14 * 24 * 60 * 60 * 1000, uses: 320, isShared: true, isFavorited: false },
|
||||||
|
])
|
||||||
|
|
||||||
|
// Filtered and sorted items
|
||||||
|
const filteredItems = computed(() => {
|
||||||
|
let result = [...items.value]
|
||||||
|
|
||||||
|
// Filter by category
|
||||||
|
if (activeCategory.value !== 'all') {
|
||||||
|
result = result.filter(item => item.type === activeCategory.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by filter option
|
||||||
|
if (filterBy.value === 'shared') {
|
||||||
|
result = result.filter(item => item.isShared)
|
||||||
|
} else if (filterBy.value === 'favorited') {
|
||||||
|
result = result.filter(item => item.isFavorited)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by search
|
||||||
|
if (searchQuery.value) {
|
||||||
|
const query = searchQuery.value.toLowerCase()
|
||||||
|
result = result.filter(item =>
|
||||||
|
item.name.toLowerCase().includes(query) ||
|
||||||
|
item.description.toLowerCase().includes(query) ||
|
||||||
|
item.author.toLowerCase().includes(query)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort
|
||||||
|
result.sort((a, b) => {
|
||||||
|
switch (sortBy.value) {
|
||||||
|
case 'name':
|
||||||
|
return a.name.localeCompare(b.name)
|
||||||
|
case 'popular':
|
||||||
|
return b.uses - a.uses
|
||||||
|
case 'updated':
|
||||||
|
case 'recent':
|
||||||
|
default:
|
||||||
|
return b.updatedTimestamp - a.updatedTimestamp
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
|
// Helpers
|
||||||
|
function getTypeColor(type: CategoryId): string {
|
||||||
|
const colors: Record<string, string> = {
|
||||||
|
workflows: 'bg-blue-500/20 text-blue-400',
|
||||||
|
models: 'bg-purple-500/20 text-purple-400',
|
||||||
|
nodepacks: 'bg-green-500/20 text-green-400',
|
||||||
|
assets: 'bg-amber-500/20 text-amber-400',
|
||||||
|
'brand-kit': 'bg-pink-500/20 text-pink-400',
|
||||||
|
}
|
||||||
|
return colors[type] || 'bg-zinc-500/20 text-zinc-400'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTypeLabel(type: CategoryId): string {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
workflows: 'Workflow',
|
||||||
|
models: 'Model',
|
||||||
|
nodepacks: 'Nodepack',
|
||||||
|
assets: 'Asset',
|
||||||
|
'brand-kit': 'Brand Kit',
|
||||||
|
}
|
||||||
|
return labels[type] || type
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatUses(uses: number): string {
|
||||||
|
if (uses >= 1000) {
|
||||||
|
return `${(uses / 1000).toFixed(1)}k`
|
||||||
|
}
|
||||||
|
return uses.toString()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="p-6">
|
||||||
|
<!-- Header with Library Switcher -->
|
||||||
|
<div class="mb-6 flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<!-- Library Switcher -->
|
||||||
|
<button
|
||||||
|
class="flex items-center gap-3 rounded-lg border border-zinc-200 bg-white px-4 py-2.5 transition-colors hover:border-zinc-300 hover:bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-800 dark:hover:border-zinc-600 dark:hover:bg-zinc-700"
|
||||||
|
@click="toggleLibraryMenu"
|
||||||
|
>
|
||||||
|
<div :class="['flex h-8 w-8 items-center justify-center rounded-md text-white', currentLibrary.color]">
|
||||||
|
<i :class="[currentLibrary.icon, 'text-sm']" />
|
||||||
|
</div>
|
||||||
|
<div class="text-left">
|
||||||
|
<p class="text-sm font-medium text-zinc-900 dark:text-zinc-100">{{ currentLibrary.name }}</p>
|
||||||
|
<p class="text-xs text-zinc-500 dark:text-zinc-400">{{ currentLibrary.itemCount }} items</p>
|
||||||
|
</div>
|
||||||
|
<i class="pi pi-chevron-down text-xs text-zinc-400" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Library Menu -->
|
||||||
|
<Popover ref="libraryMenu" append-to="self">
|
||||||
|
<div class="w-72 p-2">
|
||||||
|
<p class="px-2 py-1.5 text-xs font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
|
||||||
|
Switch Library
|
||||||
|
</p>
|
||||||
|
<div class="mt-1 flex flex-col gap-0.5">
|
||||||
|
<button
|
||||||
|
v-for="lib in libraries"
|
||||||
|
:key="lib.id"
|
||||||
|
:class="[
|
||||||
|
'flex items-center gap-3 rounded-md px-2 py-2 text-left transition-colors',
|
||||||
|
currentLibrary.id === lib.id
|
||||||
|
? 'bg-zinc-100 dark:bg-zinc-700'
|
||||||
|
: 'hover:bg-zinc-50 dark:hover:bg-zinc-800'
|
||||||
|
]"
|
||||||
|
@click="selectLibrary(lib)"
|
||||||
|
>
|
||||||
|
<div :class="['flex h-8 w-8 items-center justify-center rounded-md text-white', lib.color]">
|
||||||
|
<i :class="[lib.icon, 'text-sm']" />
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="text-sm font-medium text-zinc-900 dark:text-zinc-100">{{ lib.name }}</p>
|
||||||
|
<p class="text-xs text-zinc-500 dark:text-zinc-400">{{ lib.itemCount }} items</p>
|
||||||
|
</div>
|
||||||
|
<i v-if="currentLibrary.id === lib.id" class="pi pi-check text-sm text-blue-500" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Popover>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-100">
|
||||||
|
Library Hub
|
||||||
|
</h1>
|
||||||
|
<p class="text-sm text-zinc-500 dark:text-zinc-400">
|
||||||
|
Shared workflows, models, nodepacks, and brand assets
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<RouterLink
|
||||||
|
:to="`/${workspaceId}/create`"
|
||||||
|
class="inline-flex items-center gap-2 rounded-md border border-zinc-200 bg-white px-3 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"
|
||||||
|
>
|
||||||
|
<i class="pi pi-bolt text-xs" />
|
||||||
|
Linear
|
||||||
|
</RouterLink>
|
||||||
|
<RouterLink
|
||||||
|
:to="`/${workspaceId}/canvas`"
|
||||||
|
class="inline-flex items-center gap-2 rounded-md border border-zinc-200 bg-white px-3 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"
|
||||||
|
>
|
||||||
|
<i class="pi pi-share-alt text-xs" />
|
||||||
|
Node
|
||||||
|
</RouterLink>
|
||||||
|
<button
|
||||||
|
class="inline-flex items-center gap-2 rounded-md bg-zinc-900 px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-zinc-800 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200"
|
||||||
|
>
|
||||||
|
<i class="pi pi-plus text-xs" />
|
||||||
|
Add to Library
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Category Tabs -->
|
||||||
|
<div class="mb-6 flex items-center gap-1 rounded-lg border border-zinc-200 bg-zinc-50 p-1 dark:border-zinc-700 dark:bg-zinc-800/50">
|
||||||
|
<button
|
||||||
|
v-for="cat in categories"
|
||||||
|
:key="cat.id"
|
||||||
|
:class="[
|
||||||
|
'flex items-center gap-2 rounded-md px-4 py-2 text-sm font-medium transition-colors',
|
||||||
|
activeCategory === cat.id
|
||||||
|
? 'bg-white text-zinc-900 shadow-sm dark:bg-zinc-700 dark:text-zinc-100'
|
||||||
|
: 'text-zinc-500 hover:text-zinc-700 dark:text-zinc-400 dark:hover:text-zinc-200'
|
||||||
|
]"
|
||||||
|
@click="activeCategory = cat.id"
|
||||||
|
>
|
||||||
|
<i :class="[cat.icon, 'text-sm']" />
|
||||||
|
{{ cat.label }}
|
||||||
|
<span
|
||||||
|
:class="[
|
||||||
|
'rounded-full px-1.5 py-0.5 text-xs',
|
||||||
|
activeCategory === cat.id
|
||||||
|
? 'bg-zinc-100 text-zinc-600 dark:bg-zinc-600 dark:text-zinc-200'
|
||||||
|
: 'bg-zinc-200/50 text-zinc-500 dark:bg-zinc-700 dark:text-zinc-400'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
{{ cat.count }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search & Filters -->
|
||||||
|
<div class="mb-4 flex items-center gap-3">
|
||||||
|
<WorkspaceSearchInput
|
||||||
|
v-model="searchQuery"
|
||||||
|
placeholder="Search library..."
|
||||||
|
/>
|
||||||
|
<WorkspaceViewToggle v-model="viewMode" />
|
||||||
|
<WorkspaceSortSelect v-model="sortBy" :options="sortOptions" />
|
||||||
|
<WorkspaceFilterSelect v-model="filterBy" :options="filterOptions" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty State -->
|
||||||
|
<div
|
||||||
|
v-if="filteredItems.length === 0"
|
||||||
|
class="flex flex-col items-center justify-center rounded-lg border border-dashed border-zinc-300 py-16 dark:border-zinc-700"
|
||||||
|
>
|
||||||
|
<div class="flex h-12 w-12 items-center justify-center rounded-full bg-zinc-100 dark:bg-zinc-800">
|
||||||
|
<i class="pi pi-inbox text-xl text-zinc-400" />
|
||||||
|
</div>
|
||||||
|
<h3 class="mt-4 text-sm font-medium text-zinc-900 dark:text-zinc-100">No items found</h3>
|
||||||
|
<p class="mt-1 text-sm text-zinc-500 dark:text-zinc-400">
|
||||||
|
{{ searchQuery || filterBy !== 'all' ? 'Try different filters' : 'Add items to get started' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 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-5"
|
||||||
|
>
|
||||||
|
<WorkspaceCard
|
||||||
|
v-for="item in filteredItems"
|
||||||
|
:key="item.id"
|
||||||
|
:thumbnail="item.thumbnail"
|
||||||
|
:title="item.name"
|
||||||
|
:description="item.description"
|
||||||
|
:icon="item.icon"
|
||||||
|
:badge="getTypeLabel(item.type)"
|
||||||
|
:badge-class="getTypeColor(item.type)"
|
||||||
|
:stats="[
|
||||||
|
{ icon: 'pi pi-user', value: item.author },
|
||||||
|
{ icon: 'pi pi-chart-bar', value: formatUses(item.uses) }
|
||||||
|
]"
|
||||||
|
:updated-at="item.updatedAt"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- List View -->
|
||||||
|
<div v-else class="rounded-lg border border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-900">
|
||||||
|
<!-- List Header -->
|
||||||
|
<div class="flex items-center gap-4 border-b border-zinc-100 px-5 py-3 text-xs font-medium uppercase tracking-wide text-zinc-500 dark:border-zinc-800 dark:text-zinc-400">
|
||||||
|
<div class="w-12">Icon</div>
|
||||||
|
<div class="flex-1">Name</div>
|
||||||
|
<div class="w-24">Type</div>
|
||||||
|
<div class="w-24 text-right">Uses</div>
|
||||||
|
<div class="w-28 text-right">Author</div>
|
||||||
|
<div class="w-28 text-right">Updated</div>
|
||||||
|
<div class="w-10"></div>
|
||||||
|
</div>
|
||||||
|
<!-- List Items -->
|
||||||
|
<div class="divide-y divide-zinc-100 dark:divide-zinc-800">
|
||||||
|
<div
|
||||||
|
v-for="item in filteredItems"
|
||||||
|
:key="item.id"
|
||||||
|
class="flex cursor-pointer items-center gap-4 px-5 py-4 transition-colors hover:bg-zinc-50 dark:hover:bg-zinc-800/50"
|
||||||
|
>
|
||||||
|
<div class="w-12">
|
||||||
|
<div class="h-10 w-10 overflow-hidden rounded-md">
|
||||||
|
<img :src="item.thumbnail" :alt="item.name" class="h-full w-full object-cover" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<p class="font-medium text-zinc-900 dark:text-zinc-100">{{ item.name }}</p>
|
||||||
|
<i v-if="item.isFavorited" class="pi pi-star-fill text-xs text-amber-400" />
|
||||||
|
<i v-if="item.isShared" class="pi pi-share-alt text-xs text-zinc-400" />
|
||||||
|
</div>
|
||||||
|
<p class="truncate text-sm text-zinc-500 dark:text-zinc-400">{{ item.description }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="w-24">
|
||||||
|
<span :class="['rounded px-2 py-1 text-xs font-medium', getTypeColor(item.type)]">
|
||||||
|
{{ getTypeLabel(item.type) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-24 text-right text-sm text-zinc-500 dark:text-zinc-400">
|
||||||
|
{{ formatUses(item.uses) }}
|
||||||
|
</div>
|
||||||
|
<div class="w-28 truncate text-right text-sm text-zinc-500 dark:text-zinc-400">
|
||||||
|
{{ item.author }}
|
||||||
|
</div>
|
||||||
|
<div class="w-28 text-right text-sm text-zinc-400 dark:text-zinc-500">
|
||||||
|
{{ item.updatedAt }}
|
||||||
|
</div>
|
||||||
|
<div class="w-10">
|
||||||
|
<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"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
<i class="pi pi-ellipsis-h text-sm" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
Reference in New Issue
Block a user