init commit
This commit is contained in:
32
ui/framework/src/components/LayoutGrid.vue
Normal file
32
ui/framework/src/components/LayoutGrid.vue
Normal file
@@ -0,0 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
const props = withDefaults(defineProps<{
|
||||
columns?: number
|
||||
rows?: number
|
||||
gap?: string
|
||||
}>(), {
|
||||
columns: 2,
|
||||
rows: 2,
|
||||
gap: 'var(--space-2)',
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="layout-grid"
|
||||
:style="{
|
||||
gridTemplateColumns: `repeat(${props.columns}, 1fr)`,
|
||||
gridTemplateRows: `repeat(${props.rows}, 1fr)`,
|
||||
gap: props.gap,
|
||||
}"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.layout-grid {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
87
ui/framework/src/components/Panel.vue
Normal file
87
ui/framework/src/components/Panel.vue
Normal file
@@ -0,0 +1,87 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
title: string
|
||||
status?: 'idle' | 'live' | 'processing' | 'error'
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="panel">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">{{ title }}</span>
|
||||
<span class="panel-actions"><slot name="actions" /></span>
|
||||
<span class="panel-status" :class="status ?? 'idle'" />
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<slot />
|
||||
</div>
|
||||
<div class="panel-overlay">
|
||||
<slot name="overlay" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.panel {
|
||||
position: relative;
|
||||
background: var(--surface-1);
|
||||
border: var(--panel-border);
|
||||
border-radius: var(--panel-radius);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
height: var(--panel-header-height);
|
||||
padding: 0 var(--space-3);
|
||||
background: var(--surface-2);
|
||||
border-bottom: var(--panel-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.panel-actions {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.panel-status {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.panel-status.idle { background: var(--status-idle); }
|
||||
.panel-status.live { background: var(--status-live); }
|
||||
.panel-status.processing { background: var(--status-processing); }
|
||||
.panel-status.error { background: var(--status-error); }
|
||||
|
||||
.panel-body {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
padding: var(--space-2);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.panel-overlay {
|
||||
position: absolute;
|
||||
inset: var(--panel-header-height) 0 0 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.panel-overlay > :deep(*) {
|
||||
pointer-events: auto;
|
||||
}
|
||||
</style>
|
||||
145
ui/framework/src/components/ParameterEditor.vue
Normal file
145
ui/framework/src/components/ParameterEditor.vue
Normal file
@@ -0,0 +1,145 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
export interface ConfigField {
|
||||
name: string
|
||||
type: string
|
||||
default: unknown
|
||||
description: string
|
||||
min: number | null
|
||||
max: number | null
|
||||
options: string[] | null
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
fields: ConfigField[]
|
||||
values: Record<string, unknown>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update': [name: string, value: unknown]
|
||||
'reset': []
|
||||
}>()
|
||||
|
||||
const numericFields = computed(() => props.fields.filter(f => f.type === 'int' || f.type === 'float'))
|
||||
const boolFields = computed(() => props.fields.filter(f => f.type === 'bool'))
|
||||
|
||||
function onInput(name: string, value: unknown) {
|
||||
emit('update', name, value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="param-editor">
|
||||
<!-- Boolean fields -->
|
||||
<label v-for="f in boolFields" :key="f.name" class="param-field bool-field">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="!!values[f.name]"
|
||||
@change="(e) => onInput(f.name, (e.target as HTMLInputElement).checked)"
|
||||
/>
|
||||
<span class="field-label" :title="f.description">{{ f.name.replace(/_/g, ' ') }}</span>
|
||||
</label>
|
||||
|
||||
<!-- Numeric fields (range sliders) -->
|
||||
<div v-for="f in numericFields" :key="f.name" class="param-field">
|
||||
<div class="field-header">
|
||||
<span class="field-label" :title="f.description">{{ f.name.replace(/^edge_/, '').replace(/_/g, ' ') }}</span>
|
||||
<span class="field-value">{{ values[f.name] }}</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
:min="f.min ?? 0"
|
||||
:max="f.max ?? 500"
|
||||
:step="f.type === 'float' ? 0.01 : 1"
|
||||
:value="values[f.name] as number"
|
||||
@input="(e) => onInput(f.name, Number((e.target as HTMLInputElement).value))"
|
||||
/>
|
||||
<div class="field-range">
|
||||
<span>{{ f.min ?? 0 }}</span>
|
||||
<span>{{ f.max ?? 500 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.param-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.param-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.bool-field {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.field-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
color: var(--text-secondary);
|
||||
font-size: 10px;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.field-value {
|
||||
font-weight: 600;
|
||||
font-size: 10px;
|
||||
color: var(--text-primary);
|
||||
min-width: 30px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.field-range {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 9px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
input[type="range"] {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background: var(--surface-3);
|
||||
border-radius: 2px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
input[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input[type="range"]::-moz-range-thumb {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-primary);
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
}
|
||||
|
||||
input[type="checkbox"] {
|
||||
accent-color: #00bcd4;
|
||||
}
|
||||
</style>
|
||||
70
ui/framework/src/components/ResizeHandle.vue
Normal file
70
ui/framework/src/components/ResizeHandle.vue
Normal file
@@ -0,0 +1,70 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
direction: 'horizontal' | 'vertical'
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
resize: [delta: number]
|
||||
}>()
|
||||
|
||||
const dragging = ref(false)
|
||||
let startPos = 0
|
||||
|
||||
function onPointerDown(e: PointerEvent) {
|
||||
dragging.value = true
|
||||
startPos = props.direction === 'horizontal' ? e.clientX : e.clientY
|
||||
const el = e.target as HTMLElement
|
||||
el.setPointerCapture(e.pointerId)
|
||||
}
|
||||
|
||||
function onPointerMove(e: PointerEvent) {
|
||||
if (!dragging.value) return
|
||||
const currentPos = props.direction === 'horizontal' ? e.clientX : e.clientY
|
||||
const delta = currentPos - startPos
|
||||
startPos = currentPos
|
||||
emit('resize', delta)
|
||||
}
|
||||
|
||||
function onPointerUp() {
|
||||
dragging.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="resize-handle"
|
||||
:class="[direction, { dragging }]"
|
||||
@pointerdown="onPointerDown"
|
||||
@pointermove="onPointerMove"
|
||||
@pointerup="onPointerUp"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.resize-handle {
|
||||
flex-shrink: 0;
|
||||
background: transparent;
|
||||
transition: background 0.15s;
|
||||
touch-action: none;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.resize-handle:hover,
|
||||
.resize-handle.dragging {
|
||||
background: var(--text-dim);
|
||||
}
|
||||
|
||||
.resize-handle.horizontal {
|
||||
width: 4px;
|
||||
cursor: col-resize;
|
||||
margin: 0 -2px;
|
||||
}
|
||||
|
||||
.resize-handle.vertical {
|
||||
height: 4px;
|
||||
cursor: row-resize;
|
||||
margin: -2px 0;
|
||||
}
|
||||
</style>
|
||||
157
ui/framework/src/components/SplitPane.vue
Normal file
157
ui/framework/src/components/SplitPane.vue
Normal file
@@ -0,0 +1,157 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
/** Split direction */
|
||||
direction?: 'horizontal' | 'vertical'
|
||||
/** Initial size of the sized pane (px or flex ratio) */
|
||||
initialSize?: number
|
||||
/** Size mode: 'px' = sized pane fixed in pixels, 'ratio' = flex ratio */
|
||||
sizeMode?: 'px' | 'ratio'
|
||||
/** Which pane is sized: 'first' or 'second'. Default: 'first'. */
|
||||
anchor?: 'first' | 'second'
|
||||
/** Min size (px in px-mode, ratio in ratio-mode) */
|
||||
min?: number
|
||||
/** Max size (px in px-mode, ratio in ratio-mode) */
|
||||
max?: number
|
||||
/** Whether the divider is draggable */
|
||||
resizable?: boolean
|
||||
}>(), {
|
||||
direction: 'horizontal',
|
||||
initialSize: 1,
|
||||
sizeMode: 'ratio',
|
||||
anchor: 'first',
|
||||
min: 0.1,
|
||||
max: 10,
|
||||
resizable: true,
|
||||
})
|
||||
|
||||
const size = ref(props.initialSize)
|
||||
const dragging = ref(false)
|
||||
let startPos = 0
|
||||
|
||||
function onPointerDown(e: PointerEvent) {
|
||||
if (!props.resizable) return
|
||||
dragging.value = true
|
||||
startPos = props.direction === 'horizontal' ? e.clientX : e.clientY
|
||||
const el = e.target as HTMLElement
|
||||
el.setPointerCapture(e.pointerId)
|
||||
}
|
||||
|
||||
function onPointerMove(e: PointerEvent) {
|
||||
if (!dragging.value) return
|
||||
const currentPos = props.direction === 'horizontal' ? e.clientX : e.clientY
|
||||
let delta = currentPos - startPos
|
||||
startPos = currentPos
|
||||
|
||||
// Dragging right/down grows first pane, shrinks second.
|
||||
// If anchor is 'second', invert so dragging grows the second pane.
|
||||
if (props.anchor === 'second') delta = -delta
|
||||
|
||||
if (props.sizeMode === 'px') {
|
||||
size.value = Math.max(props.min, Math.min(props.max, size.value + delta))
|
||||
} else {
|
||||
const scale = props.direction === 'horizontal' ? 0.01 : 0.02
|
||||
size.value = Math.max(props.min, Math.min(props.max, size.value + delta * scale))
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerUp() {
|
||||
dragging.value = false
|
||||
}
|
||||
|
||||
const isHorizontal = computed(() => props.direction === 'horizontal')
|
||||
|
||||
const sizedStyle = computed(() => {
|
||||
if (props.sizeMode === 'px') {
|
||||
const sizeStr = size.value + 'px'
|
||||
const minStr = props.min + 'px'
|
||||
return isHorizontal.value
|
||||
? { width: sizeStr, minWidth: minStr, flexShrink: '0' }
|
||||
: { height: sizeStr, minHeight: minStr, flexShrink: '0' }
|
||||
}
|
||||
return { flex: String(size.value) }
|
||||
})
|
||||
|
||||
const flexStyle = computed(() => ({ flex: '1' }))
|
||||
|
||||
const firstStyle = computed(() => props.anchor === 'first' ? sizedStyle.value : flexStyle.value)
|
||||
const secondStyle = computed(() => props.anchor === 'second' ? sizedStyle.value : flexStyle.value)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="split-pane" :class="[direction]">
|
||||
<div class="split-first" :style="firstStyle">
|
||||
<slot name="first" />
|
||||
</div>
|
||||
<div
|
||||
v-if="resizable"
|
||||
class="split-divider"
|
||||
:class="[direction, { dragging }]"
|
||||
@pointerdown="onPointerDown"
|
||||
@pointermove="onPointerMove"
|
||||
@pointerup="onPointerUp"
|
||||
/>
|
||||
<div class="split-second" :style="secondStyle">
|
||||
<slot name="second" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.split-pane {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.split-pane.horizontal {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.split-pane.vertical {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.split-first,
|
||||
.split-second {
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Children must fill their pane */
|
||||
.split-first > :deep(*),
|
||||
.split-second > :deep(*) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.split-divider {
|
||||
flex-shrink: 0;
|
||||
background: transparent;
|
||||
transition: background 0.15s;
|
||||
touch-action: none;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.split-divider:hover,
|
||||
.split-divider.dragging {
|
||||
background: var(--text-dim);
|
||||
}
|
||||
|
||||
.split-divider.horizontal {
|
||||
width: 4px;
|
||||
cursor: col-resize;
|
||||
margin: 0 -2px;
|
||||
}
|
||||
|
||||
.split-divider.vertical {
|
||||
height: 4px;
|
||||
cursor: row-resize;
|
||||
margin: -2px 0;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user