mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-02 06:19:58 +00:00
feat(canvas): Add run controls, share dialog, and right toolbar
- Add CanvasRunControls with Run dropdown (Run, Run on Change), Queue button - Add CanvasShareDialog with Figma-like sharing UI (invite, permissions, link) - Add CanvasRightToolbar with zoom and fit controls - Move run button from tab bar to canvas area (top-right) - Integrate share dialog with tab bar share button 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
3
ComfyUI_vibe/src/components.d.ts
vendored
3
ComfyUI_vibe/src/components.d.ts
vendored
@@ -11,6 +11,9 @@ declare module 'vue' {
|
||||
CanvasBottomBar: typeof import('./components/v2/canvas/CanvasBottomBar.vue')['default']
|
||||
CanvasLeftSidebar: typeof import('./components/v2/canvas/CanvasLeftSidebar.vue')['default']
|
||||
CanvasLogoMenu: typeof import('./components/v2/canvas/CanvasLogoMenu.vue')['default']
|
||||
CanvasRightToolbar: typeof import('./components/v2/canvas/CanvasRightToolbar.vue')['default']
|
||||
CanvasRunControls: typeof import('./components/v2/canvas/CanvasRunControls.vue')['default']
|
||||
CanvasShareDialog: typeof import('./components/v2/canvas/CanvasShareDialog.vue')['default']
|
||||
CanvasTabBar: typeof import('./components/v2/canvas/CanvasTabBar.vue')['default']
|
||||
CanvasTabs: typeof import('./components/v2/canvas/CanvasTabs.vue')['default']
|
||||
CreateProjectDialog: typeof import('./components/v2/workspace/CreateProjectDialog.vue')['default']
|
||||
|
||||
@@ -11,7 +11,7 @@ function handleTabClick(tabId: Exclude<SidebarTabId, null>): void {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav class="flex w-12 flex-col items-center border-r border-zinc-800 bg-zinc-900 py-2">
|
||||
<nav class="flex w-12 flex-col items-center border-r border-zinc-800 bg-black py-2">
|
||||
<!-- Tab buttons -->
|
||||
<div class="flex flex-col gap-1">
|
||||
<button
|
||||
|
||||
@@ -83,7 +83,7 @@ function setFilter(value: string): void {
|
||||
|
||||
<template>
|
||||
<aside
|
||||
class="border-r border-zinc-800 bg-zinc-900/95 transition-all duration-200"
|
||||
class="border-r border-zinc-800 bg-black/95 transition-all duration-200"
|
||||
:class="sidebarPanelExpanded ? 'w-80' : 'w-0 overflow-hidden'"
|
||||
>
|
||||
<!-- Library Tab - Full custom layout -->
|
||||
@@ -136,7 +136,7 @@ function setFilter(value: string): void {
|
||||
</button>
|
||||
<div
|
||||
v-if="showFilterMenu"
|
||||
class="absolute left-0 top-full z-50 mt-1 min-w-[120px] rounded-lg border border-zinc-700 bg-zinc-900 py-1 shadow-xl"
|
||||
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"
|
||||
>
|
||||
<button
|
||||
v-for="option in filterOptions"
|
||||
@@ -162,7 +162,7 @@ function setFilter(value: string): void {
|
||||
</button>
|
||||
<div
|
||||
v-if="showSortMenu"
|
||||
class="absolute right-0 top-full z-50 mt-1 min-w-[120px] rounded-lg border border-zinc-700 bg-zinc-900 py-1 shadow-xl"
|
||||
class="absolute right-0 top-full z-50 mt-1 min-w-[120px] rounded-lg border border-zinc-700 bg-black py-1 shadow-xl"
|
||||
>
|
||||
<button
|
||||
v-for="option in sortOptions"
|
||||
|
||||
@@ -146,7 +146,7 @@ const mockRecents = [
|
||||
<!-- Expandable Panel (above tabs) -->
|
||||
<div
|
||||
v-if="bottomPanelExpanded"
|
||||
class="bottom-panel flex overflow-hidden rounded-xl border border-zinc-800 bg-zinc-900/95 shadow-2xl backdrop-blur transition-all duration-300"
|
||||
class="bottom-panel flex overflow-hidden rounded-xl border border-zinc-800 bg-black/95 shadow-2xl backdrop-blur transition-all duration-300"
|
||||
:style="{
|
||||
width: isExtended ? 'calc(100vw - 100px)' : '720px',
|
||||
maxWidth: isExtended ? '1400px' : '720px',
|
||||
@@ -157,7 +157,7 @@ const mockRecents = [
|
||||
<!-- Left Sidebar -->
|
||||
<div
|
||||
v-if="showSidebar"
|
||||
class="flex w-48 shrink-0 flex-col border-r border-zinc-800 bg-zinc-900/50"
|
||||
class="flex w-48 shrink-0 flex-col border-r border-zinc-800 bg-black/50"
|
||||
>
|
||||
<div class="p-3">
|
||||
<div class="text-[10px] font-semibold uppercase tracking-wider text-zinc-500">Categories</div>
|
||||
@@ -431,7 +431,7 @@ const mockRecents = [
|
||||
</div>
|
||||
|
||||
<!-- Bottom Tab Bar -->
|
||||
<div class="flex items-center gap-1 rounded-lg border border-zinc-800 bg-zinc-900/90 px-2 py-1.5 backdrop-blur">
|
||||
<div class="flex items-center gap-1 rounded-lg border border-zinc-800 bg-black/90 px-2 py-1.5 backdrop-blur">
|
||||
<!-- Tab buttons -->
|
||||
<button
|
||||
v-for="tab in BOTTOM_BAR_TABS"
|
||||
@@ -447,6 +447,23 @@ const mockRecents = [
|
||||
>
|
||||
<i :class="[tab.icon, 'text-base']" />
|
||||
</button>
|
||||
|
||||
<!-- Divider -->
|
||||
<div class="mx-1 h-5 w-px bg-zinc-700" />
|
||||
|
||||
<!-- Settings & Shortcuts -->
|
||||
<button
|
||||
v-tooltip.top="{ value: 'Keyboard Shortcuts', showDelay: 300 }"
|
||||
class="flex h-8 w-8 items-center justify-center rounded-md text-zinc-400 transition-colors hover:bg-zinc-800 hover:text-zinc-200"
|
||||
>
|
||||
<i class="pi pi-bolt text-base" />
|
||||
</button>
|
||||
<button
|
||||
v-tooltip.top="{ value: 'Settings', showDelay: 300 }"
|
||||
class="flex h-8 w-8 items-center justify-center rounded-md text-zinc-400 transition-colors hover:bg-zinc-800 hover:text-zinc-200"
|
||||
>
|
||||
<i class="pi pi-cog text-base" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
146
ComfyUI_vibe/src/components/v2/canvas/CanvasRightToolbar.vue
Normal file
146
ComfyUI_vibe/src/components/v2/canvas/CanvasRightToolbar.vue
Normal file
@@ -0,0 +1,146 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
interface Props {
|
||||
orientation?: 'vertical' | 'horizontal'
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
orientation: 'vertical'
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
fitView: []
|
||||
zoomIn: []
|
||||
zoomOut: []
|
||||
}>()
|
||||
|
||||
// Tool mode: 'select' or 'pan'
|
||||
const toolMode = ref<'select' | 'pan'>('select')
|
||||
|
||||
// Zoom level (percentage)
|
||||
const zoomLevel = ref(75)
|
||||
|
||||
// Toggle states
|
||||
const showMinimap = ref(false)
|
||||
const showLinks = ref(true)
|
||||
|
||||
const isVertical = computed(() => props.orientation === 'vertical')
|
||||
const tooltipPos = computed(() => isVertical.value ? 'left' : 'top')
|
||||
|
||||
function setToolMode(mode: 'select' | 'pan'): void {
|
||||
toolMode.value = mode
|
||||
}
|
||||
|
||||
function handleFitView(): void {
|
||||
emit('fitView')
|
||||
}
|
||||
|
||||
function handleZoomIn(): void {
|
||||
zoomLevel.value = Math.min(400, zoomLevel.value + 25)
|
||||
emit('zoomIn')
|
||||
}
|
||||
|
||||
function handleZoomOut(): void {
|
||||
zoomLevel.value = Math.max(10, zoomLevel.value - 25)
|
||||
emit('zoomOut')
|
||||
}
|
||||
|
||||
function toggleMinimap(): void {
|
||||
showMinimap.value = !showMinimap.value
|
||||
}
|
||||
|
||||
function toggleLinks(): void {
|
||||
showLinks.value = !showLinks.value
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="absolute z-10"
|
||||
:class="isVertical ? 'right-4 top-1/2 -translate-y-1/2' : 'bottom-4 right-4'"
|
||||
>
|
||||
<div
|
||||
class="flex items-center gap-1 rounded-lg border border-zinc-800 bg-black/90 p-1.5 backdrop-blur"
|
||||
:class="isVertical ? 'flex-col' : 'flex-row'"
|
||||
>
|
||||
<!-- Select / Pan Toggle -->
|
||||
<button
|
||||
v-tooltip:[tooltipPos]="{ value: 'Select', showDelay: 300 }"
|
||||
class="flex h-8 w-8 items-center justify-center rounded-md transition-colors"
|
||||
:class="toolMode === 'select' ? 'bg-zinc-700 text-zinc-100' : 'text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200'"
|
||||
@click="setToolMode('select')"
|
||||
>
|
||||
<i class="pi pi-arrow-up-left text-base" />
|
||||
</button>
|
||||
<button
|
||||
v-tooltip:[tooltipPos]="{ value: 'Pan', showDelay: 300 }"
|
||||
class="flex h-8 w-8 items-center justify-center rounded-md transition-colors"
|
||||
:class="toolMode === 'pan' ? 'bg-zinc-700 text-zinc-100' : 'text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200'"
|
||||
@click="setToolMode('pan')"
|
||||
>
|
||||
<i class="pi pi-arrows-alt text-base" />
|
||||
</button>
|
||||
|
||||
<!-- Divider -->
|
||||
<div :class="isVertical ? 'my-1 h-px w-5 bg-zinc-700' : 'mx-1 h-5 w-px bg-zinc-700'" />
|
||||
|
||||
<!-- Fit to Screen -->
|
||||
<button
|
||||
v-tooltip:[tooltipPos]="{ value: 'Fit to Screen', showDelay: 300 }"
|
||||
class="flex h-8 w-8 items-center justify-center rounded-md text-zinc-400 transition-colors hover:bg-zinc-800 hover:text-zinc-200"
|
||||
@click="handleFitView"
|
||||
>
|
||||
<i class="pi pi-expand text-base" />
|
||||
</button>
|
||||
|
||||
<!-- Divider -->
|
||||
<div :class="isVertical ? 'my-1 h-px w-5 bg-zinc-700' : 'mx-1 h-5 w-px bg-zinc-700'" />
|
||||
|
||||
<!-- Zoom Controls -->
|
||||
<button
|
||||
v-tooltip:[tooltipPos]="{ value: 'Zoom In', showDelay: 300 }"
|
||||
class="flex h-8 w-8 items-center justify-center rounded-md text-zinc-400 transition-colors hover:bg-zinc-800 hover:text-zinc-200"
|
||||
@click="handleZoomIn"
|
||||
>
|
||||
<i class="pi pi-plus text-sm" />
|
||||
</button>
|
||||
<div
|
||||
v-tooltip:[tooltipPos]="{ value: 'Zoom Level', showDelay: 300 }"
|
||||
class="flex h-8 w-8 items-center justify-center text-[10px] font-medium text-zinc-400"
|
||||
>
|
||||
{{ zoomLevel }}%
|
||||
</div>
|
||||
<button
|
||||
v-tooltip:[tooltipPos]="{ value: 'Zoom Out', showDelay: 300 }"
|
||||
class="flex h-8 w-8 items-center justify-center rounded-md text-zinc-400 transition-colors hover:bg-zinc-800 hover:text-zinc-200"
|
||||
@click="handleZoomOut"
|
||||
>
|
||||
<i class="pi pi-minus text-sm" />
|
||||
</button>
|
||||
|
||||
<!-- Divider -->
|
||||
<div :class="isVertical ? 'my-1 h-px w-5 bg-zinc-700' : 'mx-1 h-5 w-px bg-zinc-700'" />
|
||||
|
||||
<!-- Minimap Toggle -->
|
||||
<button
|
||||
v-tooltip:[tooltipPos]="{ value: showMinimap ? 'Hide Minimap' : 'Show Minimap', showDelay: 300 }"
|
||||
class="flex h-8 w-8 items-center justify-center rounded-md transition-colors"
|
||||
:class="showMinimap ? 'bg-zinc-700 text-zinc-100' : 'text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200'"
|
||||
@click="toggleMinimap"
|
||||
>
|
||||
<i class="pi pi-map text-base" />
|
||||
</button>
|
||||
|
||||
<!-- Links Toggle -->
|
||||
<button
|
||||
v-tooltip:[tooltipPos]="{ value: showLinks ? 'Hide Links' : 'Show Links', showDelay: 300 }"
|
||||
class="flex h-8 w-8 items-center justify-center rounded-md transition-colors"
|
||||
:class="showLinks ? 'bg-zinc-700 text-zinc-100' : 'text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200'"
|
||||
@click="toggleLinks"
|
||||
>
|
||||
<i class="pi pi-link text-base" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
115
ComfyUI_vibe/src/components/v2/canvas/CanvasRunControls.vue
Normal file
115
ComfyUI_vibe/src/components/v2/canvas/CanvasRunControls.vue
Normal file
@@ -0,0 +1,115 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import Popover from 'primevue/popover'
|
||||
|
||||
const runMenu = ref<InstanceType<typeof Popover> | null>(null)
|
||||
const isRunning = ref(false)
|
||||
const queueCount = ref(0)
|
||||
|
||||
function toggleRunMenu(event: Event): void {
|
||||
runMenu.value?.toggle(event)
|
||||
}
|
||||
|
||||
function runWorkflow(): void {
|
||||
isRunning.value = true
|
||||
setTimeout(() => {
|
||||
isRunning.value = false
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
function runOnChange(): void {
|
||||
// Toggle run on change mode
|
||||
}
|
||||
|
||||
function addToQueue(): void {
|
||||
queueCount.value++
|
||||
}
|
||||
|
||||
function clearQueue(): void {
|
||||
queueCount.value = 0
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="absolute right-4 top-4 z-10 flex items-center gap-2">
|
||||
<!-- Queue indicator -->
|
||||
<div
|
||||
v-if="queueCount > 0"
|
||||
class="flex items-center gap-1.5 rounded-md bg-amber-500/20 px-2.5 py-1.5 text-xs font-medium text-amber-400"
|
||||
>
|
||||
<i class="pi pi-list text-[10px]" />
|
||||
<span>{{ queueCount }} in queue</span>
|
||||
<button
|
||||
class="ml-1 rounded p-0.5 transition-colors hover:bg-amber-500/20"
|
||||
@click="clearQueue"
|
||||
>
|
||||
<i class="pi pi-times text-[10px]" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Add to Queue -->
|
||||
<button
|
||||
v-tooltip.bottom="{ value: 'Add to Queue', showDelay: 50 }"
|
||||
class="flex h-8 items-center gap-1.5 rounded-md bg-zinc-800/80 px-3 text-sm text-zinc-300 shadow-sm backdrop-blur transition-colors hover:bg-zinc-700 hover:text-white"
|
||||
@click="addToQueue"
|
||||
>
|
||||
<i class="pi pi-plus text-xs" />
|
||||
<span>Queue</span>
|
||||
</button>
|
||||
|
||||
<!-- Run Button with Dropdown -->
|
||||
<div class="relative flex">
|
||||
<button
|
||||
class="flex h-8 items-center gap-1.5 rounded-l-md bg-blue-600 px-3 text-sm font-medium text-white shadow-sm transition-colors hover:bg-blue-500"
|
||||
:disabled="isRunning"
|
||||
@click="runWorkflow"
|
||||
>
|
||||
<i :class="['text-xs', isRunning ? 'pi pi-spin pi-spinner' : 'pi pi-play']" />
|
||||
<span>{{ isRunning ? 'Running...' : 'Run' }}</span>
|
||||
</button>
|
||||
<button
|
||||
class="flex h-8 items-center rounded-r-md border-l border-blue-500 bg-blue-600 px-1.5 text-white shadow-sm transition-colors hover:bg-blue-500"
|
||||
@click="toggleRunMenu"
|
||||
>
|
||||
<i class="pi pi-chevron-down text-[10px]" />
|
||||
</button>
|
||||
|
||||
<!-- Run Menu Popover -->
|
||||
<Popover ref="runMenu" append-to="self">
|
||||
<div class="flex w-48 flex-col py-1">
|
||||
<button
|
||||
class="flex items-center gap-2 px-3 py-2 text-sm text-zinc-700 transition-colors hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800"
|
||||
@click="runWorkflow(); runMenu?.hide()"
|
||||
>
|
||||
<i class="pi pi-play text-xs text-blue-500" />
|
||||
<span>Run Workflow</span>
|
||||
<span class="ml-auto text-xs text-zinc-400">⌘↵</span>
|
||||
</button>
|
||||
<button
|
||||
class="flex items-center gap-2 px-3 py-2 text-sm text-zinc-700 transition-colors hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800"
|
||||
@click="runOnChange(); runMenu?.hide()"
|
||||
>
|
||||
<i class="pi pi-sync text-xs text-green-500" />
|
||||
<span>Run on Change</span>
|
||||
</button>
|
||||
<div class="my-1 h-px bg-zinc-200 dark:bg-zinc-700" />
|
||||
<button
|
||||
class="flex items-center gap-2 px-3 py-2 text-sm text-zinc-700 transition-colors hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800"
|
||||
@click="addToQueue(); runMenu?.hide()"
|
||||
>
|
||||
<i class="pi pi-plus text-xs text-amber-500" />
|
||||
<span>Add to Queue</span>
|
||||
<span class="ml-auto text-xs text-zinc-400">⌘⇧↵</span>
|
||||
</button>
|
||||
<button
|
||||
class="flex items-center gap-2 px-3 py-2 text-sm text-zinc-700 transition-colors hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800"
|
||||
@click="clearQueue(); runMenu?.hide()"
|
||||
>
|
||||
<i class="pi pi-trash text-xs text-red-500" />
|
||||
<span>Clear Queue</span>
|
||||
</button>
|
||||
</div>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
298
ComfyUI_vibe/src/components/v2/canvas/CanvasShareDialog.vue
Normal file
298
ComfyUI_vibe/src/components/v2/canvas/CanvasShareDialog.vue
Normal file
@@ -0,0 +1,298 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import Dialog from 'primevue/dialog'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import Select from 'primevue/select'
|
||||
|
||||
interface SharedUser {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
avatar?: string
|
||||
initials: string
|
||||
role: 'owner' | 'editor' | 'viewer'
|
||||
color: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
workflowName?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:visible': [value: boolean]
|
||||
}>()
|
||||
|
||||
const dialogVisible = computed({
|
||||
get: () => props.visible,
|
||||
set: (value) => emit('update:visible', value)
|
||||
})
|
||||
|
||||
const inviteEmail = ref('')
|
||||
const inviteRole = ref<'editor' | 'viewer'>('editor')
|
||||
const linkCopied = ref(false)
|
||||
const linkAccess = ref<'restricted' | 'anyone'>('restricted')
|
||||
const linkPermission = ref<'viewer' | 'editor'>('viewer')
|
||||
|
||||
const roleOptions = [
|
||||
{ label: 'Can edit', value: 'editor' },
|
||||
{ label: 'Can view', value: 'viewer' }
|
||||
]
|
||||
|
||||
const linkAccessOptions = [
|
||||
{ label: 'Restricted', value: 'restricted', description: 'Only people with access can open' },
|
||||
{ label: 'Anyone with the link', value: 'anyone', description: 'Anyone on the internet with the link can view' }
|
||||
]
|
||||
|
||||
const sharedUsers = ref<SharedUser[]>([
|
||||
{ id: '1', name: 'John Doe', email: 'john@example.com', initials: 'JD', role: 'owner', color: 'bg-blue-600' },
|
||||
{ id: '2', name: 'Sarah Wilson', email: 'sarah@example.com', initials: 'SW', role: 'editor', color: 'bg-purple-600' },
|
||||
{ id: '3', name: 'Mike Chen', email: 'mike@example.com', initials: 'MC', role: 'viewer', color: 'bg-green-600' }
|
||||
])
|
||||
|
||||
const shareLink = computed(() => {
|
||||
return `https://comfy.app/share/${props.workflowName?.toLowerCase().replace(/\s+/g, '-') || 'workflow'}`
|
||||
})
|
||||
|
||||
function copyLink(): void {
|
||||
navigator.clipboard.writeText(shareLink.value)
|
||||
linkCopied.value = true
|
||||
setTimeout(() => {
|
||||
linkCopied.value = false
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
function inviteUser(): void {
|
||||
if (!inviteEmail.value) return
|
||||
|
||||
const newUser: SharedUser = {
|
||||
id: Date.now().toString(),
|
||||
name: inviteEmail.value.split('@')[0] || 'User',
|
||||
email: inviteEmail.value,
|
||||
initials: inviteEmail.value.substring(0, 2).toUpperCase(),
|
||||
role: inviteRole.value,
|
||||
color: ['bg-pink-600', 'bg-amber-600', 'bg-cyan-600', 'bg-indigo-600'][Math.floor(Math.random() * 4)] || 'bg-zinc-600'
|
||||
}
|
||||
|
||||
sharedUsers.value.push(newUser)
|
||||
inviteEmail.value = ''
|
||||
}
|
||||
|
||||
function updateUserRole(userId: string, role: 'editor' | 'viewer'): void {
|
||||
const user = sharedUsers.value.find(u => u.id === userId)
|
||||
if (user && user.role !== 'owner') {
|
||||
user.role = role
|
||||
}
|
||||
}
|
||||
|
||||
function removeUser(userId: string): void {
|
||||
const index = sharedUsers.value.findIndex(u => u.id === userId)
|
||||
if (index > -1 && sharedUsers.value[index]?.role !== 'owner') {
|
||||
sharedUsers.value.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
function getRoleLabel(role: string): string {
|
||||
switch (role) {
|
||||
case 'owner': return 'Owner'
|
||||
case 'editor': return 'Can edit'
|
||||
case 'viewer': return 'Can view'
|
||||
default: return role
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
v-model:visible="dialogVisible"
|
||||
modal
|
||||
:draggable="false"
|
||||
:closable="true"
|
||||
:pt="{
|
||||
root: { class: 'bg-white dark:bg-zinc-900 rounded-xl shadow-2xl border border-zinc-200 dark:border-zinc-800 w-[480px]' },
|
||||
header: { class: 'p-0' },
|
||||
content: { class: 'p-0' },
|
||||
mask: { class: 'backdrop-blur-sm bg-black/50' }
|
||||
}"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex w-full items-center justify-between border-b border-zinc-200 px-5 py-4 dark:border-zinc-800">
|
||||
<div>
|
||||
<h2 class="text-base font-semibold text-zinc-900 dark:text-zinc-100">Share "{{ workflowName || 'Workflow' }}"</h2>
|
||||
<p class="mt-0.5 text-sm text-zinc-500 dark:text-zinc-400">Invite others to collaborate</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="p-5">
|
||||
<!-- Invite Section -->
|
||||
<div class="mb-5">
|
||||
<label class="mb-2 block text-sm font-medium text-zinc-700 dark:text-zinc-300">
|
||||
Invite people
|
||||
</label>
|
||||
<div class="flex gap-2">
|
||||
<div class="relative flex-1">
|
||||
<i class="pi pi-envelope absolute left-3 top-1/2 -translate-y-1/2 text-sm text-zinc-400" />
|
||||
<InputText
|
||||
v-model="inviteEmail"
|
||||
placeholder="Enter email address"
|
||||
class="w-full rounded-lg border border-zinc-300 bg-white py-2.5 pl-9 pr-3 text-sm text-zinc-900 placeholder-zinc-400 outline-none transition-colors focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 dark:placeholder-zinc-500"
|
||||
@keyup.enter="inviteUser"
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
v-model="inviteRole"
|
||||
:options="roleOptions"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
class="w-32"
|
||||
:pt="{
|
||||
root: { class: 'border border-zinc-300 dark:border-zinc-700 rounded-lg' },
|
||||
label: { class: 'text-sm py-2.5 px-3' }
|
||||
}"
|
||||
/>
|
||||
<button
|
||||
class="rounded-lg bg-blue-600 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-blue-500 disabled:opacity-50"
|
||||
:disabled="!inviteEmail"
|
||||
@click="inviteUser"
|
||||
>
|
||||
Invite
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- People with access -->
|
||||
<div class="mb-5">
|
||||
<label class="mb-2 block text-sm font-medium text-zinc-700 dark:text-zinc-300">
|
||||
People with access
|
||||
</label>
|
||||
<div class="max-h-48 space-y-1 overflow-y-auto rounded-lg border border-zinc-200 dark:border-zinc-800">
|
||||
<div
|
||||
v-for="user in sharedUsers"
|
||||
:key="user.id"
|
||||
class="group flex items-center gap-3 px-3 py-2.5 transition-colors hover:bg-zinc-50 dark:hover:bg-zinc-800/50"
|
||||
>
|
||||
<!-- Avatar -->
|
||||
<div
|
||||
:class="[
|
||||
'flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full text-xs font-semibold text-white',
|
||||
user.color
|
||||
]"
|
||||
>
|
||||
{{ user.initials }}
|
||||
</div>
|
||||
|
||||
<!-- User info -->
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-sm font-medium text-zinc-900 dark:text-zinc-100">
|
||||
{{ user.name }}
|
||||
<span v-if="user.role === 'owner'" class="ml-1 text-xs text-zinc-400">(you)</span>
|
||||
</p>
|
||||
<p class="truncate text-xs text-zinc-500 dark:text-zinc-400">{{ user.email }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Role selector / Remove -->
|
||||
<div class="flex items-center gap-1">
|
||||
<Select
|
||||
v-if="user.role !== 'owner'"
|
||||
:model-value="user.role"
|
||||
:options="roleOptions"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
class="w-28"
|
||||
:pt="{
|
||||
root: { class: 'border-0 bg-transparent' },
|
||||
label: { class: 'text-xs py-1 px-2 text-zinc-500 dark:text-zinc-400' },
|
||||
trigger: { class: 'w-4' }
|
||||
}"
|
||||
@update:model-value="updateUserRole(user.id, $event)"
|
||||
/>
|
||||
<span v-else class="px-2 text-xs text-zinc-400">{{ getRoleLabel(user.role) }}</span>
|
||||
|
||||
<button
|
||||
v-if="user.role !== 'owner'"
|
||||
class="rounded p-1 text-zinc-400 opacity-0 transition-all hover:bg-zinc-200 hover:text-zinc-600 group-hover:opacity-100 dark:hover:bg-zinc-700 dark:hover:text-zinc-300"
|
||||
@click="removeUser(user.id)"
|
||||
>
|
||||
<i class="pi pi-times text-xs" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Link sharing section -->
|
||||
<div class="rounded-lg border border-zinc-200 bg-zinc-50 p-4 dark:border-zinc-800 dark:bg-zinc-800/50">
|
||||
<div class="mb-3 flex items-start gap-3">
|
||||
<div class="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg bg-zinc-200 dark:bg-zinc-700">
|
||||
<i class="pi pi-link text-zinc-600 dark:text-zinc-400" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<Select
|
||||
v-model="linkAccess"
|
||||
:options="linkAccessOptions"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
class="flex-1"
|
||||
:pt="{
|
||||
root: { class: 'border-0 bg-transparent' },
|
||||
label: { class: 'text-sm font-medium py-0 px-0 text-zinc-900 dark:text-zinc-100' },
|
||||
trigger: { class: 'w-4' }
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
<p class="mt-0.5 text-xs text-zinc-500 dark:text-zinc-400">
|
||||
{{ linkAccess === 'restricted' ? 'Only people with access can open' : 'Anyone on the internet with the link' }}
|
||||
</p>
|
||||
</div>
|
||||
<Select
|
||||
v-if="linkAccess === 'anyone'"
|
||||
v-model="linkPermission"
|
||||
:options="roleOptions"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
class="w-28"
|
||||
:pt="{
|
||||
root: { class: 'border border-zinc-300 dark:border-zinc-700 rounded-lg' },
|
||||
label: { class: 'text-xs py-1.5 px-2' }
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Copy link -->
|
||||
<div class="flex gap-2">
|
||||
<div class="flex-1 truncate rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-600 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-400">
|
||||
{{ shareLink }}
|
||||
</div>
|
||||
<button
|
||||
:class="[
|
||||
'flex items-center gap-1.5 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
|
||||
linkCopied
|
||||
? 'bg-green-600 text-white'
|
||||
: 'bg-zinc-900 text-white hover:bg-zinc-800 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200'
|
||||
]"
|
||||
@click="copyLink"
|
||||
>
|
||||
<i :class="['text-xs', linkCopied ? 'pi pi-check' : 'pi pi-copy']" />
|
||||
{{ linkCopied ? 'Copied!' : 'Copy link' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex items-center justify-between border-t border-zinc-200 px-5 py-4 dark:border-zinc-800">
|
||||
<button class="flex items-center gap-1.5 text-sm text-zinc-500 transition-colors hover:text-zinc-700 dark:text-zinc-400 dark:hover:text-zinc-200">
|
||||
<i class="pi pi-cog text-xs" />
|
||||
Advanced settings
|
||||
</button>
|
||||
<button
|
||||
class="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-500"
|
||||
@click="dialogVisible = false"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -1,10 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import CanvasLogoMenu from './CanvasLogoMenu.vue'
|
||||
import CanvasTabs, { type CanvasTab } from './CanvasTabs.vue'
|
||||
import CanvasShareDialog from './CanvasShareDialog.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const showShareDialog = ref(false)
|
||||
|
||||
const tabs = ref<CanvasTab[]>([
|
||||
{ id: 'workflow-1', name: 'Main Workflow', isActive: true },
|
||||
@@ -51,6 +53,10 @@ function createNewTab(): void {
|
||||
})
|
||||
selectTab(newId)
|
||||
}
|
||||
|
||||
const activeWorkflowName = computed(() => {
|
||||
return tabs.value.find(t => t.id === activeTabId.value)?.name || 'Workflow'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -97,15 +103,16 @@ function createNewTab(): void {
|
||||
<button
|
||||
v-tooltip.bottom="{ value: 'Share', showDelay: 50 }"
|
||||
class="flex h-8 w-8 items-center justify-center rounded-md text-zinc-500 transition-colors hover:bg-zinc-800 hover:text-zinc-100"
|
||||
@click="showShareDialog = true"
|
||||
>
|
||||
<i class="pi pi-share-alt text-sm" />
|
||||
</button>
|
||||
<button
|
||||
v-tooltip.bottom="{ value: 'Run Workflow', showDelay: 50 }"
|
||||
class="flex h-8 w-8 items-center justify-center rounded-md bg-blue-600 text-white transition-colors hover:bg-blue-500"
|
||||
>
|
||||
<i class="pi pi-play text-sm" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Share Dialog -->
|
||||
<CanvasShareDialog
|
||||
v-model:visible="showShareDialog"
|
||||
:workflow-name="activeWorkflowName"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -131,7 +131,7 @@ const filteredWorkflows = computed(() => {
|
||||
</button>
|
||||
<div
|
||||
v-if="showFilterMenu"
|
||||
class="absolute left-0 top-full z-50 mt-1 min-w-[120px] rounded-lg border border-zinc-700 bg-zinc-900 py-1 shadow-xl"
|
||||
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"
|
||||
>
|
||||
<button
|
||||
v-for="option in filterOptions"
|
||||
@@ -157,7 +157,7 @@ const filteredWorkflows = computed(() => {
|
||||
</button>
|
||||
<div
|
||||
v-if="showSortMenu"
|
||||
class="absolute right-0 top-full z-50 mt-1 min-w-[120px] rounded-lg border border-zinc-700 bg-zinc-900 py-1 shadow-xl"
|
||||
class="absolute right-0 top-full z-50 mt-1 min-w-[120px] rounded-lg border border-zinc-700 bg-black py-1 shadow-xl"
|
||||
>
|
||||
<button
|
||||
v-for="option in sortOptions"
|
||||
@@ -177,7 +177,7 @@ const filteredWorkflows = computed(() => {
|
||||
<!-- Content -->
|
||||
<div class="flex-1 overflow-y-auto p-2">
|
||||
<!-- Team Header Card -->
|
||||
<div class="mb-3 rounded-lg border border-zinc-800 bg-zinc-900 p-2.5">
|
||||
<div class="mb-3 rounded-lg border border-zinc-800 bg-black p-2.5">
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg text-lg font-bold"
|
||||
|
||||
@@ -34,7 +34,7 @@ function handleNodeLeave(): void {
|
||||
|
||||
<template>
|
||||
<!-- Level 1: Category Icon Bar -->
|
||||
<nav class="flex w-12 flex-col items-center border-r border-zinc-800 bg-zinc-900 py-2">
|
||||
<nav class="flex w-12 flex-col items-center border-r border-zinc-800 bg-black py-2">
|
||||
<div class="flex flex-1 flex-col gap-0.5 overflow-y-auto scrollbar-hide">
|
||||
<button
|
||||
v-for="category in NODE_CATEGORIES"
|
||||
@@ -60,17 +60,17 @@ function handleNodeLeave(): void {
|
||||
|
||||
<div class="mt-auto flex flex-col gap-1 pt-2">
|
||||
<button
|
||||
v-tooltip.right="{ value: 'Settings', showDelay: 50 }"
|
||||
v-tooltip.right="{ value: 'Help', showDelay: 50 }"
|
||||
class="flex h-8 w-8 items-center justify-center rounded-md text-zinc-500 transition-colors hover:bg-zinc-800 hover:text-zinc-200"
|
||||
>
|
||||
<i class="pi pi-cog text-xs" />
|
||||
<i class="pi pi-question-circle text-xs" />
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Level 2: Subcategory Panel -->
|
||||
<aside
|
||||
class="border-r border-zinc-800 bg-zinc-900/98 transition-all duration-200 ease-out"
|
||||
class="border-r border-zinc-800 bg-black/98 transition-all duration-200 ease-out"
|
||||
:class="nodePanelExpanded ? 'w-72' : 'w-0 overflow-hidden'"
|
||||
>
|
||||
<div v-if="nodePanelExpanded && activeNodeCategoryData" class="flex h-full w-72 flex-col">
|
||||
@@ -156,7 +156,7 @@ function handleNodeLeave(): void {
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="hoveredNode && nodePanelExpanded"
|
||||
class="pointer-events-none fixed z-50 ml-2 w-64 rounded-lg border border-zinc-700 bg-zinc-900 p-3 shadow-xl"
|
||||
class="pointer-events-none fixed z-50 ml-2 w-64 rounded-lg border border-zinc-700 bg-black p-3 shadow-xl"
|
||||
:style="{ top: `${previewPosition.top}px`, left: 'calc(48px + 288px + 8px)' }"
|
||||
>
|
||||
<div class="mb-2 flex items-center gap-2">
|
||||
|
||||
@@ -8,6 +8,8 @@ import '@vue-flow/core/dist/theme-default.css'
|
||||
import CanvasTabBar from '@/components/v2/canvas/CanvasTabBar.vue'
|
||||
import CanvasLeftSidebar from '@/components/v2/canvas/CanvasLeftSidebar.vue'
|
||||
import CanvasBottomBar from '@/components/v2/canvas/CanvasBottomBar.vue'
|
||||
import CanvasRightToolbar from '@/components/v2/canvas/CanvasRightToolbar.vue'
|
||||
import CanvasRunControls from '@/components/v2/canvas/CanvasRunControls.vue'
|
||||
import NodePropertiesPanel from '@/components/v2/canvas/NodePropertiesPanel.vue'
|
||||
import { FlowNode } from '@/components/v2/nodes'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
@@ -36,7 +38,7 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
// Vue Flow
|
||||
const { onNodeClick, onPaneClick, fitView } = useVueFlow()
|
||||
const { onNodeClick, onPaneClick, fitView, zoomIn, zoomOut } = useVueFlow()
|
||||
|
||||
// Center the workflow on mount with 50% zoom
|
||||
onMounted(() => {
|
||||
@@ -109,6 +111,14 @@ function closePropertiesPanel(): void {
|
||||
<!-- Interface 2.0: Floating bottom bar -->
|
||||
<CanvasBottomBar v-if="isInterface2" />
|
||||
|
||||
<!-- Right toolbar: vertical for v2, horizontal for v1 -->
|
||||
<CanvasRightToolbar
|
||||
:orientation="isInterface2 ? 'vertical' : 'horizontal'"
|
||||
@fit-view="fitView({ padding: 0.3 })"
|
||||
@zoom-in="zoomIn()"
|
||||
@zoom-out="zoomOut()"
|
||||
/>
|
||||
|
||||
<!-- Workflow name -->
|
||||
<div class="absolute left-4 top-4 z-10">
|
||||
<span
|
||||
@@ -117,6 +127,9 @@ function closePropertiesPanel(): void {
|
||||
{{ props.canvasId }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Run Controls (top-right) -->
|
||||
<CanvasRunControls />
|
||||
</main>
|
||||
|
||||
<!-- Right sidebar - Node Properties -->
|
||||
|
||||
Reference in New Issue
Block a user