mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-02 14:27:40 +00:00
feat(linear): Add Linear Mode - simplified workflow interface
- Add LinearView as main entry point for linear mode - Add LinearWorkspace with template selector and creation panels - Add LinearIconSidebar with navigation (create, history, settings) - Add LinearTemplateSelector with category filters and template cards - Add LinearCreationPanel with step-by-step workflow builder - Add LinearInputPanel for image/text input with drag-drop support - Add LinearParameterPanel for model and generation settings - Add LinearOutputGallery for generated results display - Add LinearHistoryPanel for generation history - Add LinearStepCard, LinearTemplateCard components - Add linear mode route (/linear) - Add linear mode toggle in workspace sidebar - Update components.d.ts with linear components 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
11
ComfyUI_vibe/src/components.d.ts
vendored
11
ComfyUI_vibe/src/components.d.ts
vendored
@@ -21,6 +21,17 @@ declare module 'vue' {
|
||||
LibraryNodesSection: typeof import('./components/v1/sidebar/LibraryNodesSection.vue')['default']
|
||||
LibrarySidebar: typeof import('./components/v2/canvas/LibrarySidebar.vue')['default']
|
||||
LibraryWorkflowsSection: typeof import('./components/v1/sidebar/LibraryWorkflowsSection.vue')['default']
|
||||
LinearCreationPanel: typeof import('./components/linear/LinearCreationPanel.vue')['default']
|
||||
LinearHistoryPanel: typeof import('./components/linear/LinearHistoryPanel.vue')['default']
|
||||
LinearIconSidebar: typeof import('./components/linear/LinearIconSidebar.vue')['default']
|
||||
LinearInputPanel: typeof import('./components/linear/LinearInputPanel.vue')['default']
|
||||
LinearOutputGallery: typeof import('./components/linear/LinearOutputGallery.vue')['default']
|
||||
LinearParameterPanel: typeof import('./components/linear/LinearParameterPanel.vue')['default']
|
||||
LinearStepCard: typeof import('./components/linear/LinearStepCard.vue')['default']
|
||||
LinearTemplateCard: typeof import('./components/linear/LinearTemplateCard.vue')['default']
|
||||
LinearTemplateSelector: typeof import('./components/linear/LinearTemplateSelector.vue')['default']
|
||||
LinearWorkflowSidebar: typeof import('./components/linear/LinearWorkflowSidebar.vue')['default']
|
||||
LinearWorkspace: typeof import('./components/linear/LinearWorkspace.vue')['default']
|
||||
ModelsTab: typeof import('./components/v2/workspace/ModelsTab.vue')['default']
|
||||
NodeHeader: typeof import('./components/v2/nodes/NodeHeader.vue')['default']
|
||||
NodePropertiesPanel: typeof import('./components/v2/canvas/NodePropertiesPanel.vue')['default']
|
||||
|
||||
469
ComfyUI_vibe/src/components/linear/LinearCreationPanel.vue
Normal file
469
ComfyUI_vibe/src/components/linear/LinearCreationPanel.vue
Normal file
@@ -0,0 +1,469 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useLinearModeStore } from '@/stores/linearModeStore'
|
||||
import { TEMPLATE_CATEGORIES } from '@/data/linearTemplates'
|
||||
import type { LinearWorkflowTemplate } from '@/types/linear'
|
||||
import type { WidgetDefinition } from '@/types/node'
|
||||
import WidgetSlider from '@/components/v2/nodes/widgets/WidgetSlider.vue'
|
||||
import WidgetNumber from '@/components/v2/nodes/widgets/WidgetNumber.vue'
|
||||
import WidgetText from '@/components/v2/nodes/widgets/WidgetText.vue'
|
||||
import WidgetSelect from '@/components/v2/nodes/widgets/WidgetSelect.vue'
|
||||
import WidgetToggle from '@/components/v2/nodes/widgets/WidgetToggle.vue'
|
||||
|
||||
const store = useLinearModeStore()
|
||||
|
||||
// Mode tabs (Image / Video like Runway)
|
||||
const activeMode = ref<'image' | 'video'>('image')
|
||||
|
||||
// Workflow selection
|
||||
const showWorkflowSelector = ref(false)
|
||||
const searchQuery = ref('')
|
||||
const selectedCategory = ref<string | null>(null)
|
||||
|
||||
// Settings
|
||||
const showAdvanced = ref(false)
|
||||
|
||||
const workflow = computed(() => store.currentWorkflow)
|
||||
const isGenerating = computed(() => store.isGenerating)
|
||||
|
||||
const filteredTemplates = computed(() => {
|
||||
let templates = store.templates
|
||||
|
||||
if (selectedCategory.value) {
|
||||
templates = templates.filter((t) => t.category === selectedCategory.value)
|
||||
}
|
||||
|
||||
if (searchQuery.value) {
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
templates = templates.filter(
|
||||
(t) =>
|
||||
t.name.toLowerCase().includes(query) ||
|
||||
t.description.toLowerCase().includes(query)
|
||||
)
|
||||
}
|
||||
|
||||
return templates
|
||||
})
|
||||
|
||||
// Group widgets into basic (prompt) and advanced (everything else)
|
||||
const promptWidget = computed(() => {
|
||||
if (!workflow.value) return null
|
||||
|
||||
for (const step of workflow.value.steps) {
|
||||
if (!step.definition) continue
|
||||
|
||||
for (const widgetName of step.exposedWidgets) {
|
||||
const widget = step.definition.widgets.find((w) => w.name === widgetName)
|
||||
if (widget?.type === 'textarea') {
|
||||
return {
|
||||
stepId: step.id,
|
||||
widget,
|
||||
value: step.widgetValues[widgetName],
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
const advancedWidgets = computed(() => {
|
||||
if (!workflow.value) return []
|
||||
|
||||
const advanced: Array<{
|
||||
stepId: string
|
||||
stepName: string
|
||||
widget: WidgetDefinition
|
||||
value: unknown
|
||||
}> = []
|
||||
|
||||
for (const step of workflow.value.steps) {
|
||||
if (!step.definition) continue
|
||||
|
||||
for (const widgetName of step.exposedWidgets) {
|
||||
const widget = step.definition.widgets.find((w) => w.name === widgetName)
|
||||
if (!widget || widget.type === 'textarea') continue
|
||||
|
||||
advanced.push({
|
||||
stepId: step.id,
|
||||
stepName: step.displayName,
|
||||
widget,
|
||||
value: step.widgetValues[widgetName],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return advanced
|
||||
})
|
||||
|
||||
function selectTemplate(template: LinearWorkflowTemplate): void {
|
||||
store.selectTemplate(template)
|
||||
showWorkflowSelector.value = false
|
||||
}
|
||||
|
||||
function updateWidget(stepId: string, widgetName: string, value: unknown): void {
|
||||
store.updateStepWidget(stepId, widgetName, value)
|
||||
}
|
||||
|
||||
function getWidgetComponent(type: WidgetDefinition['type']): unknown {
|
||||
switch (type) {
|
||||
case 'slider':
|
||||
return WidgetSlider
|
||||
case 'number':
|
||||
return WidgetNumber
|
||||
case 'text':
|
||||
return WidgetText
|
||||
case 'select':
|
||||
return WidgetSelect
|
||||
case 'toggle':
|
||||
return WidgetToggle
|
||||
default:
|
||||
return WidgetText
|
||||
}
|
||||
}
|
||||
|
||||
function handleGenerate(): void {
|
||||
store.startGeneration()
|
||||
}
|
||||
|
||||
function handleCancel(): void {
|
||||
store.cancelGeneration()
|
||||
}
|
||||
|
||||
function randomizeSeed(): void {
|
||||
if (!workflow.value) return
|
||||
|
||||
for (const step of workflow.value.steps) {
|
||||
if (step.exposedWidgets.includes('seed')) {
|
||||
const randomSeed = Math.floor(Math.random() * 2147483647)
|
||||
store.updateStepWidget(step.id, 'seed', randomSeed)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="flex h-full w-96 flex-col border-r border-zinc-800 bg-zinc-950">
|
||||
<!-- Mode Tabs (Image / Video) -->
|
||||
<div class="flex items-center gap-2 border-b border-zinc-800 px-3 py-2">
|
||||
<button
|
||||
:class="[
|
||||
'flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-colors',
|
||||
activeMode === 'image'
|
||||
? 'bg-zinc-800 text-zinc-100'
|
||||
: 'text-zinc-500 hover:text-zinc-300'
|
||||
]"
|
||||
@click="activeMode = 'image'"
|
||||
>
|
||||
<i class="pi pi-image text-xs" />
|
||||
Image
|
||||
</button>
|
||||
<button
|
||||
:class="[
|
||||
'flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-colors',
|
||||
activeMode === 'video'
|
||||
? 'bg-zinc-800 text-zinc-100'
|
||||
: 'text-zinc-500 hover:text-zinc-300'
|
||||
]"
|
||||
@click="activeMode = 'video'"
|
||||
>
|
||||
<i class="pi pi-video text-xs" />
|
||||
Video
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Scrollable Content -->
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<!-- Image Upload Area -->
|
||||
<div class="border-b border-zinc-800 p-3">
|
||||
<div
|
||||
class="flex h-48 flex-col items-center justify-center rounded-lg border-2 border-dashed border-zinc-700 bg-zinc-900/50 transition-colors hover:border-zinc-600 hover:bg-zinc-900"
|
||||
>
|
||||
<i class="pi pi-cloud-upload mb-2 text-2xl text-zinc-500" />
|
||||
<p class="text-xs text-zinc-400">Drop an image or click to upload</p>
|
||||
<div class="mt-2 flex gap-2">
|
||||
<button class="rounded bg-zinc-800 px-2.5 py-1 text-[10px] font-medium text-zinc-300 transition-colors hover:bg-zinc-700">
|
||||
Select asset
|
||||
</button>
|
||||
<button class="rounded bg-zinc-800 px-2.5 py-1 text-[10px] font-medium text-zinc-300 transition-colors hover:bg-zinc-700">
|
||||
Create image
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Prompt Input -->
|
||||
<div class="border-b border-zinc-800 p-3">
|
||||
<textarea
|
||||
v-if="promptWidget"
|
||||
:value="String(promptWidget.value ?? '')"
|
||||
:placeholder="'Drop an image to animate. Or drop a video to use Aleph. View guide'"
|
||||
class="min-h-[80px] w-full resize-none rounded-lg border border-zinc-800 bg-zinc-900 p-3 text-sm text-zinc-200 outline-none transition-colors placeholder:text-zinc-600 focus:border-zinc-600"
|
||||
@input="updateWidget(promptWidget.stepId, promptWidget.widget.name, ($event.target as HTMLTextAreaElement).value)"
|
||||
/>
|
||||
<textarea
|
||||
v-else
|
||||
placeholder="Describe your idea..."
|
||||
class="min-h-[80px] w-full resize-none rounded-lg border border-zinc-800 bg-zinc-900 p-3 text-sm text-zinc-200 outline-none transition-colors placeholder:text-zinc-600 focus:border-zinc-600"
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Workflow Selector -->
|
||||
<div class="border-b border-zinc-800 p-3">
|
||||
<button
|
||||
class="flex w-full items-center justify-between rounded-lg bg-zinc-900 px-3 py-2 text-left transition-colors hover:bg-zinc-800"
|
||||
@click="showWorkflowSelector = !showWorkflowSelector"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="pi pi-sitemap text-xs text-zinc-500" />
|
||||
<span class="text-xs font-medium text-zinc-300">
|
||||
{{ store.selectedTemplate?.name ?? 'Select Workflow' }}
|
||||
</span>
|
||||
</div>
|
||||
<i
|
||||
:class="[
|
||||
'pi text-xs text-zinc-500 transition-transform',
|
||||
showWorkflowSelector ? 'pi-chevron-up' : 'pi-chevron-down'
|
||||
]"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<!-- Workflow Dropdown -->
|
||||
<div
|
||||
v-if="showWorkflowSelector"
|
||||
class="mt-2 rounded-lg border border-zinc-800 bg-zinc-900"
|
||||
>
|
||||
<!-- Search -->
|
||||
<div class="border-b border-zinc-800 p-2">
|
||||
<div class="flex items-center rounded bg-zinc-800 px-2 py-1.5">
|
||||
<i class="pi pi-search text-xs text-zinc-500" />
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
placeholder="Search workflows..."
|
||||
class="ml-2 w-full bg-transparent text-xs text-zinc-300 outline-none placeholder:text-zinc-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Categories -->
|
||||
<div class="flex flex-wrap gap-1 border-b border-zinc-800 p-2">
|
||||
<button
|
||||
:class="[
|
||||
'rounded px-2 py-1 text-[10px] font-medium transition-colors',
|
||||
!selectedCategory
|
||||
? 'bg-zinc-700 text-zinc-100'
|
||||
: 'text-zinc-400 hover:bg-zinc-800'
|
||||
]"
|
||||
@click="selectedCategory = null"
|
||||
>
|
||||
All
|
||||
</button>
|
||||
<button
|
||||
v-for="cat in TEMPLATE_CATEGORIES.slice(0, 4)"
|
||||
:key="cat.id"
|
||||
:class="[
|
||||
'rounded px-2 py-1 text-[10px] font-medium transition-colors',
|
||||
selectedCategory === cat.id
|
||||
? 'bg-zinc-700 text-zinc-100'
|
||||
: 'text-zinc-400 hover:bg-zinc-800'
|
||||
]"
|
||||
@click="selectedCategory = cat.id"
|
||||
>
|
||||
{{ cat.name }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Template List -->
|
||||
<div class="max-h-48 overflow-y-auto p-2">
|
||||
<button
|
||||
v-for="template in filteredTemplates"
|
||||
:key="template.id"
|
||||
:class="[
|
||||
'flex w-full items-center gap-2 rounded-lg p-2 text-left transition-colors',
|
||||
store.selectedTemplate?.id === template.id
|
||||
? 'bg-zinc-800'
|
||||
: 'hover:bg-zinc-800/50'
|
||||
]"
|
||||
@click="selectTemplate(template)"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'flex h-8 w-8 shrink-0 items-center justify-center rounded',
|
||||
store.selectedTemplate?.id === template.id
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-zinc-700 text-zinc-400'
|
||||
]"
|
||||
>
|
||||
<i :class="['pi', template.icon, 'text-xs']" />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-xs font-medium text-zinc-200">
|
||||
{{ template.name }}
|
||||
</div>
|
||||
<div class="truncate text-[10px] text-zinc-500">
|
||||
{{ template.steps.length }} steps
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Settings -->
|
||||
<div class="border-b border-zinc-800 p-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
class="flex items-center gap-1.5 rounded bg-zinc-900 px-2.5 py-1.5 text-[10px] font-medium text-zinc-400 transition-colors hover:bg-zinc-800 hover:text-zinc-200"
|
||||
>
|
||||
<i class="pi pi-pencil text-[10px]" />
|
||||
Prompt
|
||||
</button>
|
||||
<button
|
||||
class="flex items-center gap-1.5 rounded bg-zinc-900 px-2.5 py-1.5 text-[10px] font-medium text-zinc-400 transition-colors hover:bg-zinc-800 hover:text-zinc-200"
|
||||
>
|
||||
<i class="pi pi-sparkles text-[10px]" />
|
||||
Act-Two
|
||||
</button>
|
||||
<div class="flex-1" />
|
||||
<button
|
||||
class="flex items-center gap-1 rounded bg-zinc-900 px-2 py-1.5 text-[10px] text-zinc-500 transition-colors hover:bg-zinc-800 hover:text-zinc-300"
|
||||
>
|
||||
<i class="pi pi-desktop text-[10px]" />
|
||||
16:9
|
||||
</button>
|
||||
<button
|
||||
class="flex h-7 w-7 items-center justify-center rounded bg-zinc-900 text-zinc-500 transition-colors hover:bg-zinc-800 hover:text-zinc-300"
|
||||
>
|
||||
<i class="pi pi-sliders-h text-[10px]" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Advanced Settings -->
|
||||
<div class="p-3">
|
||||
<button
|
||||
class="flex w-full items-center justify-between rounded-lg bg-zinc-900 px-3 py-2 text-left transition-colors hover:bg-zinc-800"
|
||||
@click="showAdvanced = !showAdvanced"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="pi pi-cog text-xs text-zinc-500" />
|
||||
<span class="text-xs font-medium text-zinc-300">Advanced Settings</span>
|
||||
<span
|
||||
v-if="advancedWidgets.length"
|
||||
class="rounded bg-zinc-700 px-1.5 py-0.5 text-[9px] text-zinc-400"
|
||||
>
|
||||
{{ advancedWidgets.length }}
|
||||
</span>
|
||||
</div>
|
||||
<i
|
||||
:class="[
|
||||
'pi text-xs text-zinc-500 transition-transform',
|
||||
showAdvanced ? 'pi-chevron-up' : 'pi-chevron-down'
|
||||
]"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<!-- Advanced Widgets -->
|
||||
<div
|
||||
v-if="showAdvanced && advancedWidgets.length"
|
||||
class="mt-3 space-y-3 rounded-lg border border-zinc-800 bg-zinc-900/50 p-3"
|
||||
>
|
||||
<!-- Quick Actions -->
|
||||
<div class="flex items-center gap-2 border-b border-zinc-800 pb-3">
|
||||
<button
|
||||
class="flex items-center gap-1.5 rounded bg-zinc-800 px-2 py-1 text-[10px] text-zinc-400 transition-colors hover:bg-zinc-700 hover:text-zinc-200"
|
||||
@click="randomizeSeed"
|
||||
>
|
||||
<i class="pi pi-sync text-[10px]" />
|
||||
Random Seed
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Widgets -->
|
||||
<div
|
||||
v-for="(item, index) in advancedWidgets"
|
||||
:key="`${item.stepId}-${item.widget.name}`"
|
||||
class="space-y-1.5"
|
||||
>
|
||||
<div
|
||||
v-if="index === 0 || advancedWidgets[index - 1]?.stepId !== item.stepId"
|
||||
class="mb-2 text-[10px] font-semibold uppercase tracking-wide text-zinc-500"
|
||||
>
|
||||
{{ item.stepName }}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="w-20 shrink-0 text-right text-[11px] text-zinc-500">
|
||||
{{ item.widget.label ?? item.widget.name }}
|
||||
</label>
|
||||
<div class="flex-1">
|
||||
<component
|
||||
:is="getWidgetComponent(item.widget.type)"
|
||||
:widget="item.widget"
|
||||
:model-value="item.value"
|
||||
@update:model-value="updateWidget(item.stepId, item.widget.name, $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom Bar: Model + Generate -->
|
||||
<div class="flex items-center gap-2 border-t border-zinc-800 p-3">
|
||||
<!-- Model Selector -->
|
||||
<button class="flex items-center gap-2 rounded-lg bg-zinc-900 px-3 py-2 text-left transition-colors hover:bg-zinc-800">
|
||||
<div class="flex h-6 w-6 items-center justify-center rounded bg-blue-600">
|
||||
<i class="pi pi-star text-[10px] text-white" />
|
||||
</div>
|
||||
<span class="text-xs font-medium text-zinc-300">Gen-4 Turbo</span>
|
||||
<i class="pi pi-chevron-down text-[10px] text-zinc-500" />
|
||||
</button>
|
||||
|
||||
<!-- Duration -->
|
||||
<button class="flex items-center gap-1 rounded-lg bg-zinc-900 px-3 py-2 text-xs text-zinc-400 transition-colors hover:bg-zinc-800">
|
||||
5s
|
||||
<i class="pi pi-chevron-down text-[10px]" />
|
||||
</button>
|
||||
|
||||
<!-- Spacer -->
|
||||
<div class="flex-1" />
|
||||
|
||||
<!-- Generate Button -->
|
||||
<button
|
||||
v-if="!isGenerating"
|
||||
:disabled="!store.selectedTemplate"
|
||||
:class="[
|
||||
'flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-colors',
|
||||
store.selectedTemplate
|
||||
? 'bg-blue-600 text-white hover:bg-blue-500'
|
||||
: 'cursor-not-allowed bg-zinc-800 text-zinc-500'
|
||||
]"
|
||||
@click="handleGenerate"
|
||||
>
|
||||
<i class="pi pi-video text-xs" />
|
||||
Generate
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="flex items-center gap-2 rounded-lg bg-red-600/20 px-4 py-2 text-sm font-medium text-red-400 transition-colors hover:bg-red-600/30"
|
||||
@click="handleCancel"
|
||||
>
|
||||
<i class="pi pi-times text-xs" />
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Progress Bar (when generating) -->
|
||||
<div v-if="isGenerating" class="border-t border-zinc-800 px-3 py-2">
|
||||
<div class="h-1 overflow-hidden rounded-full bg-zinc-800">
|
||||
<div
|
||||
class="h-full rounded-full bg-blue-600 transition-all duration-300"
|
||||
:style="{ width: `${store.executionProgress}%` }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
217
ComfyUI_vibe/src/components/linear/LinearHistoryPanel.vue
Normal file
217
ComfyUI_vibe/src/components/linear/LinearHistoryPanel.vue
Normal file
@@ -0,0 +1,217 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useLinearModeStore } from '@/stores/linearModeStore'
|
||||
import type { LinearOutput } from '@/types/linear'
|
||||
|
||||
const store = useLinearModeStore()
|
||||
|
||||
const activeTab = ref<'queue' | 'history'>('queue')
|
||||
|
||||
const outputs = computed(() => store.outputs)
|
||||
const isGenerating = computed(() => store.isGenerating)
|
||||
const currentWorkflow = computed(() => store.currentWorkflow)
|
||||
|
||||
// Mock queue items
|
||||
const queueItems = computed(() => {
|
||||
if (!isGenerating.value || !currentWorkflow.value) return []
|
||||
|
||||
return [
|
||||
{
|
||||
id: currentWorkflow.value.id,
|
||||
name: currentWorkflow.value.templateName,
|
||||
status: 'running' as const,
|
||||
progress: store.executionProgress,
|
||||
currentStep: currentWorkflow.value.currentStepIndex + 1,
|
||||
totalSteps: currentWorkflow.value.steps.length,
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
function formatTime(date: Date): string {
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}).format(date)
|
||||
}
|
||||
|
||||
function handleDownload(output: LinearOutput): void {
|
||||
const link = document.createElement('a')
|
||||
link.href = output.url
|
||||
link.download = output.filename
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
}
|
||||
|
||||
function handleDelete(outputId: string): void {
|
||||
store.deleteOutput(outputId)
|
||||
}
|
||||
|
||||
function handleClearHistory(): void {
|
||||
store.clearOutputs()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Main content area - takes remaining space -->
|
||||
<main class="flex h-full flex-1 flex-col bg-zinc-950">
|
||||
<!-- Tabs -->
|
||||
<div class="flex border-b border-zinc-800">
|
||||
<button
|
||||
:class="[
|
||||
'flex-1 px-4 py-2.5 text-xs font-medium transition-colors',
|
||||
activeTab === 'queue'
|
||||
? 'border-b-2 border-blue-600 text-zinc-100'
|
||||
: 'text-zinc-500 hover:text-zinc-300'
|
||||
]"
|
||||
@click="activeTab = 'queue'"
|
||||
>
|
||||
Queue
|
||||
<span
|
||||
v-if="queueItems.length"
|
||||
class="ml-1.5 rounded-full bg-blue-600 px-1.5 py-0.5 text-[10px] text-white"
|
||||
>
|
||||
{{ queueItems.length }}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
:class="[
|
||||
'flex-1 px-4 py-2.5 text-xs font-medium transition-colors',
|
||||
activeTab === 'history'
|
||||
? 'border-b-2 border-blue-600 text-zinc-100'
|
||||
: 'text-zinc-500 hover:text-zinc-300'
|
||||
]"
|
||||
@click="activeTab = 'history'"
|
||||
>
|
||||
History
|
||||
<span
|
||||
v-if="outputs.length"
|
||||
class="ml-1.5 rounded bg-zinc-700 px-1.5 py-0.5 text-[10px] text-zinc-400"
|
||||
>
|
||||
{{ outputs.length }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Queue View -->
|
||||
<div v-if="activeTab === 'queue'" class="flex-1 overflow-y-auto">
|
||||
<!-- Active Queue Items -->
|
||||
<div v-if="queueItems.length" class="p-3">
|
||||
<div
|
||||
v-for="item in queueItems"
|
||||
:key="item.id"
|
||||
class="rounded-lg border border-zinc-800 bg-zinc-800/50 p-3"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="h-2 w-2 animate-pulse rounded-full bg-blue-500" />
|
||||
<span class="text-xs font-medium text-zinc-200">{{ item.name }}</span>
|
||||
</div>
|
||||
<span class="text-[10px] text-zinc-500">
|
||||
Step {{ item.currentStep }}/{{ item.totalSteps }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Progress -->
|
||||
<div class="mt-2">
|
||||
<div class="h-1 overflow-hidden rounded-full bg-zinc-700">
|
||||
<div
|
||||
class="h-full rounded-full bg-blue-600 transition-all duration-300"
|
||||
:style="{ width: `${item.progress}%` }"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-1 text-right text-[10px] text-zinc-500">
|
||||
{{ Math.round(item.progress) }}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty Queue -->
|
||||
<div
|
||||
v-else
|
||||
class="flex flex-col items-center justify-center py-12 text-zinc-500"
|
||||
>
|
||||
<i class="pi pi-clock mb-2 text-2xl" />
|
||||
<span class="text-xs">Queue is empty</span>
|
||||
<p class="mt-1 text-center text-[10px] text-zinc-600">
|
||||
Generated images will appear here
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- History View -->
|
||||
<div v-else class="flex flex-1 flex-col overflow-hidden">
|
||||
<!-- History Header -->
|
||||
<div
|
||||
v-if="outputs.length"
|
||||
class="flex items-center justify-between border-b border-zinc-800 px-3 py-2"
|
||||
>
|
||||
<span class="text-[10px] text-zinc-500">{{ outputs.length }} generations</span>
|
||||
<button
|
||||
class="text-[10px] text-zinc-500 transition-colors hover:text-red-400"
|
||||
@click="handleClearHistory"
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- History Grid -->
|
||||
<div v-if="outputs.length" class="flex-1 overflow-y-auto p-4">
|
||||
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
|
||||
<div
|
||||
v-for="output in outputs"
|
||||
:key="output.id"
|
||||
class="group relative aspect-square overflow-hidden rounded-lg bg-zinc-800"
|
||||
>
|
||||
<img
|
||||
:src="output.thumbnailUrl ?? output.url"
|
||||
:alt="output.filename"
|
||||
class="h-full w-full object-cover transition-transform group-hover:scale-105"
|
||||
/>
|
||||
|
||||
<!-- Hover Overlay -->
|
||||
<div
|
||||
class="absolute inset-0 flex flex-col justify-between bg-gradient-to-t from-black/80 via-transparent to-black/40 p-2 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
>
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
class="flex h-6 w-6 items-center justify-center rounded bg-black/50 text-zinc-300 transition-colors hover:bg-red-600 hover:text-white"
|
||||
@click="handleDelete(output.id)"
|
||||
>
|
||||
<i class="pi pi-trash text-[10px]" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-[10px] text-zinc-300">
|
||||
{{ formatTime(output.createdAt) }}
|
||||
</span>
|
||||
<button
|
||||
class="flex h-6 w-6 items-center justify-center rounded bg-black/50 text-zinc-300 transition-colors hover:bg-blue-600 hover:text-white"
|
||||
@click="handleDownload(output)"
|
||||
>
|
||||
<i class="pi pi-download text-[10px]" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty History -->
|
||||
<div
|
||||
v-else
|
||||
class="flex flex-1 flex-col items-center justify-center text-zinc-500"
|
||||
>
|
||||
<i class="pi pi-images mb-2 text-2xl" />
|
||||
<span class="text-xs">No history yet</span>
|
||||
<p class="mt-1 text-center text-[10px] text-zinc-600">
|
||||
Your creations will appear here
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
59
ComfyUI_vibe/src/components/linear/LinearIconSidebar.vue
Normal file
59
ComfyUI_vibe/src/components/linear/LinearIconSidebar.vue
Normal file
@@ -0,0 +1,59 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
type LinearTab = 'chat' | 'tool' | 'apps' | 'workflow'
|
||||
|
||||
const activeTab = ref<LinearTab>('tool')
|
||||
|
||||
const tabs: Array<{ id: LinearTab; icon: string; label: string }> = [
|
||||
{ id: 'chat', icon: 'pi-sparkles', label: 'Chat' },
|
||||
{ id: 'tool', icon: 'pi-sliders-h', label: 'Tool' },
|
||||
{ id: 'apps', icon: 'pi-th-large', label: 'Apps' },
|
||||
{ id: 'workflow', icon: 'pi-sitemap', label: 'Workflow' },
|
||||
]
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:activeTab': [tab: LinearTab]
|
||||
}>()
|
||||
|
||||
function selectTab(tab: LinearTab): void {
|
||||
activeTab.value = tab
|
||||
emit('update:activeTab', tab)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="flex h-full w-12 flex-col items-center border-r border-zinc-800 bg-zinc-900 py-2">
|
||||
<!-- Tab Buttons -->
|
||||
<div class="flex flex-col gap-1">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.id"
|
||||
v-tooltip.right="tab.label"
|
||||
:class="[
|
||||
'flex h-10 w-10 flex-col items-center justify-center rounded-lg transition-colors',
|
||||
activeTab === tab.id
|
||||
? 'bg-zinc-800 text-zinc-100'
|
||||
: 'text-zinc-500 hover:bg-zinc-800/50 hover:text-zinc-300'
|
||||
]"
|
||||
@click="selectTab(tab.id)"
|
||||
>
|
||||
<i :class="['pi', tab.icon, 'text-base']" />
|
||||
<span class="mt-0.5 text-[8px] font-medium uppercase tracking-wide">{{ tab.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Spacer -->
|
||||
<div class="flex-1" />
|
||||
|
||||
<!-- Bottom Actions -->
|
||||
<div class="flex flex-col gap-1">
|
||||
<button
|
||||
v-tooltip.right="'Settings'"
|
||||
class="flex h-10 w-10 items-center justify-center rounded-lg text-zinc-500 transition-colors hover:bg-zinc-800/50 hover:text-zinc-300"
|
||||
>
|
||||
<i class="pi pi-cog text-base" />
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
300
ComfyUI_vibe/src/components/linear/LinearInputPanel.vue
Normal file
300
ComfyUI_vibe/src/components/linear/LinearInputPanel.vue
Normal file
@@ -0,0 +1,300 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useLinearModeStore } from '@/stores/linearModeStore'
|
||||
import type { WidgetDefinition } from '@/types/node'
|
||||
import WidgetSlider from '@/components/v2/nodes/widgets/WidgetSlider.vue'
|
||||
import WidgetNumber from '@/components/v2/nodes/widgets/WidgetNumber.vue'
|
||||
import WidgetText from '@/components/v2/nodes/widgets/WidgetText.vue'
|
||||
import WidgetSelect from '@/components/v2/nodes/widgets/WidgetSelect.vue'
|
||||
import WidgetToggle from '@/components/v2/nodes/widgets/WidgetToggle.vue'
|
||||
|
||||
const store = useLinearModeStore()
|
||||
|
||||
const showAdvanced = ref(false)
|
||||
|
||||
const workflow = computed(() => store.currentWorkflow)
|
||||
const isGenerating = computed(() => store.isGenerating)
|
||||
|
||||
// Group widgets into basic (prompt, model) and advanced (everything else)
|
||||
const basicWidgets = computed(() => {
|
||||
if (!workflow.value) return []
|
||||
|
||||
const basic: Array<{
|
||||
stepId: string
|
||||
stepName: string
|
||||
widget: WidgetDefinition
|
||||
value: unknown
|
||||
}> = []
|
||||
|
||||
for (const step of workflow.value.steps) {
|
||||
if (!step.definition) continue
|
||||
|
||||
for (const widgetName of step.exposedWidgets) {
|
||||
const widget = step.definition.widgets.find((w) => w.name === widgetName)
|
||||
if (!widget) continue
|
||||
|
||||
// Basic: prompt text, model selection
|
||||
const isBasic =
|
||||
widget.type === 'textarea' ||
|
||||
(widget.type === 'select' && widgetName === 'ckpt_name')
|
||||
|
||||
if (isBasic) {
|
||||
basic.push({
|
||||
stepId: step.id,
|
||||
stepName: step.displayName,
|
||||
widget,
|
||||
value: step.widgetValues[widgetName],
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return basic
|
||||
})
|
||||
|
||||
const advancedWidgets = computed(() => {
|
||||
if (!workflow.value) return []
|
||||
|
||||
const advanced: Array<{
|
||||
stepId: string
|
||||
stepName: string
|
||||
widget: WidgetDefinition
|
||||
value: unknown
|
||||
}> = []
|
||||
|
||||
for (const step of workflow.value.steps) {
|
||||
if (!step.definition) continue
|
||||
|
||||
for (const widgetName of step.exposedWidgets) {
|
||||
const widget = step.definition.widgets.find((w) => w.name === widgetName)
|
||||
if (!widget) continue
|
||||
|
||||
// Advanced: everything except prompt and model
|
||||
const isBasic =
|
||||
widget.type === 'textarea' ||
|
||||
(widget.type === 'select' && widgetName === 'ckpt_name')
|
||||
|
||||
if (!isBasic) {
|
||||
advanced.push({
|
||||
stepId: step.id,
|
||||
stepName: step.displayName,
|
||||
widget,
|
||||
value: step.widgetValues[widgetName],
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return advanced
|
||||
})
|
||||
|
||||
function updateWidget(stepId: string, widgetName: string, value: unknown): void {
|
||||
store.updateStepWidget(stepId, widgetName, value)
|
||||
}
|
||||
|
||||
function getWidgetComponent(type: WidgetDefinition['type']): unknown {
|
||||
switch (type) {
|
||||
case 'slider':
|
||||
return WidgetSlider
|
||||
case 'number':
|
||||
return WidgetNumber
|
||||
case 'text':
|
||||
return WidgetText
|
||||
case 'select':
|
||||
return WidgetSelect
|
||||
case 'toggle':
|
||||
return WidgetToggle
|
||||
default:
|
||||
return WidgetText
|
||||
}
|
||||
}
|
||||
|
||||
function handleGenerate(): void {
|
||||
store.startGeneration()
|
||||
}
|
||||
|
||||
function handleCancel(): void {
|
||||
store.cancelGeneration()
|
||||
}
|
||||
|
||||
function randomizeSeed(): void {
|
||||
if (!workflow.value) return
|
||||
|
||||
for (const step of workflow.value.steps) {
|
||||
if (step.exposedWidgets.includes('seed')) {
|
||||
const randomSeed = Math.floor(Math.random() * 2147483647)
|
||||
store.updateStepWidget(step.id, 'seed', randomSeed)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="flex flex-1 flex-col bg-zinc-950">
|
||||
<!-- Empty State -->
|
||||
<div
|
||||
v-if="!workflow"
|
||||
class="flex flex-1 flex-col items-center justify-center text-zinc-500"
|
||||
>
|
||||
<i class="pi pi-arrow-left mb-3 text-4xl" />
|
||||
<p class="text-sm">Select a workflow to get started</p>
|
||||
</div>
|
||||
|
||||
<!-- Input Form -->
|
||||
<div v-else class="flex flex-1 flex-col">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between border-b border-zinc-800 px-4 py-3">
|
||||
<div>
|
||||
<h1 class="text-sm font-semibold text-zinc-100">
|
||||
{{ workflow.templateName }}
|
||||
</h1>
|
||||
<p class="mt-0.5 text-xs text-zinc-500">
|
||||
{{ workflow.steps.length }} steps
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Generate Button -->
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
v-if="!isGenerating"
|
||||
class="flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-500"
|
||||
@click="handleGenerate"
|
||||
>
|
||||
<i class="pi pi-play text-xs" />
|
||||
Generate
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="flex items-center gap-2 rounded-lg bg-red-600/20 px-4 py-2 text-sm font-medium text-red-400 transition-colors hover:bg-red-600/30"
|
||||
@click="handleCancel"
|
||||
>
|
||||
<i class="pi pi-times text-xs" />
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scrollable Content -->
|
||||
<div class="flex-1 overflow-y-auto p-4">
|
||||
<div class="mx-auto max-w-2xl space-y-6">
|
||||
<!-- Basic Inputs -->
|
||||
<section>
|
||||
<div
|
||||
v-for="item in basicWidgets"
|
||||
:key="`${item.stepId}-${item.widget.name}`"
|
||||
class="mb-4"
|
||||
>
|
||||
<label class="mb-1.5 block text-xs font-medium text-zinc-400">
|
||||
{{ item.widget.label ?? item.widget.name }}
|
||||
</label>
|
||||
|
||||
<!-- Textarea for prompts -->
|
||||
<textarea
|
||||
v-if="item.widget.type === 'textarea'"
|
||||
:value="String(item.value ?? '')"
|
||||
:placeholder="item.widget.options?.placeholder ?? 'Enter your prompt...'"
|
||||
class="min-h-[120px] w-full resize-none rounded-lg border border-zinc-800 bg-zinc-900 p-3 text-sm text-zinc-200 outline-none transition-colors placeholder:text-zinc-600 focus:border-zinc-600"
|
||||
@input="updateWidget(item.stepId, item.widget.name, ($event.target as HTMLTextAreaElement).value)"
|
||||
/>
|
||||
|
||||
<!-- Select for model -->
|
||||
<component
|
||||
v-else
|
||||
:is="getWidgetComponent(item.widget.type)"
|
||||
:widget="item.widget"
|
||||
:model-value="item.value"
|
||||
@update:model-value="updateWidget(item.stepId, item.widget.name, $event)"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Advanced Settings Toggle -->
|
||||
<div class="border-t border-zinc-800 pt-4">
|
||||
<button
|
||||
class="flex w-full items-center justify-between rounded-lg bg-zinc-900 px-3 py-2.5 text-left transition-colors hover:bg-zinc-800"
|
||||
@click="showAdvanced = !showAdvanced"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="pi pi-sliders-h text-xs text-zinc-500" />
|
||||
<span class="text-xs font-medium text-zinc-300">Advanced Settings</span>
|
||||
<span class="rounded bg-zinc-800 px-1.5 py-0.5 text-[10px] text-zinc-500">
|
||||
{{ advancedWidgets.length }}
|
||||
</span>
|
||||
</div>
|
||||
<i
|
||||
:class="[
|
||||
'pi text-xs text-zinc-500 transition-transform',
|
||||
showAdvanced ? 'pi-chevron-up' : 'pi-chevron-down'
|
||||
]"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<!-- Advanced Widgets -->
|
||||
<div
|
||||
v-if="showAdvanced"
|
||||
class="mt-3 space-y-4 rounded-lg border border-zinc-800 bg-zinc-900/50 p-4"
|
||||
>
|
||||
<!-- Quick Actions -->
|
||||
<div class="flex items-center gap-2 border-b border-zinc-800 pb-3">
|
||||
<button
|
||||
class="flex items-center gap-1.5 rounded bg-zinc-800 px-2 py-1 text-[10px] text-zinc-400 transition-colors hover:bg-zinc-700 hover:text-zinc-200"
|
||||
@click="randomizeSeed"
|
||||
>
|
||||
<i class="pi pi-sync text-[10px]" />
|
||||
Random Seed
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Grouped by step -->
|
||||
<div
|
||||
v-for="(item, index) in advancedWidgets"
|
||||
:key="`${item.stepId}-${item.widget.name}`"
|
||||
class="space-y-1.5"
|
||||
>
|
||||
<!-- Step name as group header (only show once per step) -->
|
||||
<div
|
||||
v-if="index === 0 || advancedWidgets[index - 1]?.stepId !== item.stepId"
|
||||
class="mb-2 text-[10px] font-semibold uppercase tracking-wide text-zinc-500"
|
||||
>
|
||||
{{ item.stepName }}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="w-24 shrink-0 text-right text-[11px] text-zinc-500">
|
||||
{{ item.widget.label ?? item.widget.name }}
|
||||
</label>
|
||||
<div class="flex-1">
|
||||
<component
|
||||
:is="getWidgetComponent(item.widget.type)"
|
||||
:widget="item.widget"
|
||||
:model-value="item.value"
|
||||
@update:model-value="updateWidget(item.stepId, item.widget.name, $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!advancedWidgets.length" class="py-4 text-center text-xs text-zinc-600">
|
||||
No advanced settings available
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progress Bar (when generating) -->
|
||||
<div v-if="isGenerating" class="border-t border-zinc-800 px-4 py-3">
|
||||
<div class="flex items-center justify-between text-xs text-zinc-400">
|
||||
<span>Generating...</span>
|
||||
<span>{{ Math.round(store.executionProgress) }}%</span>
|
||||
</div>
|
||||
<div class="mt-2 h-1.5 overflow-hidden rounded-full bg-zinc-800">
|
||||
<div
|
||||
class="h-full rounded-full bg-blue-600 transition-all duration-300"
|
||||
:style="{ width: `${store.executionProgress}%` }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
428
ComfyUI_vibe/src/components/linear/LinearOutputGallery.vue
Normal file
428
ComfyUI_vibe/src/components/linear/LinearOutputGallery.vue
Normal file
@@ -0,0 +1,428 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import type { LinearOutput } from '@/types/linear'
|
||||
|
||||
interface Props {
|
||||
outputs: LinearOutput[]
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
delete: [outputId: string]
|
||||
download: [output: LinearOutput]
|
||||
select: [output: LinearOutput]
|
||||
}>()
|
||||
|
||||
const selectedOutput = ref<LinearOutput | null>(null)
|
||||
|
||||
function openLightbox(output: LinearOutput): void {
|
||||
selectedOutput.value = output
|
||||
emit('select', output)
|
||||
}
|
||||
|
||||
function closeLightbox(): void {
|
||||
selectedOutput.value = null
|
||||
}
|
||||
|
||||
function downloadImage(output: LinearOutput): void {
|
||||
emit('download', output)
|
||||
}
|
||||
|
||||
function deleteOutput(outputId: string, event: Event): void {
|
||||
event.stopPropagation()
|
||||
emit('delete', outputId)
|
||||
}
|
||||
|
||||
function formatDate(date: Date): string {
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}).format(date)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="output-gallery">
|
||||
<!-- Gallery header -->
|
||||
<div class="gallery-header">
|
||||
<h3 class="gallery-title">
|
||||
<i class="pi pi-images" />
|
||||
Generated Images
|
||||
</h3>
|
||||
<span class="gallery-count">{{ outputs.length }} images</span>
|
||||
</div>
|
||||
|
||||
<!-- Gallery grid -->
|
||||
<div v-if="outputs.length" class="gallery-grid">
|
||||
<div
|
||||
v-for="output in outputs"
|
||||
:key="output.id"
|
||||
class="gallery-item"
|
||||
@click="openLightbox(output)"
|
||||
>
|
||||
<img
|
||||
:src="output.thumbnailUrl ?? output.url"
|
||||
:alt="output.filename"
|
||||
class="gallery-image"
|
||||
/>
|
||||
|
||||
<!-- Overlay -->
|
||||
<div class="item-overlay">
|
||||
<div class="overlay-top">
|
||||
<button
|
||||
class="overlay-btn"
|
||||
title="Delete"
|
||||
@click="deleteOutput(output.id, $event)"
|
||||
>
|
||||
<i class="pi pi-trash" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="overlay-bottom">
|
||||
<span class="item-time">{{ formatDate(output.createdAt) }}</span>
|
||||
<button
|
||||
class="overlay-btn"
|
||||
title="Download"
|
||||
@click.stop="downloadImage(output)"
|
||||
>
|
||||
<i class="pi pi-download" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div v-else class="empty-state">
|
||||
<i class="pi pi-image text-4xl text-zinc-700" />
|
||||
<p class="empty-text">Your generated images will appear here</p>
|
||||
</div>
|
||||
|
||||
<!-- Lightbox -->
|
||||
<Teleport to="body">
|
||||
<div v-if="selectedOutput" class="lightbox" @click="closeLightbox">
|
||||
<div class="lightbox-content" @click.stop>
|
||||
<button class="lightbox-close" @click="closeLightbox">
|
||||
<i class="pi pi-times" />
|
||||
</button>
|
||||
|
||||
<img
|
||||
:src="selectedOutput.url"
|
||||
:alt="selectedOutput.filename"
|
||||
class="lightbox-image"
|
||||
/>
|
||||
|
||||
<div class="lightbox-info">
|
||||
<div class="info-row">
|
||||
<span class="info-label">Filename</span>
|
||||
<span class="info-value">{{ selectedOutput.filename }}</span>
|
||||
</div>
|
||||
<div v-if="selectedOutput.metadata?.prompt" class="info-row">
|
||||
<span class="info-label">Prompt</span>
|
||||
<span class="info-value prompt">{{ selectedOutput.metadata.prompt }}</span>
|
||||
</div>
|
||||
<div class="info-grid">
|
||||
<div v-if="selectedOutput.metadata?.seed" class="info-item">
|
||||
<span class="info-label">Seed</span>
|
||||
<span class="info-value">{{ selectedOutput.metadata.seed }}</span>
|
||||
</div>
|
||||
<div v-if="selectedOutput.metadata?.steps" class="info-item">
|
||||
<span class="info-label">Steps</span>
|
||||
<span class="info-value">{{ selectedOutput.metadata.steps }}</span>
|
||||
</div>
|
||||
<div v-if="selectedOutput.metadata?.cfg" class="info-item">
|
||||
<span class="info-label">CFG</span>
|
||||
<span class="info-value">{{ selectedOutput.metadata.cfg }}</span>
|
||||
</div>
|
||||
<div v-if="selectedOutput.metadata?.sampler" class="info-item">
|
||||
<span class="info-label">Sampler</span>
|
||||
<span class="info-value">{{ selectedOutput.metadata.sampler }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="lightbox-actions">
|
||||
<button class="action-btn secondary" @click="closeLightbox">
|
||||
Close
|
||||
</button>
|
||||
<button class="action-btn primary" @click="downloadImage(selectedOutput)">
|
||||
<i class="pi pi-download" />
|
||||
Download
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.output-gallery {
|
||||
background: #18181b;
|
||||
border: 1px solid #27272a;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.gallery-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
background: #1f1f23;
|
||||
border-bottom: 1px solid #27272a;
|
||||
}
|
||||
|
||||
.gallery-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #fafafa;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.gallery-count {
|
||||
font-size: 12px;
|
||||
color: #71717a;
|
||||
}
|
||||
|
||||
.gallery-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.gallery-item {
|
||||
position: relative;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
background: #27272a;
|
||||
}
|
||||
|
||||
.gallery-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.gallery-item:hover .gallery-image {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.item-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
rgba(0, 0, 0, 0.5) 0%,
|
||||
transparent 30%,
|
||||
transparent 70%,
|
||||
rgba(0, 0, 0, 0.7) 100%
|
||||
);
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.gallery-item:hover .item-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.overlay-top,
|
||||
.overlay-bottom {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.overlay-top {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.overlay-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
border: none;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.overlay-btn:hover {
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
.item-time {
|
||||
font-size: 10px;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 48px 24px;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 13px;
|
||||
color: #52525b;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Lightbox */
|
||||
.lightbox {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.lightbox-content {
|
||||
position: relative;
|
||||
max-width: 900px;
|
||||
max-height: 90vh;
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
background: #18181b;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.lightbox-close {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
border: none;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
z-index: 10;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.lightbox-close:hover {
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
.lightbox-image {
|
||||
max-width: 600px;
|
||||
max-height: 80vh;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.lightbox-info {
|
||||
width: 280px;
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-size: 11px;
|
||||
color: #71717a;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-size: 13px;
|
||||
color: #fafafa;
|
||||
}
|
||||
|
||||
.info-value.prompt {
|
||||
line-height: 1.5;
|
||||
max-height: 100px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.lightbox-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 10px 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.action-btn.primary {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.action-btn.primary:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.action-btn.secondary {
|
||||
background: #27272a;
|
||||
color: #a1a1aa;
|
||||
}
|
||||
|
||||
.action-btn.secondary:hover {
|
||||
background: #3f3f46;
|
||||
color: #fafafa;
|
||||
}
|
||||
</style>
|
||||
252
ComfyUI_vibe/src/components/linear/LinearParameterPanel.vue
Normal file
252
ComfyUI_vibe/src/components/linear/LinearParameterPanel.vue
Normal file
@@ -0,0 +1,252 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { LinearStep } from '@/types/linear'
|
||||
import type { WidgetDefinition } from '@/types/node'
|
||||
import WidgetSlider from '@/components/v2/nodes/widgets/WidgetSlider.vue'
|
||||
import WidgetNumber from '@/components/v2/nodes/widgets/WidgetNumber.vue'
|
||||
import WidgetText from '@/components/v2/nodes/widgets/WidgetText.vue'
|
||||
import WidgetSelect from '@/components/v2/nodes/widgets/WidgetSelect.vue'
|
||||
import WidgetToggle from '@/components/v2/nodes/widgets/WidgetToggle.vue'
|
||||
|
||||
interface Props {
|
||||
step: LinearStep
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:widget': [widgetName: string, value: unknown]
|
||||
}>()
|
||||
|
||||
// Get only the exposed widgets from the step definition
|
||||
const exposedWidgets = computed(() => {
|
||||
if (!props.step.definition) return []
|
||||
|
||||
return props.step.definition.widgets.filter((widget) =>
|
||||
props.step.exposedWidgets.includes(widget.name)
|
||||
)
|
||||
})
|
||||
|
||||
function getWidgetValue(widgetName: string): unknown {
|
||||
return props.step.widgetValues[widgetName]
|
||||
}
|
||||
|
||||
function updateWidget(widgetName: string, value: unknown): void {
|
||||
emit('update:widget', widgetName, value)
|
||||
}
|
||||
|
||||
function getWidgetComponent(type: WidgetDefinition['type']): unknown {
|
||||
switch (type) {
|
||||
case 'slider':
|
||||
return WidgetSlider
|
||||
case 'number':
|
||||
return WidgetNumber
|
||||
case 'text':
|
||||
case 'textarea':
|
||||
return WidgetText
|
||||
case 'select':
|
||||
return WidgetSelect
|
||||
case 'toggle':
|
||||
return WidgetToggle
|
||||
default:
|
||||
return WidgetText
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="parameter-panel">
|
||||
<!-- Panel header -->
|
||||
<div class="panel-header">
|
||||
<div class="header-icon">
|
||||
<i :class="['pi', step.icon ?? 'pi-cog']" />
|
||||
</div>
|
||||
<div class="header-content">
|
||||
<h3 class="panel-title">{{ step.displayName }}</h3>
|
||||
<p v-if="step.description" class="panel-description">
|
||||
{{ step.description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Widget list -->
|
||||
<div class="widgets-container">
|
||||
<div
|
||||
v-for="widget in exposedWidgets"
|
||||
:key="widget.name"
|
||||
class="widget-row"
|
||||
>
|
||||
<label class="widget-label">
|
||||
{{ widget.label ?? widget.name }}
|
||||
</label>
|
||||
|
||||
<!-- Textarea gets special treatment -->
|
||||
<div v-if="widget.type === 'textarea'" class="widget-textarea">
|
||||
<textarea
|
||||
:value="String(getWidgetValue(widget.name) ?? '')"
|
||||
:placeholder="widget.options?.placeholder"
|
||||
class="textarea-input"
|
||||
rows="4"
|
||||
@input="updateWidget(widget.name, ($event.target as HTMLTextAreaElement).value)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Other widgets use the component system -->
|
||||
<component
|
||||
v-else
|
||||
:is="getWidgetComponent(widget.type)"
|
||||
:widget="widget"
|
||||
:model-value="getWidgetValue(widget.name)"
|
||||
:multiline="widget.type === 'textarea'"
|
||||
@update:model-value="updateWidget(widget.name, $event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div v-if="!exposedWidgets.length" class="empty-state">
|
||||
<i class="pi pi-cog text-zinc-600" />
|
||||
<span>No parameters to configure</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.parameter-panel {
|
||||
background: #18181b;
|
||||
border: 1px solid #27272a;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: #1f1f23;
|
||||
border-bottom: 1px solid #27272a;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(135deg, #3b82f6, #8b5cf6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #fafafa;
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.panel-description {
|
||||
font-size: 13px;
|
||||
color: #71717a;
|
||||
margin: 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.widgets-container {
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.widget-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.widget-label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #a1a1aa;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.widget-textarea {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.textarea-input {
|
||||
width: 100%;
|
||||
background: #27272a;
|
||||
border: 1px solid #3f3f46;
|
||||
border-radius: 8px;
|
||||
color: #fafafa;
|
||||
padding: 12px;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
line-height: 1.5;
|
||||
resize: vertical;
|
||||
min-height: 100px;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.textarea-input:focus {
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
|
||||
.textarea-input::placeholder {
|
||||
color: #52525b;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 24px;
|
||||
color: #52525b;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Override widget styles for larger linear mode display */
|
||||
:deep(.widget-slider) {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
:deep(.custom-slider) {
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
:deep(.custom-slider::-webkit-slider-thumb) {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
:deep(.number-input) {
|
||||
width: 72px;
|
||||
padding: 6px 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
:deep(.custom-select) {
|
||||
padding: 10px 32px 10px 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
:deep(.custom-input) {
|
||||
padding: 10px 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
221
ComfyUI_vibe/src/components/linear/LinearStepCard.vue
Normal file
221
ComfyUI_vibe/src/components/linear/LinearStepCard.vue
Normal file
@@ -0,0 +1,221 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { LinearStep } from '@/types/linear'
|
||||
|
||||
interface Props {
|
||||
step: LinearStep
|
||||
stepIndex: number
|
||||
isActive: boolean
|
||||
isCompleted: boolean
|
||||
isExecuting: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [stepId: string]
|
||||
}>()
|
||||
|
||||
const statusIcon = computed(() => {
|
||||
if (props.step.state === 'completed') return 'pi-check'
|
||||
if (props.step.state === 'executing') return 'pi-spin pi-spinner'
|
||||
if (props.step.state === 'error') return 'pi-exclamation-triangle'
|
||||
return props.step.icon ?? 'pi-circle'
|
||||
})
|
||||
|
||||
const statusClass = computed(() => {
|
||||
if (props.step.state === 'completed') return 'status-completed'
|
||||
if (props.step.state === 'executing') return 'status-executing'
|
||||
if (props.step.state === 'error') return 'status-error'
|
||||
if (props.isActive) return 'status-active'
|
||||
return 'status-idle'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
:class="['step-card', statusClass, { active: isActive }]"
|
||||
@click="emit('select', step.id)"
|
||||
>
|
||||
<!-- Step number / status indicator -->
|
||||
<div class="step-indicator">
|
||||
<div class="indicator-circle">
|
||||
<i :class="['pi', statusIcon]" />
|
||||
</div>
|
||||
<div v-if="stepIndex < 4" class="connector-line" />
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="step-content">
|
||||
<div class="step-header">
|
||||
<span class="step-number">Step {{ stepIndex + 1 }}</span>
|
||||
<span v-if="step.state === 'executing'" class="progress-text">
|
||||
{{ step.progress ?? 0 }}%
|
||||
</span>
|
||||
</div>
|
||||
<h3 class="step-title">{{ step.displayName }}</h3>
|
||||
<p v-if="step.description" class="step-description">
|
||||
{{ step.description }}
|
||||
</p>
|
||||
|
||||
<!-- Progress bar -->
|
||||
<div v-if="step.state === 'executing'" class="progress-bar">
|
||||
<div
|
||||
class="progress-fill"
|
||||
:style="{ width: `${step.progress ?? 0}%` }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Expand arrow -->
|
||||
<i v-if="isActive" class="pi pi-chevron-right expand-icon" />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.step-card {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: #18181b;
|
||||
border: 1px solid #27272a;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.step-card:hover {
|
||||
background: #1f1f23;
|
||||
border-color: #3f3f46;
|
||||
}
|
||||
|
||||
.step-card.active {
|
||||
background: #1e293b;
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
.step-indicator {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.indicator-circle {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: #27272a;
|
||||
border: 2px solid #3f3f46;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #71717a;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.connector-line {
|
||||
width: 2px;
|
||||
height: 24px;
|
||||
background: #27272a;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* Status variants */
|
||||
.status-completed .indicator-circle {
|
||||
background: #22c55e;
|
||||
border-color: #22c55e;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.status-completed .connector-line {
|
||||
background: #22c55e;
|
||||
}
|
||||
|
||||
.status-executing .indicator-circle {
|
||||
background: #3b82f6;
|
||||
border-color: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.status-executing .connector-line {
|
||||
background: linear-gradient(to bottom, #3b82f6, #27272a);
|
||||
}
|
||||
|
||||
.status-error .indicator-circle {
|
||||
background: #ef4444;
|
||||
border-color: #ef4444;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.status-active .indicator-circle {
|
||||
border-color: #3b82f6;
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.step-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.step-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.step-number {
|
||||
font-size: 11px;
|
||||
color: #71717a;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
font-size: 11px;
|
||||
color: #3b82f6;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.step-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #fafafa;
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.step-description {
|
||||
font-size: 12px;
|
||||
color: #71717a;
|
||||
margin: 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 3px;
|
||||
background: #27272a;
|
||||
border-radius: 2px;
|
||||
margin-top: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #3b82f6, #60a5fa);
|
||||
border-radius: 2px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.expand-icon {
|
||||
color: #3b82f6;
|
||||
font-size: 12px;
|
||||
margin-left: auto;
|
||||
align-self: center;
|
||||
}
|
||||
</style>
|
||||
197
ComfyUI_vibe/src/components/linear/LinearTemplateCard.vue
Normal file
197
ComfyUI_vibe/src/components/linear/LinearTemplateCard.vue
Normal file
@@ -0,0 +1,197 @@
|
||||
<script setup lang="ts">
|
||||
import type { LinearWorkflowTemplate } from '@/types/linear'
|
||||
|
||||
interface Props {
|
||||
template: LinearWorkflowTemplate
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [template: LinearWorkflowTemplate]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
class="template-card group"
|
||||
@click="emit('select', template)"
|
||||
>
|
||||
<!-- Thumbnail -->
|
||||
<div class="thumbnail">
|
||||
<img
|
||||
v-if="template.thumbnailUrl"
|
||||
:src="template.thumbnailUrl"
|
||||
:alt="template.name"
|
||||
class="thumbnail-img"
|
||||
/>
|
||||
<div v-else class="thumbnail-placeholder">
|
||||
<i :class="['pi', template.icon, 'text-2xl text-zinc-500']" />
|
||||
</div>
|
||||
|
||||
<!-- Featured badge -->
|
||||
<span v-if="template.featured" class="featured-badge">
|
||||
Featured
|
||||
</span>
|
||||
|
||||
<!-- Hover overlay -->
|
||||
<div class="hover-overlay">
|
||||
<span class="use-btn">Use Template</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="content">
|
||||
<div class="header">
|
||||
<i :class="['pi', template.icon, 'text-sm text-zinc-400']" />
|
||||
<h3 class="title">{{ template.name }}</h3>
|
||||
</div>
|
||||
<p class="description">{{ template.description }}</p>
|
||||
|
||||
<!-- Tags -->
|
||||
<div class="tags">
|
||||
<span
|
||||
v-for="tag in template.tags.slice(0, 3)"
|
||||
:key="tag"
|
||||
class="tag"
|
||||
>
|
||||
{{ tag }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.template-card {
|
||||
background: #27272a;
|
||||
border: 1px solid #3f3f46;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.template-card:hover {
|
||||
border-color: #52525b;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.thumbnail {
|
||||
position: relative;
|
||||
aspect-ratio: 4 / 3;
|
||||
background: #18181b;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.thumbnail-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.template-card:hover .thumbnail-img {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.thumbnail-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #27272a, #18181b);
|
||||
}
|
||||
|
||||
.featured-badge {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
background: linear-gradient(135deg, #3b82f6, #8b5cf6);
|
||||
color: white;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.hover-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.template-card:hover .hover-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.use-btn {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
transform: translateY(8px);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.template-card:hover .use-btn {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #fafafa;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 12px;
|
||||
color: #a1a1aa;
|
||||
margin: 0 0 8px 0;
|
||||
line-height: 1.4;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.tag {
|
||||
font-size: 10px;
|
||||
color: #71717a;
|
||||
background: #3f3f46;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
text-transform: lowercase;
|
||||
}
|
||||
</style>
|
||||
272
ComfyUI_vibe/src/components/linear/LinearTemplateSelector.vue
Normal file
272
ComfyUI_vibe/src/components/linear/LinearTemplateSelector.vue
Normal file
@@ -0,0 +1,272 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useLinearModeStore } from '@/stores/linearModeStore'
|
||||
import { TEMPLATE_CATEGORIES } from '@/data/linearTemplates'
|
||||
import LinearTemplateCard from './LinearTemplateCard.vue'
|
||||
import type { LinearWorkflowTemplate } from '@/types/linear'
|
||||
|
||||
const store = useLinearModeStore()
|
||||
|
||||
const selectedCategory = ref<string | null>(null)
|
||||
const searchQuery = ref('')
|
||||
|
||||
const filteredTemplates = computed(() => {
|
||||
let templates = store.templates
|
||||
|
||||
// Filter by category
|
||||
if (selectedCategory.value) {
|
||||
templates = templates.filter((t) => t.category === selectedCategory.value)
|
||||
}
|
||||
|
||||
// Filter by search
|
||||
if (searchQuery.value) {
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
templates = templates.filter(
|
||||
(t) =>
|
||||
t.name.toLowerCase().includes(query) ||
|
||||
t.description.toLowerCase().includes(query) ||
|
||||
t.tags.some((tag) => tag.toLowerCase().includes(query))
|
||||
)
|
||||
}
|
||||
|
||||
return templates
|
||||
})
|
||||
|
||||
const featuredTemplates = computed(() =>
|
||||
store.templates.filter((t) => t.featured)
|
||||
)
|
||||
|
||||
function selectTemplate(template: LinearWorkflowTemplate): void {
|
||||
store.selectTemplate(template)
|
||||
}
|
||||
|
||||
function clearCategory(): void {
|
||||
selectedCategory.value = null
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="template-selector">
|
||||
<!-- Header -->
|
||||
<div class="header">
|
||||
<div class="header-content">
|
||||
<h1 class="title">Create with AI</h1>
|
||||
<p class="subtitle">
|
||||
Choose a workflow template to get started
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Search -->
|
||||
<div class="search-wrapper">
|
||||
<i class="pi pi-search search-icon" />
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
placeholder="Search workflows..."
|
||||
class="search-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Categories -->
|
||||
<div class="categories">
|
||||
<button
|
||||
:class="['category-chip', { active: !selectedCategory }]"
|
||||
@click="clearCategory"
|
||||
>
|
||||
All
|
||||
</button>
|
||||
<button
|
||||
v-for="category in TEMPLATE_CATEGORIES"
|
||||
:key="category.id"
|
||||
:class="['category-chip', { active: selectedCategory === category.id }]"
|
||||
@click="selectedCategory = category.id"
|
||||
>
|
||||
<i :class="['pi', category.icon]" />
|
||||
{{ category.name }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Featured Section (only when no filter) -->
|
||||
<section v-if="!selectedCategory && !searchQuery && featuredTemplates.length" class="section">
|
||||
<h2 class="section-title">
|
||||
<i class="pi pi-star-fill text-yellow-500" />
|
||||
Featured
|
||||
</h2>
|
||||
<div class="template-grid featured-grid">
|
||||
<LinearTemplateCard
|
||||
v-for="template in featuredTemplates"
|
||||
:key="template.id"
|
||||
:template="template"
|
||||
@select="selectTemplate"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- All Templates -->
|
||||
<section class="section">
|
||||
<h2 v-if="!selectedCategory && !searchQuery" class="section-title">
|
||||
<i class="pi pi-th-large" />
|
||||
All Workflows
|
||||
</h2>
|
||||
<h2 v-else-if="selectedCategory" class="section-title">
|
||||
<i :class="['pi', TEMPLATE_CATEGORIES.find(c => c.id === selectedCategory)?.icon]" />
|
||||
{{ TEMPLATE_CATEGORIES.find(c => c.id === selectedCategory)?.name }}
|
||||
</h2>
|
||||
<h2 v-else class="section-title">
|
||||
<i class="pi pi-search" />
|
||||
Search Results
|
||||
</h2>
|
||||
|
||||
<div v-if="filteredTemplates.length" class="template-grid">
|
||||
<LinearTemplateCard
|
||||
v-for="template in filteredTemplates"
|
||||
:key="template.id"
|
||||
:template="template"
|
||||
@select="selectTemplate"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else class="empty-state">
|
||||
<i class="pi pi-inbox text-4xl text-zinc-600" />
|
||||
<p>No workflows found</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.template-selector {
|
||||
min-height: 100vh;
|
||||
background: #09090b;
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.header {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto 32px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: #fafafa;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 16px;
|
||||
color: #71717a;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.search-wrapper {
|
||||
position: relative;
|
||||
width: 280px;
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: #71717a;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
background: #18181b;
|
||||
border: 1px solid #27272a;
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px 10px 36px;
|
||||
color: #fafafa;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
.search-input::placeholder {
|
||||
color: #52525b;
|
||||
}
|
||||
|
||||
.categories {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto 32px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.category-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 14px;
|
||||
background: #18181b;
|
||||
border: 1px solid #27272a;
|
||||
border-radius: 20px;
|
||||
color: #a1a1aa;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.category-chip:hover {
|
||||
border-color: #3f3f46;
|
||||
color: #fafafa;
|
||||
}
|
||||
|
||||
.category-chip.active {
|
||||
background: #3b82f6;
|
||||
border-color: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.section {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto 48px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #fafafa;
|
||||
margin: 0 0 20px;
|
||||
}
|
||||
|
||||
.template-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.featured-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 64px;
|
||||
color: #52525b;
|
||||
}
|
||||
</style>
|
||||
151
ComfyUI_vibe/src/components/linear/LinearWorkflowSidebar.vue
Normal file
151
ComfyUI_vibe/src/components/linear/LinearWorkflowSidebar.vue
Normal file
@@ -0,0 +1,151 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useLinearModeStore } from '@/stores/linearModeStore'
|
||||
import { TEMPLATE_CATEGORIES } from '@/data/linearTemplates'
|
||||
import type { LinearWorkflowTemplate } from '@/types/linear'
|
||||
|
||||
const store = useLinearModeStore()
|
||||
|
||||
const searchQuery = ref('')
|
||||
const selectedCategory = ref<string | null>(null)
|
||||
|
||||
const filteredTemplates = computed(() => {
|
||||
let templates = store.templates
|
||||
|
||||
if (selectedCategory.value) {
|
||||
templates = templates.filter((t) => t.category === selectedCategory.value)
|
||||
}
|
||||
|
||||
if (searchQuery.value) {
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
templates = templates.filter(
|
||||
(t) =>
|
||||
t.name.toLowerCase().includes(query) ||
|
||||
t.description.toLowerCase().includes(query)
|
||||
)
|
||||
}
|
||||
|
||||
return templates
|
||||
})
|
||||
|
||||
function selectTemplate(template: LinearWorkflowTemplate): void {
|
||||
store.selectTemplate(template)
|
||||
}
|
||||
|
||||
function isSelected(template: LinearWorkflowTemplate): boolean {
|
||||
return store.selectedTemplate?.id === template.id
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="flex h-full w-72 flex-col border-r border-zinc-800 bg-zinc-900">
|
||||
<!-- 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">
|
||||
Workflows
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Search -->
|
||||
<div class="border-b border-zinc-800 p-2">
|
||||
<div class="flex items-center rounded bg-zinc-800 px-2 py-1.5">
|
||||
<i class="pi pi-search text-xs text-zinc-500" />
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
placeholder="Search workflows..."
|
||||
class="ml-2 w-full bg-transparent text-xs text-zinc-300 outline-none placeholder:text-zinc-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Categories -->
|
||||
<div class="flex flex-wrap gap-1 border-b border-zinc-800 p-2">
|
||||
<button
|
||||
:class="[
|
||||
'rounded px-2 py-1 text-[10px] font-medium transition-colors',
|
||||
!selectedCategory
|
||||
? 'bg-zinc-700 text-zinc-100'
|
||||
: 'text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200'
|
||||
]"
|
||||
@click="selectedCategory = null"
|
||||
>
|
||||
All
|
||||
</button>
|
||||
<button
|
||||
v-for="cat in TEMPLATE_CATEGORIES.slice(0, 4)"
|
||||
:key="cat.id"
|
||||
:class="[
|
||||
'rounded px-2 py-1 text-[10px] font-medium transition-colors',
|
||||
selectedCategory === cat.id
|
||||
? 'bg-zinc-700 text-zinc-100'
|
||||
: 'text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200'
|
||||
]"
|
||||
@click="selectedCategory = cat.id"
|
||||
>
|
||||
{{ cat.name }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Template List -->
|
||||
<div class="flex-1 overflow-y-auto p-2">
|
||||
<div class="flex flex-col gap-1">
|
||||
<button
|
||||
v-for="template in filteredTemplates"
|
||||
:key="template.id"
|
||||
:class="[
|
||||
'group flex items-start gap-3 rounded-lg p-2.5 text-left transition-colors',
|
||||
isSelected(template)
|
||||
? 'bg-zinc-800 ring-1 ring-zinc-600'
|
||||
: 'hover:bg-zinc-800/50'
|
||||
]"
|
||||
@click="selectTemplate(template)"
|
||||
>
|
||||
<!-- Icon -->
|
||||
<div
|
||||
:class="[
|
||||
'flex h-9 w-9 shrink-0 items-center justify-center rounded-lg transition-colors',
|
||||
isSelected(template)
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-zinc-800 text-zinc-400 group-hover:bg-zinc-700 group-hover:text-zinc-300'
|
||||
]"
|
||||
>
|
||||
<i :class="['pi', template.icon, 'text-sm']" />
|
||||
</div>
|
||||
|
||||
<!-- Info -->
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="truncate text-xs font-medium text-zinc-200">
|
||||
{{ template.name }}
|
||||
</span>
|
||||
<span
|
||||
v-if="template.featured"
|
||||
class="shrink-0 rounded bg-blue-600/20 px-1 py-0.5 text-[9px] font-medium text-blue-400"
|
||||
>
|
||||
Featured
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-0.5 line-clamp-2 text-[10px] leading-relaxed text-zinc-500">
|
||||
{{ template.description }}
|
||||
</p>
|
||||
<div class="mt-1 flex items-center gap-2">
|
||||
<span class="text-[9px] text-zinc-600">
|
||||
{{ template.steps.length }} steps
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div
|
||||
v-if="!filteredTemplates.length"
|
||||
class="flex flex-col items-center justify-center py-8 text-zinc-500"
|
||||
>
|
||||
<i class="pi pi-inbox mb-2 text-2xl" />
|
||||
<span class="text-xs">No workflows found</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
460
ComfyUI_vibe/src/components/linear/LinearWorkspace.vue
Normal file
460
ComfyUI_vibe/src/components/linear/LinearWorkspace.vue
Normal file
@@ -0,0 +1,460 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useLinearModeStore } from '@/stores/linearModeStore'
|
||||
import LinearStepCard from './LinearStepCard.vue'
|
||||
import LinearParameterPanel from './LinearParameterPanel.vue'
|
||||
import LinearOutputGallery from './LinearOutputGallery.vue'
|
||||
import type { LinearOutput } from '@/types/linear'
|
||||
|
||||
const store = useLinearModeStore()
|
||||
|
||||
const activeStepId = ref<string | null>(null)
|
||||
|
||||
const activeStep = computed(() => {
|
||||
if (!activeStepId.value) return store.currentSteps[0] ?? null
|
||||
return store.currentSteps.find((s) => s.id === activeStepId.value) ?? null
|
||||
})
|
||||
|
||||
const workflowName = computed(() => store.currentWorkflow?.templateName ?? '')
|
||||
|
||||
function selectStep(stepId: string): void {
|
||||
activeStepId.value = stepId
|
||||
}
|
||||
|
||||
function updateStepWidget(widgetName: string, value: unknown): void {
|
||||
if (activeStep.value) {
|
||||
store.updateStepWidget(activeStep.value.id, widgetName, value)
|
||||
}
|
||||
}
|
||||
|
||||
function handleGenerate(): void {
|
||||
store.startGeneration()
|
||||
}
|
||||
|
||||
function handleCancel(): void {
|
||||
store.cancelGeneration()
|
||||
}
|
||||
|
||||
function handleReset(): void {
|
||||
store.resetWorkflow()
|
||||
}
|
||||
|
||||
function handleBack(): void {
|
||||
store.showTemplates()
|
||||
}
|
||||
|
||||
function handleDeleteOutput(outputId: string): void {
|
||||
store.deleteOutput(outputId)
|
||||
}
|
||||
|
||||
function handleDownloadOutput(output: LinearOutput): void {
|
||||
// Create a download link
|
||||
const link = document.createElement('a')
|
||||
link.href = output.url
|
||||
link.download = output.filename
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
}
|
||||
|
||||
// Generate random seed
|
||||
function randomizeSeed(): void {
|
||||
if (activeStep.value && activeStep.value.exposedWidgets.includes('seed')) {
|
||||
const randomSeed = Math.floor(Math.random() * 2147483647)
|
||||
store.updateStepWidget(activeStep.value.id, 'seed', randomSeed)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="linear-workspace">
|
||||
<!-- Top bar -->
|
||||
<header class="workspace-header">
|
||||
<div class="header-left">
|
||||
<button class="back-btn" @click="handleBack">
|
||||
<i class="pi pi-arrow-left" />
|
||||
</button>
|
||||
<div class="workflow-info">
|
||||
<h1 class="workflow-name">{{ workflowName }}</h1>
|
||||
<span v-if="store.isGenerating" class="status-badge generating">
|
||||
<i class="pi pi-spin pi-spinner" />
|
||||
Generating...
|
||||
</span>
|
||||
<span v-else-if="store.currentWorkflow?.executionState === 'completed'" class="status-badge completed">
|
||||
<i class="pi pi-check" />
|
||||
Completed
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="header-right">
|
||||
<button
|
||||
v-if="!store.isGenerating"
|
||||
class="action-btn secondary"
|
||||
@click="handleReset"
|
||||
>
|
||||
<i class="pi pi-refresh" />
|
||||
Reset
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="store.isGenerating"
|
||||
class="action-btn danger"
|
||||
@click="handleCancel"
|
||||
>
|
||||
<i class="pi pi-times" />
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-else
|
||||
class="action-btn primary"
|
||||
:disabled="!store.canGenerate"
|
||||
@click="handleGenerate"
|
||||
>
|
||||
<i class="pi pi-play" />
|
||||
Generate
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Main content area -->
|
||||
<div class="workspace-content">
|
||||
<!-- Left column: Steps -->
|
||||
<aside class="steps-panel">
|
||||
<div class="panel-header">
|
||||
<h2 class="panel-title">Workflow Steps</h2>
|
||||
<span class="step-count">{{ store.currentSteps.length }} steps</span>
|
||||
</div>
|
||||
|
||||
<div class="steps-list">
|
||||
<LinearStepCard
|
||||
v-for="(step, index) in store.currentSteps"
|
||||
:key="step.id"
|
||||
:step="step"
|
||||
:step-index="index"
|
||||
:is-active="activeStep?.id === step.id"
|
||||
:is-completed="step.state === 'completed'"
|
||||
:is-executing="step.state === 'executing'"
|
||||
@select="selectStep"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Overall progress -->
|
||||
<div v-if="store.isGenerating" class="overall-progress">
|
||||
<div class="progress-header">
|
||||
<span class="progress-label">Overall Progress</span>
|
||||
<span class="progress-value">{{ Math.round(store.executionProgress) }}%</span>
|
||||
</div>
|
||||
<div class="progress-bar">
|
||||
<div
|
||||
class="progress-fill"
|
||||
:style="{ width: `${store.executionProgress}%` }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Center: Parameters -->
|
||||
<main class="parameters-panel">
|
||||
<div v-if="activeStep" class="parameters-content">
|
||||
<!-- Quick actions for this step -->
|
||||
<div v-if="activeStep.exposedWidgets.includes('seed')" class="quick-actions">
|
||||
<button class="quick-btn" @click="randomizeSeed">
|
||||
<i class="pi pi-sync" />
|
||||
Random Seed
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<LinearParameterPanel
|
||||
:step="activeStep"
|
||||
@update:widget="updateStepWidget"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else class="empty-parameters">
|
||||
<i class="pi pi-arrow-left text-4xl text-zinc-700" />
|
||||
<p>Select a step to configure its parameters</p>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Right: Output gallery -->
|
||||
<aside class="output-panel">
|
||||
<LinearOutputGallery
|
||||
:outputs="store.outputs"
|
||||
@delete="handleDeleteOutput"
|
||||
@download="handleDownloadOutput"
|
||||
/>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.linear-workspace {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
background: #09090b;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.workspace-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 24px;
|
||||
background: #18181b;
|
||||
border-bottom: 1px solid #27272a;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
background: #27272a;
|
||||
border: 1px solid #3f3f46;
|
||||
color: #a1a1aa;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.back-btn:hover {
|
||||
background: #3f3f46;
|
||||
color: #fafafa;
|
||||
}
|
||||
|
||||
.workflow-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.workflow-name {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #fafafa;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-badge.generating {
|
||||
background: rgba(59, 130, 246, 0.2);
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
.status-badge.completed {
|
||||
background: rgba(34, 197, 94, 0.2);
|
||||
color: #4ade80;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 10px 18px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.action-btn.primary {
|
||||
background: linear-gradient(135deg, #3b82f6, #8b5cf6);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.action-btn.primary:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
|
||||
}
|
||||
|
||||
.action-btn.primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.action-btn.secondary {
|
||||
background: #27272a;
|
||||
color: #a1a1aa;
|
||||
border: 1px solid #3f3f46;
|
||||
}
|
||||
|
||||
.action-btn.secondary:hover {
|
||||
background: #3f3f46;
|
||||
color: #fafafa;
|
||||
}
|
||||
|
||||
.action-btn.danger {
|
||||
background: #7f1d1d;
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
.action-btn.danger:hover {
|
||||
background: #991b1b;
|
||||
}
|
||||
|
||||
/* Content area */
|
||||
.workspace-content {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: 320px 1fr 360px;
|
||||
gap: 1px;
|
||||
background: #27272a;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Steps panel */
|
||||
.steps-panel {
|
||||
background: #09090b;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #1f1f23;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #fafafa;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.step-count {
|
||||
font-size: 12px;
|
||||
color: #71717a;
|
||||
}
|
||||
|
||||
.steps-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.overall-progress {
|
||||
padding: 16px;
|
||||
border-top: 1px solid #1f1f23;
|
||||
}
|
||||
|
||||
.progress-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.progress-label {
|
||||
font-size: 12px;
|
||||
color: #71717a;
|
||||
}
|
||||
|
||||
.progress-value {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 4px;
|
||||
background: #27272a;
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #3b82f6, #8b5cf6);
|
||||
border-radius: 2px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
/* Parameters panel */
|
||||
.parameters-panel {
|
||||
background: #09090b;
|
||||
overflow-y: auto;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.parameters-content {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.quick-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.quick-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 14px;
|
||||
background: #27272a;
|
||||
border: 1px solid #3f3f46;
|
||||
border-radius: 6px;
|
||||
color: #a1a1aa;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.quick-btn:hover {
|
||||
background: #3f3f46;
|
||||
color: #fafafa;
|
||||
}
|
||||
|
||||
.empty-parameters {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
height: 100%;
|
||||
color: #52525b;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Output panel */
|
||||
.output-panel {
|
||||
background: #09090b;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
}
|
||||
</style>
|
||||
16
ComfyUI_vibe/src/components/linear/index.ts
Normal file
16
ComfyUI_vibe/src/components/linear/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
// Linear Mode Components - Runway-style 2-Column Layout
|
||||
export { default as LinearIconSidebar } from './LinearIconSidebar.vue'
|
||||
export { default as LinearCreationPanel } from './LinearCreationPanel.vue'
|
||||
export { default as LinearHistoryPanel } from './LinearHistoryPanel.vue'
|
||||
|
||||
// Previous layout components (may be deprecated)
|
||||
export { default as LinearWorkflowSidebar } from './LinearWorkflowSidebar.vue'
|
||||
export { default as LinearInputPanel } from './LinearInputPanel.vue'
|
||||
|
||||
// Legacy components (deprecated)
|
||||
export { default as LinearTemplateCard } from './LinearTemplateCard.vue'
|
||||
export { default as LinearTemplateSelector } from './LinearTemplateSelector.vue'
|
||||
export { default as LinearStepCard } from './LinearStepCard.vue'
|
||||
export { default as LinearParameterPanel } from './LinearParameterPanel.vue'
|
||||
export { default as LinearOutputGallery } from './LinearOutputGallery.vue'
|
||||
export { default as LinearWorkspace } from './LinearWorkspace.vue'
|
||||
@@ -452,6 +452,8 @@ const mockRecents = [
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@reference "@/assets/css/main.css";
|
||||
|
||||
.bottom-panel {
|
||||
animation: slideUp 0.2s ease-out;
|
||||
}
|
||||
|
||||
@@ -27,6 +27,12 @@ const router = useRouter()
|
||||
const isTeam = computed(() => props.workspaceId === 'team')
|
||||
|
||||
const userMenuGroups = computed<MenuGroup[]>(() => [
|
||||
{
|
||||
label: 'Create',
|
||||
items: [
|
||||
{ label: 'Linear Mode', icon: 'pi pi-bolt', route: `/${props.workspaceId}/create` }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Overview',
|
||||
items: [
|
||||
@@ -46,6 +52,12 @@ const userMenuGroups = computed<MenuGroup[]>(() => [
|
||||
])
|
||||
|
||||
const teamMenuGroups = computed<MenuGroup[]>(() => [
|
||||
{
|
||||
label: 'Create',
|
||||
items: [
|
||||
{ label: 'Linear Mode', icon: 'pi pi-bolt', route: `/${props.workspaceId}/create` }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Overview',
|
||||
items: [
|
||||
|
||||
@@ -67,6 +67,17 @@ const v2Routes: RouteRecordRaw[] = [
|
||||
name: 'canvas',
|
||||
component: () => import('./views/v2/CanvasView.vue'),
|
||||
props: true
|
||||
},
|
||||
{
|
||||
path: '/create',
|
||||
name: 'linear-create',
|
||||
component: () => import('./views/linear/LinearView.vue')
|
||||
},
|
||||
{
|
||||
path: '/:workspaceId/create',
|
||||
name: 'workspace-linear-create',
|
||||
component: () => import('./views/linear/LinearView.vue'),
|
||||
props: true
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
58
ComfyUI_vibe/src/views/linear/LinearView.vue
Normal file
58
ComfyUI_vibe/src/views/linear/LinearView.vue
Normal file
@@ -0,0 +1,58 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import LinearIconSidebar from '@/components/linear/LinearIconSidebar.vue'
|
||||
import LinearCreationPanel from '@/components/linear/LinearCreationPanel.vue'
|
||||
import LinearHistoryPanel from '@/components/linear/LinearHistoryPanel.vue'
|
||||
|
||||
const sessionName = ref('Untitled session')
|
||||
const credits = ref(4625)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="linear-view flex h-screen bg-zinc-950">
|
||||
<!-- Left Icon Sidebar (Chat, Tool, Apps, Workflow) -->
|
||||
<LinearIconSidebar />
|
||||
|
||||
<!-- Left Creation Panel (prompt, upload, settings, generate) -->
|
||||
<LinearCreationPanel />
|
||||
|
||||
<!-- Right Main Area (queue/history) -->
|
||||
<div class="flex flex-1 flex-col">
|
||||
<!-- Top Bar -->
|
||||
<header class="flex h-12 shrink-0 items-center justify-between border-b border-zinc-800 bg-zinc-950 px-4">
|
||||
<div />
|
||||
|
||||
<!-- Center: Session Name -->
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-sm text-zinc-300">{{ sessionName }}</span>
|
||||
<button class="p-1 text-zinc-500 transition-colors hover:text-zinc-300">
|
||||
<i class="pi pi-ellipsis-h text-xs" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Right: Credits + Upgrade -->
|
||||
<div class="flex items-center gap-3">
|
||||
<button class="text-zinc-500 transition-colors hover:text-zinc-300">
|
||||
<i class="pi pi-question-circle text-sm" />
|
||||
</button>
|
||||
<button class="text-zinc-500 transition-colors hover:text-zinc-300">
|
||||
<i class="pi pi-external-link text-sm" />
|
||||
</button>
|
||||
<span class="text-xs text-zinc-400">{{ credits.toLocaleString() }} credits</span>
|
||||
<button class="rounded-md bg-blue-600 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-blue-500">
|
||||
Upgrade
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Main Content Area -->
|
||||
<LinearHistoryPanel />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.linear-view {
|
||||
font-family: var(--font-sans, system-ui);
|
||||
}
|
||||
</style>
|
||||
@@ -56,6 +56,16 @@ async function connectToServer() {
|
||||
: connectToServer()
|
||||
"
|
||||
/>
|
||||
|
||||
<Button
|
||||
label="Linear Mode"
|
||||
severity="secondary"
|
||||
icon="pi pi-bolt"
|
||||
@click="$router.push('/create')"
|
||||
/>
|
||||
<p class="text-center text-xs text-zinc-500">
|
||||
Simplified Runway/Midjourney-style interface
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
Reference in New Issue
Block a user