ui i like most

This commit is contained in:
itten
2026-07-09 01:27:35 +03:00
parent 68640c0a3c
commit 55a6d6f88e
160 changed files with 31108 additions and 311 deletions
+175 -30
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { ref, markRaw, watch } from 'vue'
import { VueFlow } from '@vue-flow/core'
import type { Node, Edge, Connection } from '@vue-flow/core'
import { VueFlow, useVueFlow } from '@vue-flow/core'
import type { Node, Edge, Connection, XYPosition, EdgeMouseEvent } from '@vue-flow/core'
import '@vue-flow/core/dist/style.css'
import '@vue-flow/core/dist/theme-default.css'
@@ -13,15 +13,18 @@ import { buildGraph } from './graphBuilder'
import { defaultParams } from './nodeTypes'
import type { StudioNodeData } from './types'
const FLOW_ID = 'dmf-studio'
const { viewportHelper } = useVueFlow(FLOW_ID)
const studio = useStudioStore()
const nodeTypes = { studio: markRaw(StudioNode) }
const nodes = ref<Node[]>([])
const edges = ref<Edge[]>([])
const flowWrapperRef = ref<HTMLElement>()
let nodeCounter = 0
// Sync server-push state into Vue Flow node data
watch(
() => studio.serverStatus,
(status) => {
@@ -36,31 +39,64 @@ watch(
{ deep: true }
)
function addNode(nodeType: string) {
const DROP_OFFSET_X = 100
const DROP_OFFSET_Y = 20
function createNodeAt(nodeType: string, pos: XYPosition) {
nodeCounter++
const id = `${nodeType}-${nodeCounter}`
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const newNode: Node = {
id,
type: 'studio',
position: { x: 160 + (nodeCounter % 6) * 220, y: 80 + Math.floor(nodeCounter / 6) * 200 },
position: pos,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
data: { nodeType, params: defaultParams(nodeType), serverState: undefined } as any,
}
// @ts-ignore TS2589: Vue Flow Node<T> generic is too deep for vue-tsc's instantiation limit
// @ts-ignore TS2589
nodes.value = [...nodes.value, newNode]
}
function onDrop(event: DragEvent) {
const nodeType = event.dataTransfer?.getData('application/dmf-node-type')
if (!nodeType) return
const vp = viewportHelper.value
if (!vp) return
const flowPos = vp.screenToFlowCoordinate({ x: event.clientX, y: event.clientY })
createNodeAt(nodeType, { x: flowPos.x - DROP_OFFSET_X, y: flowPos.y - DROP_OFFSET_Y })
}
function onDragOver(event: DragEvent) {
if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy'
}
function addNode(nodeType: string) {
const vp = viewportHelper.value
if (vp && flowWrapperRef.value) {
const rect = flowWrapperRef.value.getBoundingClientRect()
const center = { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }
const flowPos = vp.screenToFlowCoordinate(center)
createNodeAt(nodeType, { x: flowPos.x - DROP_OFFSET_X, y: flowPos.y - DROP_OFFSET_Y })
} else {
createNodeAt(nodeType, { x: 200, y: 80 })
}
}
function onConnect(connection: Connection) {
const srcHandle = connection.sourceHandle ?? ''
const tgtHandle = connection.targetHandle ?? ''
if (srcHandle.startsWith('video') !== tgtHandle.startsWith('video')) return
const kind = srcHandle.startsWith('video') ? 'video' : 'audio'
edges.value = [
...edges.value,
{ ...connection, id: `e-${Date.now()}`, animated: true } as Edge,
{ ...connection, id: `e-${Date.now()}`, animated: true, class: `edge-${kind}` } as Edge,
]
}
function onEdgeClick(event: EdgeMouseEvent) {
edges.value = edges.value.filter(e => e.id !== event.edge.id)
}
function run() {
if (nodes.value.length === 0) { alert('Add at least one node first.'); return }
if (edges.value.length === 0) { alert('Connect the nodes with at least one wire first.'); return }
@@ -84,16 +120,29 @@ function clear() {
<Toolbar @run="run" @stop="stop" @clear="clear" />
<div class="canvas-area">
<NodePalette @add-node="addNode" />
<VueFlow
v-model:nodes="nodes"
v-model:edges="edges"
:node-types="nodeTypes"
class="flow-canvas"
:min-zoom="0.3"
:max-zoom="2"
fit-view-on-init
@connect="onConnect"
/>
<div
class="flow-wrapper"
ref="flowWrapperRef"
@dragover.prevent="onDragOver"
@drop.prevent="onDrop"
>
<VueFlow
:id="FLOW_ID"
v-model:nodes="nodes"
v-model:edges="edges"
:node-types="nodeTypes"
class="flow-canvas"
:min-zoom="0.3"
:max-zoom="2"
fit-view-on-init
@connect="onConnect"
@edge-click="onEdgeClick"
/>
<div v-if="nodes.length === 0" class="canvas-hint">
<div class="hint-label">EMPTY WORKSPACE</div>
<div class="hint-text">DRAG NODES FROM LEFT PANEL</div>
</div>
</div>
</div>
</div>
</template>
@@ -104,35 +153,96 @@ html, body, #app {
padding: 0;
width: 100%;
height: 100%;
background: #11111b;
font-family: 'Inter', system-ui, sans-serif;
color: #cdd6f4;
background: var(--bg-base);
font-family: var(--font-mono);
color: var(--text-primary);
-webkit-font-smoothing: antialiased;
overflow: hidden;
}
*, *::before, *::after {
box-sizing: border-box;
}
/* ── Vue Flow: Canvas ── */
.vue-flow {
background: #11111b !important;
background-color: var(--bg-base) !important;
background-image:
radial-gradient(circle, #1a1a1a 1px, transparent 1px) !important;
background-size: 24px 24px !important;
}
.vue-flow__background {
background: #11111b !important;
background: transparent !important;
}
/* ── Vue Flow: Edges — thin white wires ── */
.vue-flow__edge {
cursor: pointer;
}
.vue-flow__edge-path {
stroke: #89b4fa !important;
stroke-width: 2 !important;
stroke: #E6E6E6 !important;
stroke-width: 1.5 !important;
}
.vue-flow__edge.edge-audio .vue-flow__edge-path {
stroke-dasharray: 4 3 !important;
}
.vue-flow__edge.animated .vue-flow__edge-path {
stroke-dasharray: 5;
animation: dashdraw 0.5s linear infinite;
stroke-dasharray: 5 4;
animation: dashflow 0.8s linear infinite;
}
.vue-flow__edge:hover .vue-flow__edge-path {
stroke: var(--accent) !important;
stroke-width: 2 !important;
}
@keyframes dashflow {
to { stroke-dashoffset: -18; }
}
/* ── Vue Flow: Handles ── */
.vue-flow__handle {
border-radius: 50% !important;
border: 1px solid var(--border-medium) !important;
}
@keyframes dashdraw {
to { stroke-dashoffset: -10; }
/* ── Vue Flow: Selection — red dot indicator ── */
.vue-flow__node.selectable:focus,
.vue-flow__node.selectable:active {
outline: none !important;
}
.vue-flow__node.selected .studio-node {
border-color: var(--accent) !important;
}
/* ── Vue Flow: Controls ── */
.vue-flow__controls {
background: var(--bg-panel-1) !important;
border: 1px solid var(--border-thin) !important;
border-radius: 0 !important;
box-shadow: none !important;
margin: 8px !important;
}
.vue-flow__controls-button {
background: transparent !important;
border-bottom: 1px solid var(--border-thin) !important;
color: var(--text-secondary) !important;
border-radius: 0 !important;
}
.vue-flow__controls-button:hover {
background: var(--bg-elevated) !important;
color: var(--text-primary) !important;
}
.vue-flow__controls-button svg {
fill: currentColor !important;
}
</style>
@@ -143,15 +253,50 @@ html, body, #app {
width: 100vw;
height: 100vh;
overflow: hidden;
background: var(--bg-base);
}
.canvas-area {
display: flex;
flex: 1;
overflow: hidden;
position: relative;
}
.flow-wrapper {
flex: 1;
position: relative;
overflow: hidden;
}
.flow-canvas {
flex: 1;
width: 100%;
height: 100%;
}
</style>
.canvas-hint {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
pointer-events: none;
z-index: var(--z-hint);
gap: 8px;
}
.hint-label {
font-size: 12px;
font-weight: 400;
letter-spacing: 4px;
color: var(--text-dim);
}
.hint-text {
font-size: 10px;
letter-spacing: 2px;
color: var(--text-dim);
opacity: 0.7;
}
</style>
-95
View File
@@ -1,95 +0,0 @@
<script setup lang="ts">
import { ref } from 'vue'
import viteLogo from '../assets/vite.svg'
import heroImg from '../assets/hero.png'
import vueLogo from '../assets/vue.svg'
const count = ref(0)
</script>
<template>
<section id="center">
<div class="hero">
<img :src="heroImg" class="base" width="170" height="179" alt="" />
<img :src="vueLogo" class="framework" alt="Vue logo" />
<img :src="viteLogo" class="vite" alt="Vite logo" />
</div>
<div>
<h1>Get started</h1>
<p>Edit <code>src/App.vue</code> and save to test <code>HMR</code></p>
</div>
<button type="button" class="counter" @click="count++">
Count is {{ count }}
</button>
</section>
<div class="ticks"></div>
<section id="next-steps">
<div id="docs">
<svg class="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#documentation-icon"></use>
</svg>
<h2>Documentation</h2>
<p>Your questions, answered</p>
<ul>
<li>
<a href="https://vite.dev/" target="_blank">
<img class="logo" :src="viteLogo" alt="" />
Explore Vite
</a>
</li>
<li>
<a href="https://vuejs.org/" target="_blank">
<img class="button-icon" :src="vueLogo" alt="" />
Learn more
</a>
</li>
</ul>
</div>
<div id="social">
<svg class="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#social-icon"></use>
</svg>
<h2>Connect with us</h2>
<p>Join the Vite community</p>
<ul>
<li>
<a href="https://github.com/vitejs/vite" target="_blank">
<svg class="button-icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#github-icon"></use>
</svg>
GitHub
</a>
</li>
<li>
<a href="https://chat.vite.dev/" target="_blank">
<svg class="button-icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#discord-icon"></use>
</svg>
Discord
</a>
</li>
<li>
<a href="https://x.com/vite_js" target="_blank">
<svg class="button-icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#x-icon"></use>
</svg>
X.com
</a>
</li>
<li>
<a href="https://bsky.app/profile/vite.dev" target="_blank">
<svg class="button-icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#bluesky-icon"></use>
</svg>
Bluesky
</a>
</li>
</ul>
</div>
</section>
<div class="ticks"></div>
<section id="spacer"></section>
</template>
+107
View File
@@ -0,0 +1,107 @@
<template>
<svg
:width="size"
:height="size"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
:stroke-width="strokeWidth"
stroke-linecap="round"
stroke-linejoin="round"
class="dmf-icon"
>
<template v-if="name === 'play'">
<polygon points="6 3 20 12 6 21 6 3" fill="currentColor" stroke="none" />
</template>
<template v-else-if="name === 'stop'">
<rect x="5" y="5" width="14" height="14" rx="2" fill="currentColor" stroke="none" />
</template>
<template v-else-if="name === 'trash'">
<path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2m-1 0v14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2V6" />
<line x1="10" y1="11" x2="10" y2="17" />
<line x1="14" y1="11" x2="14" y2="17" />
</template>
<template v-else-if="name === 'x'">
<path d="M18 6 6 18M6 6l12 12" />
</template>
<template v-else-if="name === 'plus'">
<path d="M12 5v14M5 12h14" />
</template>
<template v-else-if="name === 'zap'">
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2" fill="currentColor" stroke="none" />
</template>
<template v-else-if="name === 'grid'">
<rect x="3" y="3" width="7" height="7" rx="1" />
<rect x="14" y="3" width="7" height="7" rx="1" />
<rect x="3" y="14" width="7" height="7" rx="1" />
<rect x="14" y="14" width="7" height="7" rx="1" />
</template>
<template v-else-if="name === 'wifi'">
<path d="M5 12.55a11 11 0 0 1 11 0M8.53 16.11a6 6 0 0 1 6.95 0M2 8.82a15 15 0 0 1 20 0" />
<circle cx="12" cy="20" r="0.5" fill="currentColor" />
</template>
<template v-else-if="name === 'video'">
<rect x="2" y="5" width="14" height="14" rx="2" />
<path d="M22 8 16 12 22 16 22 8" />
</template>
<template v-else-if="name === 'file-video'">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<path d="M9 12 14 15 9 18 9 12" fill="currentColor" stroke="none" />
</template>
<template v-else-if="name === 'cast'">
<path d="M2 8.5a20 20 0 0 1 20 0M5 12.1a14 14 0 0 1 14 0M8.5 15.6a8 8 0 0 1 7 0" />
<circle cx="12" cy="20" r="1" fill="currentColor" stroke="none" />
</template>
<template v-else-if="name === 'monitor'">
<rect x="2" y="3" width="20" height="14" rx="2" />
<polyline points="8 21 12 17 16 21" />
</template>
<template v-else-if="name === 'eye-off'">
<path d="M9.88 9.88a3 3 0 0 0 4.24 4.24" />
<path d="M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 10 7 10 7a13.16 13.16 0 0 1-1.67 2.68" />
<path d="M6.61 6.61A13.526 13.526 0 0 0 2 12s3 7 10 7a9.74 9.74 0 0 0 5.39-1.61" />
<path d="m2 2 20 20" />
</template>
<template v-else-if="name === 'waveform'">
<path d="M2 12 5 12 7 6 9 18 11 9 13 15 15 4 17 20 19 10 22 12" />
</template>
<template v-else-if="name === 'signal'">
<path d="M2 20h.01M6 20v-4M10 20v-8M14 20V8M18 20V4M22 20v-2" />
</template>
<template v-else-if="name === 'power'">
<path d="M12 2v10M18.4 6.6a9 9 0 1 1-12.8 0" />
</template>
<template v-else-if="name === 'arrow-right'">
<path d="M5 12h14M12 5l7 7-7 7" />
</template>
<template v-else-if="name === 'check'">
<path d="M20 6 9 17l-5-5" />
</template>
<template v-else-if="name === 'dot'">
<circle cx="12" cy="12" r="4" fill="currentColor" stroke="none" />
</template>
<template v-else-if="name === 'folder'">
<path d="M4 20h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.93a2 2 0 0 1-1.66-.9l-.82-1.2A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13c0 1.1.9 2 2 2Z" />
</template>
</svg>
</template>
<script setup lang="ts">
withDefaults(defineProps<{
name: string
size?: number
strokeWidth?: number
}>(), {
size: 18,
strokeWidth: 2,
})
</script>
<style scoped>
.dmf-icon {
display: inline-block;
flex-shrink: 0;
vertical-align: middle;
}
</style>
+113 -44
View File
@@ -5,77 +5,146 @@ const emit = defineEmits<{
addNode: [nodeType: string]
}>()
const groups = [
{ label: 'Sources', types: ['testpattern', 'ndiin', 'decklinkin'] },
{ label: 'Sinks', types: ['ndiout', 'decklinkout', 'fakesink'] },
interface PaletteGroup {
label: string
types: string[]
}
const groups: PaletteGroup[] = [
{ label: 'SOURCES', types: ['testpattern', 'ndiin', 'decklinkin', 'videoin'] },
{ label: 'SINKS', types: ['ndiout', 'decklinkout', 'fakesink'] },
]
const iconMap: Record<string, string> = {
testpattern: '▦',
ndiin: '◬',
decklinkin: '◧',
videoin: '▣',
ndiout: '◬',
decklinkout: '◧',
fakesink: '◯',
}
function onDragStart(event: DragEvent, nodeType: string) {
if (event.dataTransfer) {
event.dataTransfer.setData('application/dmf-node-type', nodeType)
event.dataTransfer.effectAllowed = 'copy'
}
}
</script>
<template>
<div class="palette">
<div class="palette-title">Nodes</div>
<div v-for="group in groups" :key="group.label" class="palette-group">
<div class="group-label">{{ group.label }}</div>
<button
v-for="type in group.types"
:key="type"
class="palette-btn"
@click="emit('addNode', type)"
>
+ {{ NODE_TYPES[type]?.label ?? type }}
</button>
<div class="palette-label">NODES</div>
<div class="palette-body">
<div v-for="group in groups" :key="group.label" class="palette-group">
<div class="group-label">{{ group.label }}</div>
<div class="group-items">
<button
v-for="type in group.types"
:key="type"
class="node-btn"
draggable="true"
@dragstart="onDragStart($event, type)"
@click="emit('addNode', type)"
>
<span class="node-icon">{{ iconMap[type] ?? '◯' }}</span>
<span class="node-name">{{ NODE_TYPES[type]?.label ?? type }}</span>
</button>
</div>
</div>
</div>
<div class="palette-footer">DRAG OR CLICK TO ADD</div>
</div>
</template>
<style scoped>
.palette {
width: 140px;
background: #181825;
border-right: 1px solid #313244;
width: var(--palette-width);
background: var(--bg-panel-0);
border-right: 1px solid var(--border-thin);
display: flex;
flex-direction: column;
flex-shrink: 0;
overflow-y: auto;
overflow: hidden;
}
.palette-title {
font-size: 11px;
font-weight: 700;
color: #6c7086;
letter-spacing: 0.8px;
padding: 12px 12px 6px;
text-transform: uppercase;
.palette-label {
height: 32px;
display: flex;
align-items: center;
padding: 0 var(--sp-4);
border-bottom: 1px solid var(--border-thin);
font-size: 9px;
font-weight: 500;
letter-spacing: 3px;
color: var(--text-muted);
flex-shrink: 0;
}
.palette-body {
flex: 1;
overflow-y: auto;
padding: 0;
}
.palette-body::-webkit-scrollbar { width: 3px; }
.palette-body::-webkit-scrollbar-track { background: transparent; }
.palette-body::-webkit-scrollbar-thumb { background: var(--border-medium); }
.palette-group {
padding: 4px 0 8px;
border-bottom: 1px dotted var(--border-thin);
}
.group-label {
font-size: 10px;
color: #45475a;
padding: 4px 12px 2px;
letter-spacing: 0.5px;
text-transform: uppercase;
padding: 10px var(--sp-4) 4px;
font-size: 8px;
font-weight: 500;
letter-spacing: 2px;
color: var(--text-dim);
}
.palette-btn {
display: block;
.node-btn {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
text-align: left;
background: none;
background: transparent;
border: none;
padding: 6px 12px;
font-size: 12px;
color: #bac2de;
padding: 6px var(--sp-4);
font-family: var(--font-mono);
font-size: 10px;
color: var(--text-secondary);
cursor: pointer;
transition: background 0.1s, color 0.1s;
border-radius: 0;
text-align: left;
text-transform: uppercase;
letter-spacing: 1px;
transition: background var(--dur-fast) var(--ease), color var(--dur-fast) var(--ease);
}
.palette-btn:hover {
background: #2a2b3d;
color: #cdd6f4;
.node-btn:hover {
background: var(--bg-elevated);
color: var(--text-primary);
}
</style>
.node-icon {
font-size: 12px;
color: var(--text-muted);
width: 14px;
text-align: center;
}
.node-btn:hover .node-icon {
color: var(--text-primary);
}
.palette-footer {
padding: 8px var(--sp-4);
border-top: 1px solid var(--border-thin);
font-size: 8px;
letter-spacing: 2px;
color: var(--text-dim);
text-align: center;
flex-shrink: 0;
}
</style>
+66 -40
View File
@@ -11,22 +11,24 @@ const emit = defineEmits<{
const studio = useStudioStore()
const statusLabel = computed(() =>
studio.connected ? 'Connected' : 'Disconnected'
studio.connected ? 'ONLINE' : 'OFFLINE'
)
</script>
<template>
<div class="toolbar">
<span class="app-title">DMF Studio</span>
<div class="brand">DMF STUDIO</div>
<div class="toolbar-center">
<button class="btn btn-run" @click="emit('run')"> Run</button>
<button class="btn btn-stop" @click="emit('stop')"> Stop</button>
<button class="btn btn-clear" @click="emit('clear')">Clear</button>
<div class="separator" />
<div class="actions">
<button class="btn" @click="emit('run')"> RUN</button>
<button class="btn" @click="emit('stop')"> STOP</button>
<button class="btn btn-danger" @click="emit('clear')">CLEAR</button>
</div>
<div class="status">
<span :class="['status-dot', studio.connected ? 'dot-ok' : 'dot-err']" />
<span :class="['dot', studio.connected ? 'dot-on' : 'dot-off']" />
<span class="status-text">{{ statusLabel }}</span>
</div>
</div>
@@ -36,57 +38,81 @@ const statusLabel = computed(() =>
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
height: 44px;
background: #181825;
border-bottom: 1px solid #313244;
height: var(--toolbar-height);
padding: 0 var(--sp-4);
gap: var(--sp-4);
background: var(--bg-panel-0);
border-bottom: 1px solid var(--border-thin);
flex-shrink: 0;
z-index: var(--z-toolbar);
}
.brand {
font-size: 12px;
font-weight: 500;
letter-spacing: 4px;
color: var(--text-primary);
text-transform: uppercase;
}
.separator {
width: 1px;
height: 20px;
background: var(--border-thin);
flex-shrink: 0;
}
.app-title {
font-size: 14px;
font-weight: 700;
color: #89b4fa;
letter-spacing: 0.5px;
}
.toolbar-center {
.actions {
display: flex;
gap: 8px;
gap: var(--sp-1);
}
.btn {
padding: 5px 14px;
border: none;
border-radius: 5px;
font-size: 12px;
font-weight: 600;
background: transparent;
border: 1px solid var(--border-thin);
color: var(--text-secondary);
padding: 5px 12px;
font-size: 10px;
font-family: var(--font-mono);
font-weight: 500;
letter-spacing: 1.5px;
cursor: pointer;
transition: opacity 0.15s;
border-radius: 0;
text-transform: uppercase;
transition: border-color var(--dur-fast) var(--ease), color var(--dur-fast) var(--ease);
}
.btn:hover { opacity: 0.85; }
.btn:hover {
border-color: var(--border-medium);
color: var(--text-primary);
}
.btn-run { background: #a6e3a1; color: #1e2030; }
.btn-stop { background: #f38ba8; color: #1e2030; }
.btn-clear { background: #313244; color: #9399b2; }
.btn-danger:hover {
border-color: var(--accent-border);
color: var(--accent);
}
.status {
margin-left: auto;
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: #9399b2;
gap: 8px;
}
.status-dot {
width: 8px;
height: 8px;
.dot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.dot-ok { background: #a6e3a1; box-shadow: 0 0 4px #a6e3a1; }
.dot-err { background: #f38ba8; }
</style>
.dot-on { background: var(--text-primary); }
.dot-off { background: var(--accent); }
.status-text {
font-size: 9px;
font-weight: 400;
letter-spacing: 2px;
color: var(--text-secondary);
}
</style>
+173 -94
View File
@@ -1,30 +1,43 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Handle, Position } from '@vue-flow/core'
import { Handle, Position, useVueFlow } from '@vue-flow/core'
import type { NodeProps } from '@vue-flow/core'
import type { StudioNodeData } from '../../types'
import { NODE_TYPES } from '../../nodeTypes'
const props = defineProps<NodeProps<StudioNodeData>>()
const { removeNodes } = useVueFlow()
const typeDef = computed(() => NODE_TYPES[props.data.nodeType] ?? null)
const inputs = computed(() => typeDef.value?.ports.filter(p => p.direction === 'in') ?? [])
const outputs = computed(() => typeDef.value?.ports.filter(p => p.direction === 'out') ?? [])
const stateBadge = computed(() => {
const isSource = computed(() => outputs.value.length > 0)
const iconMap: Record<string, string> = {
testpattern: '▦',
ndiin: '◬',
decklinkin: '◧',
videoin: '▣',
ndiout: '◬',
decklinkout: '◧',
fakesink: '◯',
}
const nodeIcon = computed(() => iconMap[props.data.nodeType] ?? '◯')
const stateText = computed(() => {
switch (props.data.serverState) {
case 'running': return { text: 'RUN', cls: 'badge-running' }
case 'stopped': return { text: 'STOP', cls: 'badge-stopped' }
case 'crashed': return { text: 'ERR', cls: 'badge-crashed' }
default: return null
case 'running': return 'RUN'
case 'stopped': return 'STOP'
case 'crashed': return 'ERR'
default: return ''
}
})
// Fixed layout constants (keep in sync with CSS)
const HEADER_H = 36 // px — node-header height
const PORT_H = 26 // px — port-row height
const HEADER_H = 38
const PORT_H = 28
// Handle is centered on its port row
function handleTopPx(index: number): string {
return `${HEADER_H + PORT_H * index + PORT_H / 2}px`
}
@@ -32,6 +45,19 @@ function handleTopPx(index: number): string {
function setParam(key: string, value: string | number) {
props.data.params[key] = value
}
const handleFileChange = (key: string, event: Event) => {
const target = event.target as HTMLInputElement
if (target.files && target.files.length > 0) {
const file = target.files[0].name
const basePath = '/home/itten/test-vid/'
setParam(key, basePath + file)
}
}
function onDeleteNode() {
removeNodes([props.id])
}
</script>
<template>
@@ -42,7 +68,7 @@ function setParam(key: string, value: string | number) {
'node-crashed': data.serverState === 'crashed',
}"
>
<!-- Input handles (left) positioned to match port rows -->
<!-- Input handles -->
<Handle
v-for="(port, i) in inputs"
:key="port.id"
@@ -55,22 +81,22 @@ function setParam(key: string, value: string | number) {
<!-- Header -->
<div class="node-header">
<span class="node-icon">{{ nodeIcon }}</span>
<span class="node-label">{{ typeDef?.label ?? data.nodeType }}</span>
<span v-if="stateBadge" :class="['badge', stateBadge.cls]">{{ stateBadge.text }}</span>
<span v-if="stateText" :class="['state', `state-${data.serverState}`]">{{ stateText }}</span>
<button class="node-close" @click.stop="onDeleteNode">×</button>
</div>
<!-- Port rows -->
<div class="node-ports">
<!-- Ports -->
<div v-if="inputs.length || outputs.length" class="node-ports">
<div class="ports-side ports-left">
<div v-for="port in inputs" :key="port.id" class="port-row">
<span :class="`port-dot dot-${port.kind}`" />
<span class="port-name">{{ port.kind }}</span>
<span :class="['port-name', `port-${port.kind}`]">{{ port.kind }}</span>
</div>
</div>
<div class="ports-side ports-right">
<div v-for="port in outputs" :key="port.id" class="port-row port-row-right">
<span class="port-name">{{ port.kind }}</span>
<span :class="`port-dot dot-${port.kind}`" />
<span :class="['port-name', `port-${port.kind}`]">{{ port.kind }}</span>
</div>
</div>
</div>
@@ -78,7 +104,7 @@ function setParam(key: string, value: string | number) {
<!-- Params -->
<div v-if="typeDef && typeDef.params.length" class="node-body">
<div v-for="param in typeDef.params" :key="param.key" class="param-row">
<label class="param-label">{{ param.label }}</label>
<label class="param-label">{{ param.label.toUpperCase() }}</label>
<select
v-if="param.type === 'select'"
class="param-input"
@@ -98,13 +124,19 @@ function setParam(key: string, value: string | number) {
:max="param.max"
@change="setParam(param.key, Number(($event.target as HTMLInputElement).value))"
/>
<input
v-else-if="param.type === 'input'"
type="file"
class="param-input-file"
@change="handleFileChange(param.key, $event)"
/>
</div>
</div>
<!-- Footer: node id -->
<!-- Footer -->
<div class="node-id">{{ id }}</div>
<!-- Output handles (right) positioned to match port rows -->
<!-- Output handles -->
<Handle
v-for="(port, i) in outputs"
:key="port.id"
@@ -118,62 +150,90 @@ function setParam(key: string, value: string | number) {
</template>
<style scoped>
/* Keep HEADER_H and PORT_H in sync with the JS constants above */
.studio-node {
background: #1e2030;
border: 1px solid #383a4e;
border-radius: 8px;
min-width: 190px;
font-size: 12px;
color: #cdd6f4;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
transition: border-color 0.2s, box-shadow 0.2s;
background: var(--bg-panel-1);
border: 1px solid var(--border-thin);
border-radius: 0;
min-width: var(--node-min-width);
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-primary);
box-shadow: none;
transition: border-color var(--dur-base) var(--ease);
position: relative;
overflow: visible;
}
.studio-node.node-running {
border-color: #a6e3a1;
box-shadow: 0 0 0 1px #a6e3a1, 0 4px 12px rgba(0, 0, 0, 0.4);
.node-running {
border-color: var(--text-primary);
}
.studio-node.node-crashed {
border-color: #f38ba8;
box-shadow: 0 0 0 1px #f38ba8, 0 4px 12px rgba(0, 0, 0, 0.4);
.node-crashed {
border-color: var(--accent);
}
/* HEADER_H = 36px */
/* ── Header ── */
.node-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
height: var(--node-header-h);
padding: 0 10px;
height: 36px;
border-bottom: 1px solid #383a4e;
gap: 6px;
border-bottom: 1px solid var(--border-thin);
}
.node-icon {
font-size: 13px;
color: var(--text-muted);
}
.node-label {
font-weight: 600;
font-size: 13px;
color: #89b4fa;
font-size: 11px;
font-weight: 500;
letter-spacing: 1px;
color: var(--text-primary);
text-transform: uppercase;
flex: 1;
}
.badge {
font-size: 9px;
font-weight: 700;
padding: 2px 5px;
border-radius: 3px;
letter-spacing: 0.5px;
.state {
font-size: 8px;
font-weight: 500;
letter-spacing: 1px;
padding: 1px 5px;
border: 1px solid currentColor;
}
.badge-running { background: #a6e3a1; color: #1e2030; }
.badge-stopped { background: #6c7086; color: #cdd6f4; }
.badge-crashed { background: #f38ba8; color: #1e2030; }
/* PORT_H = 26px per row */
.state-running { color: var(--text-primary); }
.state-stopped { color: var(--text-muted); }
.state-crashed { color: var(--accent); }
.node-close {
background: transparent;
border: 1px solid var(--border-thin);
color: var(--text-muted);
width: 18px;
height: 18px;
font-size: 12px;
cursor: pointer;
border-radius: 0;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
transition: border-color var(--dur-fast), color var(--dur-fast);
}
.node-close:hover {
border-color: var(--accent);
color: var(--accent);
}
/* ── Port rows ── */
.node-ports {
display: flex;
justify-content: space-between;
border-bottom: 1px solid #2a2b3d;
border-bottom: 1px solid var(--border-thin);
}
.ports-side {
@@ -184,9 +244,8 @@ function setParam(key: string, value: string | number) {
.port-row {
display: flex;
align-items: center;
gap: 5px;
height: 26px;
padding: 0 8px;
height: var(--node-port-h);
padding: 0 10px;
}
.port-row-right {
@@ -194,30 +253,26 @@ function setParam(key: string, value: string | number) {
}
.port-name {
font-size: 10px;
color: #6c7086;
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.3px;
letter-spacing: 1.5px;
color: var(--text-secondary);
}
.port-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
.port-video { color: var(--text-primary); }
.port-audio {
color: var(--text-secondary);
opacity: 0.7;
}
.dot-video { background: #89b4fa; }
.dot-audio { background: #fab387; }
/* Params */
/* ── Params ── */
.node-body {
padding: 8px 10px;
display: flex;
flex-direction: column;
gap: 6px;
border-bottom: 1px solid #2a2b3d;
border-bottom: 1px solid var(--border-thin);
background: var(--bg-base);
}
.param-row {
@@ -227,46 +282,70 @@ function setParam(key: string, value: string | number) {
}
.param-label {
color: #9399b2;
min-width: 70px;
font-size: 11px;
color: var(--text-muted);
min-width: 68px;
font-size: 8px;
font-weight: 400;
letter-spacing: 1px;
flex-shrink: 0;
}
.param-input {
flex: 1;
background: #11111b;
border: 1px solid #383a4e;
border-radius: 4px;
color: #cdd6f4;
font-size: 11px;
background: var(--bg-base);
border: 1px solid var(--border-thin);
border-radius: 0;
color: var(--text-primary);
font-size: 10px;
font-family: var(--font-mono);
padding: 3px 6px;
outline: none;
cursor: pointer;
min-width: 0;
}
.param-input:focus {
border-color: #89b4fa;
border-color: var(--accent);
}
/* Footer */
.param-input option {
background: var(--bg-panel-1);
}
.param-input-file {
flex: 1;
background: var(--bg-base);
border: 1px solid var(--border-thin);
border-radius: 0;
color: var(--text-primary);
font-size: 9px;
font-family: var(--font-mono);
padding: 2px 4px;
outline: none;
cursor: pointer;
max-width: 100px;
}
/* ── Footer ── */
.node-id {
padding: 4px 10px;
font-size: 10px;
color: #45475a;
font-size: 8px;
color: var(--text-dim);
letter-spacing: 0.5px;
}
/* Handles — use :deep() to pierce Vue Flow's DOM */
/* ── Handles — circular sockets ── */
:deep(.handle-video) {
background: #89b4fa !important;
border-color: #89b4fa !important;
width: 11px !important;
height: 11px !important;
background: var(--text-primary) !important;
border-color: var(--bg-base) !important;
width: var(--handle-size) !important;
height: var(--handle-size) !important;
}
:deep(.handle-audio) {
background: #fab387 !important;
border-color: #fab387 !important;
width: 11px !important;
height: 11px !important;
background: transparent !important;
border: 1px solid var(--text-secondary) !important;
width: var(--handle-size) !important;
height: var(--handle-size) !important;
}
</style>
</style>
+2 -1
View File
@@ -1,5 +1,6 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import './tokens.css'
createApp(App).use(createPinia()).mount('#app')
createApp(App).use(createPinia()).mount('#app')
+11
View File
@@ -92,6 +92,17 @@ export const NODE_TYPES: Record<string, NodeTypeDef> = {
],
params: [],
},
videoin: {
type: 'videoin',
label: 'Multimedia file in',
ports: [
{ id: 'video-out', kind: 'video', direction: 'out' },
{ id: 'audio-out', kind: 'audio', direction: 'out' },
],
params: [
{ key: 'file', label: 'File', type: 'input', default: 0, min: 0, max: 15 }
],
},
}
// Default params for a new node of the given type
+67
View File
@@ -0,0 +1,67 @@
/* ═══════════════════════════════════════════════════════════════
DMF Studio — Cyber-Industrial Design Tokens
Monochrome palette, single red accent, monospace typography
═══════════════════════════════════════════════════════════════ */
:root {
/* ── Color Palette ── */
--bg-base: #060606;
--bg-panel-0: #0B0B0B;
--bg-panel-1: #101010;
--bg-elevated: #141414;
--border-thin: #2A2A2A;
--border-medium: #353535;
--text-primary: #E6E6E6;
--text-secondary: #8B8B8B;
--text-muted: #666666;
--text-dim: #444444;
--accent: #FF4040;
--accent-dim: rgba(255, 64, 64, 0.15);
--accent-border: rgba(255, 64, 64, 0.40);
--accent-glow: 0 0 8px rgba(255, 64, 64, 0.30);
--status-ok: #E6E6E6;
--status-err: #FF4040;
/* ── Typography ── */
--font-mono: 'JetBrains Mono', 'IBM Plex Mono', 'SF Mono', monospace;
/* ── Radii — flat, no rounded corners ── */
--radius: 0px;
/* ── Shadows — none ── */
--shadow: none;
/* ── Transitions ── */
--ease: linear;
--dur-fast: 100ms;
--dur-base: 150ms;
--dur-slow: 250ms;
/* ── Spacing — strict 8px grid ── */
--sp-1: 4px;
--sp-2: 8px;
--sp-3: 12px;
--sp-4: 16px;
--sp-5: 20px;
--sp-6: 24px;
--sp-8: 32px;
/* ── Borders ── */
--border-width: 1px;
/* ── App-specific ── */
--toolbar-height: 40px;
--palette-width: 200px;
--node-header-h: 38px;
--node-port-h: 28px;
--node-min-width: 200px;
--handle-size: 12px;
/* ── Z-index ── */
--z-toolbar: 200;
--z-hint: 5;
}
+2 -2
View File
@@ -9,7 +9,7 @@ export interface PortDef {
export interface ParamDef {
key: string
label: string
type: 'select' | 'number'
type: 'select' | 'number' | 'input'
options?: { label: string; value: string | number }[]
default: string | number
min?: number
@@ -27,7 +27,7 @@ export interface NodeTypeDef {
// All config as a flat record; format keys (res, fps) handled separately in graphBuilder
export interface StudioNodeData {
nodeType: string
params: Record<string, string | number>
params: Record<string, string | number | File>
serverState?: 'running' | 'stopped' | 'crashed'
}