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:
orkhanart
2025-11-28 21:20:38 -08:00
parent 1c9715de4e
commit 5e178bb2ce
18 changed files with 3146 additions and 0 deletions

View File

@@ -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']

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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'

View File

@@ -452,6 +452,8 @@ const mockRecents = [
</template>
<style scoped>
@reference "@/assets/css/main.css";
.bottom-panel {
animation: slideUp 0.2s ease-out;
}

View File

@@ -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: [

View File

@@ -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
}
]

View 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>

View File

@@ -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>