Files
quibot/quibot-web/app/app.vue
2026-06-18 13:45:32 +02:00

1410 lines
39 KiB
Vue

<template>
<main class="dashboard" :data-theme="isDark ? 'dark' : 'light'">
<header class="header">
<div class="logo-area">
<svg class="logo-icon" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="12" y="18" width="40" height="28" rx="6" stroke="var(--accent)" stroke-width="3"/>
<circle cx="24" cy="32" r="5" fill="var(--icon-red, #ef4444)"/>
<circle cx="40" cy="32" r="5" fill="var(--icon-red, #ef4444)"/>
<rect x="26" y="44" width="12" height="4" rx="2" fill="var(--icon-gray)"/>
<line x1="8" y1="52" x2="12" y2="52" stroke="var(--accent)" stroke-width="3" stroke-linecap="round"/>
<line x1="52" y1="52" x2="56" y2="52" stroke="var(--accent)" stroke-width="3" stroke-linecap="round"/>
</svg>
<div class="logo-text">
<h1>QUIBOT</h1>
<span class="subtitle">{{ $t('header.subtitle') }}</span>
</div>
</div>
<div class="status-bar">
<div v-if="connected" class="status-indicator online">
<span class="pulse"></span>
{{ $t('header.online') }}
</div>
<div v-else class="status-indicator offline">
<span class="pulse pulse-red"></span>
{{ $t('header.offline') }}
</div>
<span class="robot-mode">{{ robotModeLabel }}</span>
<button class="settings-btn" :aria-label="$t('settings.title')" @click="showSettings = !showSettings">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12.22 2h-.44a2 2 0 00-2 2v.18a2 2 0 01-1 1.73l-.43.25a2 2 0 01-2 0l-.15-.08a2 2 0 00-2.73.73l-.22.38a2 2 0 00.73 2.73l.15.1a2 2 0 011 1.72v.51a2 2 0 01-1 1.74l-.15.09a2 2 0 00-.73 2.73l.22.38a2 2 0 002.73.73l.15-.08a2 2 0 012 0l.43.25a2 2 0 011 1.73V20a2 2 0 002 2h.44a2 2 0 002-2v-.18a2 2 0 011-1.73l.43-.25a2 2 0 012 0l.15.08a2 2 0 002.73-.73l.22-.39a2 2 0 00-.73-2.73l-.15-.08a2 2 0 01-1-1.74v-.5a2 2 0 011-1.74l.15-.09a2 2 0 00.73 2.73l.22.38a2 2 0 00-2.73-.73l-.15.08a2 2 0 01-2 0l.43.25a2 2 0 01-1-1.73V4a2 2 0 00-2 2z"/><circle cx="12" cy="12" r="3"/></svg>
</button>
</div>
</header>
<!-- Settings Modal -->
<SettingsModal :show="showSettings" @update:show="(v) => showSettings = v" @toast="handleToast($event)" />
<div class="panels-grid">
<div class="row-top">
<!-- Block Queue Panel -->
<section class="panel block-queue-panel">
<div class="panel-header">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--icon-purple)" stroke-width="2"><rect x="3" y="3" width="7" height="18" rx="1"/><path d="M14 6h5a1 1 0 011 1v10a1 1 0 01-1 1h-5"/><path d="M12 3v18"/></svg>
<h2>{{ $t('panels.blockQueue') }}</h2>
</div>
<div v-if="blocks.length === 0" class="empty-state">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="var(--empty-icon)" stroke-width="1.5"><path d="M20 7H4a2 2 0 00-2 2v6a2 2 0 002 2h16a2 2 0 002-2V9a2 2 0 00-2-2z"/><path d="M16 7V5a2 2 0 00-2-2h-4a2 2 0 00-2 2v2"/></svg>
<p>{{ $t('blocks.empty') }}</p>
</div>
<div v-else class="block-list">
<div v-for="(block, index) in blocks" :key="index" class="block-item">
<div class="block-number">{{ index + 1 }}</div>
<div class="block-color-indicator" :style="{ background: block.colorHex }">
<span v-if="block.processing" class="processing-ring"></span>
</div>
<div class="block-info">
<span class="block-name">{{ block.name }}</span>
<span v-if="block.processing" class="status-badge processing">{{ $t('blocks.processing') }}</span>
<span v-else class="status-badge queued">{{ $t('blocks.queued') }}</span>
</div>
<div v-if="block.action" class="block-action">{{ block.actionText }}</div>
</div>
</div>
<button v-if="blocks.length > 0" class="btn-sm btn-danger" :disabled="busy" @click="clearBlocks">{{ $t('blocks.clear') }}</button>
</section>
<!-- Motion Controls Panel -->
<section class="panel motion-panel">
<div class="panel-header">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M12 2v4m0 12v4m10-10h-4M6 12H2"/></svg>
<h2>{{ $t('panels.motionControls') }}</h2>
</div>
<div class="d-pad">
<button class="btn-direction up" :disabled="busy" @click="doAction('forward')">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M12 19V5m-7 7l7-7 7 7"/></svg>
<span>{{ $t('motion.forward') }}</span>
</button>
<button class="btn-direction left" :disabled="busy" @click="doAction('left')">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M19 12H5m7 7l-7-7 7-7"/></svg>
<span>{{ $t('motion.left') }}</span>
</button>
<button class="btn-direction center" :disabled="busy" @click="doAction('stop')">
<svg width="28" height="28" viewBox="0 0 24 24" fill="currentColor"><rect x="4" y="4" width="16" height="16" rx="3"/></svg>
</button>
<button class="btn-direction right" :disabled="busy" @click="doAction('right')">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M5 12h14m-7-7l7 7-7 7"/></svg>
<span>{{ $t('motion.right') }}</span>
</button>
<button class="btn-direction down" :disabled="busy" @click="doAction('backward')">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M12 5v14m7-7l-7 7-7-7"/></svg>
<span>{{ $t('motion.backward') }}</span>
</button>
</div>
<p v-if="currentAction" class="action-status">{{ currentAction }}</p>
</section>
</div>
<!-- Eye Controls Panel -->
<section class="panel eye-panel">
<div class="panel-header">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--icon-red)" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
<h2>{{ $t('panels.eyeControls') }}</h2>
</div>
<!-- Eye shape grid -->
<div class="eye-shape-grid">
<button v-for="shape in eyeShapes" :key="shape.id"
class="btn-shape" :class="{ active: selectedShape === shape.id }"
@click="selectedShape = shape.id">
<div class="eye-preview">
<svg viewBox="0 0 80 40" class="eye-svg">
<template v-if="shape.id === 'open'">
<rect x="2" y="6" width="30" height="28" rx="14" :fill="eyeColorHex" stroke="#fff" stroke-width="1.5"/>
<rect x="48" y="6" width="30" height="28" rx="14" :fill="eyeColorHex" stroke="#fff" stroke-width="1.5"/>
<circle cx="17" cy="20" r="4" fill="#111827"/>
<circle cx="63" cy="20" r="4" fill="#111827"/>
</template>
<template v-if="shape.id === 'fw'">
<rect x="2" y="6" width="30" height="28" rx="14" :fill="eyeColorHex" stroke="#fff" stroke-width="1.5"/>
<rect x="48" y="6" width="30" height="28" rx="14" :fill="eyeColorHex" stroke="#fff" stroke-width="1.5"/>
<rect x="10" y="12" width="6" height="8" rx="2" fill="#111827"/>
<rect x="56" y="12" width="6" height="8" rx="2" fill="#111827"/>
</template>
<template v-if="shape.id === 'down'">
<rect x="2" y="6" width="30" height="28" rx="14" :fill="eyeColorHex" stroke="#fff" stroke-width="1.5"/>
<rect x="48" y="6" width="30" height="28" rx="14" :fill="eyeColorHex" stroke="#fff" stroke-width="1.5"/>
<rect x="12" y="20" width="6" height="5" rx="2" fill="#111827"/>
<rect x="58" y="20" width="6" height="5" rx="2" fill="#111827"/>
</template>
<template v-if="shape.id === 'gesture'">
<rect x="2" y="2" width="30" height="36" rx="14" :fill="eyeColorHex" :style="{ stroke: 'var(--icon-cyan)' }" stroke-width="2" opacity="0.8"/>
<rect x="48" y="2" width="30" height="36" rx="14" :fill="eyeColorHex" :style="{ stroke: 'var(--icon-cyan)' }" stroke-width="2" opacity="0.8"/>
</template>
</svg>
</div>
<span>{{ $t(`eyeShapes.${shape.labelKey}`) }}</span>
</button>
</div>
<!-- Apply shape button -->
<button class="btn-md" :disabled="busy || !selectedShape" @click="applyEyeShape">{{ $t('eyes.applyShape') }}</button>
<!-- Eye color selector -->
<h3 class="section-label">{{ $t('eyes.shapeLabel') }}</h3>
<div class="color-grid">
<button v-for="c in eyeColors" :key="c.id"
class="color-btn" :class="{ active: selectedColor === c.id }"
:style="{ background: c.hex }"
:title="c.name"
@click="selectedColor = c.id">
<span v-if="selectedColor === c.id" class="check-icon">&#10003;</span>
</button>
</div>
<button class="btn-md" :disabled="busy" @click="applyEyeColor">{{ $t('eyes.applyColor') }}</button>
<!-- Eye special actions -->
<h3 class="section-label">{{ $t('eyes.actionsLabel') }}</h3>
<div class="action-row">
<button class="btn-sm" :disabled="busy" @click="eyeOn">{{ $t('eyes.eyeOn') }}</button>
<button class="btn-sm btn-danger" :disabled="busy" @click="eyeOff">{{ $t('eyes.eyeOff') }}</button>
</div>
</section>
<!-- Gesture Sensor Panel -->
<section class="panel gesture-panel">
<div class="panel-header">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--status-green)" stroke-width="2"><path d="M18 11V6a2 2 0 00-4 0v1M14 10V4a2 2 0 00-4 0v2m4 2h.01M20 11v5a2 2 0 01-4 0v-3"/><path d="M6.5 11.5V17a5.5 5.5 0 0011 0v-4"/></svg>
<h2>{{ $t('panels.gestureSensor') }}</h2>
</div>
<!-- Mode toggle -->
<div class="mode-card">
<span class="section-label">{{ $t('gestures.modeLabel') }}</span>
<div class="mode-switch">
<button :class="['btn-mode', { active: gestureMode === 'block' }]" @click="setGestureMode('block')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="18" rx="1"/><path d="M14 6h5a1 1 0 011 1v10a1 1 0 01-1 1h-5"/></svg>
{{ $t('gestures.blockMode') }}
</button>
<button :class="['btn-mode', { active: gestureMode === 'gesture' }]" @click="setGestureMode('gesture')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 11V6a2 2 0 00-4 0v1M14 10V4a2 2 0 00-4 0v2m4 2h.01"/></svg>
{{ $t('gestures.gestureMode') }}
</button>
</div>
</div>
<!-- Gesture detection history -->
<h3 class="section-label">{{ $t('gestures.detectedLabel') }}</h3>
<div v-if="gestureLog.length === 0" class="empty-state">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="var(--empty-icon)" stroke-width="1.5"><path d="M18 11V6a2 2 0 00-4 0v1M14 10V4a2 2 0 00-4 0v2m4 2h.01M20 11v5a2 2 0 01-4 0v-3"/><path d="M6.5 11.5V17a5.5 5.5 0 0011 0v-4"/></svg>
<p>{{ $t('gestures.empty') }}</p>
</div>
<div v-else class="gesture-list">
<div v-for="(gesture, index) in gestureLog" :key="index" class="gesture-item" :class="'gesture-' + gesture.id.toLowerCase().replace('_', '')">
<div class="gesture-icon">{{ gesture.emoji }}</div>
<div class="gesture-info">
<span class="gesture-name">{{ gesture.name }}</span>
<span v-if="gesture.action" class="gesture-action-text">{{ gesture.actionText }}</span>
</div>
<span class="gesture-time">{{ gesture.time }}</span>
</div>
</div>
<!-- Gesture quick reference -->
<details class="gesture-ref">
<summary class="ref-toggle">{{ $t('gestures.reference') }}</summary>
<div class="ref-grid">
<div v-for="g in availableGestures" :key="g.id" class="ref-item">
<span class="ref-emoji">{{ g.emoji }}</span>
<span class="ref-name">{{ $t(`gestureRef.${g.nameKey}`) }}</span>
</div>
</div>
</details>
<button class="btn-sm" :disabled="busy" @click="clearGestureLog">{{ $t('gestures.clearLog') }}</button>
</section>
</div>
<!-- Toast message -->
<transition name="toast">
<div v-if="toastMsg" class="toast" :class="toastType">
{{ toastMsg }}
</div>
</transition>
</main>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
// ── Settings ──
const showSettings = ref(false)
// ── State ──
const busy = ref(false)
const currentAction = ref('')
const message = ref('')
const error = ref('')
const connected = ref(true)
const toastMsg = ref('')
const toastType = ref<'success' | 'error' | ''>('')
const isDark = ref(() => {
const saved = localStorage.getItem('quibot-theme')
if (saved) return saved === 'dark'
return window.matchMedia('(prefers-color-scheme: dark)').matches
})
let toastTimer: ReturnType<typeof setTimeout> | null = null
let themeMediaQuery: MediaQueryList | null = null
function toggleTheme() {
isDark.value = !isDark.value
localStorage.setItem('quibot-theme', isDark.value ? 'dark' : 'light')
applyTheme()
}
function applyTheme() {
document.documentElement.setAttribute('data-theme', isDark.value ? 'dark' : 'light')
}
// ── Block Queue ──
interface BlockInfo {
name: string
colorHex: string
processing: boolean
actionText?: string
}
const blocks = ref<BlockInfo[]>([])
const blockColorMap = [
{ id: 'RD', key: 'red', hex: '#ef4444' },
{ id: 'GN', key: 'green', hex: '#22c55e' },
{ id: 'BU', key: 'blue', hex: '#3b82f6' },
{ id: 'YE', key: 'yellow', hex: '#eab308' },
{ id: 'OG', key: 'orange', hex: '#f97316' },
{ id: 'VT', key: 'violet', hex: '#a855f7' },
]
interface BlockColorEntry {
name: string
hex: string
}
function getBlockColor(key: string): BlockColorEntry {
const c = blockColorMap.find(b => b.key === key)
return {
name: c ? t(`colors.${c.key}`) : key,
hex: c?.hex || '#888',
}
}
function updateBlocksFromResponse() {
try {
const data = JSON.parse(localStorage.getItem('quibot_blocks') || '{}') as Record<string, boolean>
blocks.value = []
for (const [colorId, present] of Object.entries(data)) {
const c = blockColorMap.find(b => b.id === colorId)
if (c && present) {
const colorInfo = getBlockColor(c.key)
blocks.value.push({
name: `${colorInfo.name} [${colorId}]`,
colorHex: c.hex,
processing: false,
actionText: t(`blockActions.${c.key.toLowerCase()}`),
})
}
}
} catch {
// ignore
}
// Demo: simulate 2 blocks if none available yet
if (blocks.value.length === 0) {
const rdInfo = getBlockColor('red')
const gnInfo = getBlockColor('green')
blocks.value = [
{ name: `${rdInfo.name} [RD]`, colorHex: '#ef4444', processing: false, actionText: t('blockActions.rd') },
{ name: `${gnInfo.name} [GN]`, colorHex: '#22c55e', processing: false, actionText: t('blockActions.gn') },
]
}
}
function clearBlocks() {
blocks.value = []
localStorage.removeItem('quibot_blocks')
showToast(t('toast.cleared'), 'success')
}
// ── Motion Controls ──
type Direction = 'forward' | 'backward' | 'left' | 'right' | 'stop'
async function doAction(dir: Direction) {
currentAction.value = t('motion.sending', { dir })
message.value = ''
error.value = ''
busy.value = true
try {
if (dir === 'stop') {
await $fetch('/api/motor/stop', { method: 'POST' })
} else {
const validDir = dir === 'left' ? 'forward' : dir === 'right' ? 'backward' : dir
await $fetch(`/api/motor/step/${validDir}`, { method: 'POST' })
}
message.value = t('motion.sent', { dir })
showToast(t('motionToast.success', { dir }), 'success')
} catch (err: unknown) {
const e = err as Record<string, unknown>
error.value = String(e?.message || 'Request failed')
showToast(t('motion.failed'), 'error')
} finally {
busy.value = false
currentAction.value = ''
}
}
// ── Eye Controls ──
interface ShapeInfo {
id: string
labelKey: string
}
const eyeShapes: ShapeInfo[] = [
{ id: 'open', labelKey: 'open' },
{ id: 'fw', labelKey: 'fw' },
{ id: 'down', labelKey: 'down' },
{ id: 'gesture', labelKey: 'gesture' },
]
interface ColorInfo {
id: string
nameKey: string
hex: string
}
const eyeColors: ColorInfo[] = [
{ id: 'white', nameKey: 'white', hex: '#f8fafc' },
{ id: 'red', nameKey: 'red', hex: '#ef4444' },
{ id: 'green', nameKey: 'green', hex: '#22c55e' },
{ id: 'blue', nameKey: 'blue', hex: '#3b82f6' },
{ id: 'yellow', nameKey: 'yellow', hex: '#eab308' },
{ id: 'orange', nameKey: 'orange', hex: '#f97316' },
{ id: 'purple', nameKey: 'purple', hex: '#a855f7' },
{ id: 'cyan', nameKey: 'cyan', hex: '#06b6d4' },
{ id: 'black', nameKey: 'black', hex: '#111827' },
]
function getEyeColorName(key: string) {
return t(`eyeColors.${key}`)
}
const selectedShape = ref('open')
const selectedColor = ref('white')
const eyeColorHex = computed(() => {
const c = eyeColors.find(e => e.id === selectedColor.value)
return c ? c.hex : '#f8fafc'
})
async function applyEyeShape() {
busy.value = true
currentAction.value = `Setting eye shape: ${selectedShape.value}`
try {
await $fetch('/api/eye/shape', { method: 'POST', body: { shape: selectedShape.value } })
showToast(t('eyes.toastSetShape', { shape: selectedShape.value }), 'success')
} catch {
showToast(t('eyes.toastFailedShape'), 'error')
} finally {
busy.value = false
currentAction.value = ''
}
}
async function applyEyeColor() {
busy.value = true
currentAction.value = `Setting eye color: ${selectedColor.value}`
try {
await $fetch('/api/eye/color', { method: 'POST', body: { color: selectedColor.value } })
showToast(t('eyes.toastSetColor', { color: selectedColor.value }), 'success')
} catch {
showToast(t('eyes.toastFailedColor'), 'error')
} finally {
busy.value = false
currentAction.value = ''
}
}
async function eyeOn() {
busy.value = true
try {
await $fetch('/api/eye/on', { method: 'POST' })
showToast(t('eyes.toastOn'), 'success')
} catch {
showToast(t('eyes.toastFailedOn'), 'error')
} finally {
busy.value = false
}
}
async function eyeOff() {
busy.value = true
try {
await $fetch('/api/eye/off', { method: 'POST' })
showToast(t('eyes.toastOff'), 'success')
} catch {
showToast(t('eyes.toastFailedOff'), 'error')
} finally {
busy.value = false
}
}
// ── Gesture Sensor ──
interface GestureEntry {
id: string
name: string
emoji: string
actionText: string
time: string
}
const availableGestures = [
{ id: 'GS_FORWARD', nameKey: 'forward', emoji: '\u27A1\uFE0F' },
{ id: 'GS_LEFT', nameKey: 'left', emoji: '\uD83D\uDC4B' },
{ id: 'GS_RIGHT', nameKey: 'right', emoji: '\uD83D\uDC4B' },
{ id: 'GS_UP', nameKey: 'up', emoji: '\u2191\uFE0F' },
{ id: 'GS_DOWN', nameKey: 'down', emoji: '\u2193\uFE0F' },
{ id: 'GS_CLOCKWISE', nameKey: 'clockwise', emoji: '\uD83D\uDD01' },
{ id: 'GS_ANTICLOCKWISE', nameKey: 'anticlockwise', emoji: '\uD83D\uDD02' },
{ id: 'GS_WAVE', nameKey: 'wave', emoji: '\uD83D\uDC4B\uD83C\uDFF8' },
]
const gestureLog = ref<GestureEntry[]>([])
interface GestureActionInfo {
nameKey: string
actionKey: string
emoji: string
}
const gestureActionMap: Record<string, GestureActionInfo> = {
GS_FORWARD: { nameKey: 'forward', actionKey: 'forward', emoji: '\u27A1\uFE0F' },
GS_RIGHT: { nameKey: 'right', actionKey: 'right', emoji: '\uD83D\uDC4B' },
GS_LEFT: { nameKey: 'left', actionKey: 'left', emoji: '\uD83D\uDC4B' },
GS_UP: { nameKey: 'up', actionKey: 'up', emoji: '\u2191\uFE0F' },
GS_DOWN: { nameKey: 'down', actionKey: 'down', emoji: '\u2193\uFE0F' },
GS_CLOCKWISE: { nameKey: 'clockwise', actionKey: 'clockwise', emoji: '\uD83D\uDD01' },
GS_ANTICLOCKWISE: { nameKey: 'anticlockwise', actionKey: 'anticlockwise', emoji: '\uD83D\uDD02' },
GS_WAVE: { nameKey: 'wave', actionKey: 'wave', emoji: '\uD83D\uDC4B\uD83C\uDFF8' },
}
const gestureMode = ref<'block' | 'gesture'>('block')
const robotModeLabel = computed(() => {
return t(`modes.${gestureMode.value === 'gesture' ? 'gesture' : 'block'}`)
})
async function setGestureMode(mode: 'block' | 'gesture') {
gestureMode.value = mode
connected.value = true
showToast(t('toast.switched', { mode: t(`modes.${mode}`) }), 'success')
try {
if (mode === 'gesture') {
await $fetch('/api/gesture/on', { method: 'POST' })
} else {
await $fetch('/api/gesture/off', { method: 'POST' })
}
} catch {
// ignore backend not ready
}
}
function addGestureLog(gestureId: string) {
const info = gestureActionMap[gestureId]
if (!info) return
const now = new Date()
const timeStr = `${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}`
gestureLog.value.unshift({
id: gestureId,
name: t(`gestureNames.${info.nameKey}`),
emoji: info.emoji,
actionText: t(`gestureActions.${info.actionKey}`),
time: timeStr,
})
// Keep at most 20
if (gestureLog.value.length > 20) {
gestureLog.value = gestureLog.value.slice(0, 20)
}
}
function clearGestureLog() {
gestureLog.value = []
}
// Simulate a gesture detection every few seconds for demo
let gestureInterval: ReturnType<typeof setInterval> | null = null
const fakeGestures = ['GS_FORWARD', 'GS_RIGHT', 'GS_LEFT', 'GS_UP', 'GS_DOWN']
function startGestureDemo() {
// Load demo gestures from localStorage if available
try {
const saved = JSON.parse(localStorage.getItem('quibot_gestures') || '[]') as string[]
for (const gId of saved) {
addGestureLog(gId)
}
} catch {
// ignore
}
gestureInterval = setInterval(() => {
const fake = fakeGestures[Math.floor(Math.random() * fakeGestures.length)]
if (gestureMode.value === 'gesture') {
addGestureLog(fake)
}
}, 4000)
}
// ── Toast ──
function handleToast(msgType: [string, 'success' | 'error']) {
showToast(msgType[0], msgType[1])
}
function showToast(msg: string, type: 'success' | 'error') {
toastMsg.value = msg
toastType.value = type
if (toastTimer) clearTimeout(toastTimer)
toastTimer = setTimeout(() => {
toastMsg.value = ''
toastType.value = ''
}, 3000)
}
// ── Lifecycle ──
onMounted(() => {
applyTheme()
updateBlocksFromResponse()
startGestureDemo()
themeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
themeMediaQuery.addEventListener('change', (e) => {
if (!localStorage.getItem('quibot-theme')) {
isDark.value = e.matches
applyTheme()
}
})
// Sync cookie for server routes
const savedPI = localStorage.getItem('quibot-pi-url') || ''
document.cookie = `quibot-pi-url=${encodeURIComponent(savedPI)};path=/`
const savedTheme = localStorage.getItem('quibot-theme') || ''
document.cookie = `quibot-theme=${encodeURIComponent(savedTheme)};path=/`
})
onUnmounted(() => {
if (themeMediaQuery) {
themeMediaQuery.removeEventListener('change', () => {})
}
if (gestureInterval) clearInterval(gestureInterval)
if (toastTimer) clearTimeout(toastTimer)
})
</script>
<style scoped>
/* ── Theme Variables ── */
:global(:root[data-theme="dark"]) {
--bg-primary: #0f172a;
--bg-secondary: #1e293b;
--bg-tertiary: #0f172a;
--bg-panel: #1e293b;
--border-color: #334155;
--border-subtle: #475569;
--text-primary: #f1f5f9;
--text-secondary: #e2e8f0;
--text-muted: #94a3b8;
--text-dim: #64748b;
--text-ghost: #475569;
--accent: #f97316;
--accent-glow: rgba(249, 115, 22, 0.3);
--accent-hover: #fb923c;
--gradient-start: #1e293b;
--gradient-end: #0f172a;
--card-bg: #1e293b;
--item-bg: #0f172a;
--btn-bg: #334155;
--btn-hover: #475569;
--status-online-bg: rgba(34, 197, 94, 0.1);
--status-online-border: rgba(34, 197, 94, 0.2);
--status-offline-bg: rgba(239, 68, 68, 0.1);
--status-offline-border: rgba(239, 68, 68, 0.2);
--badge-processing-bg: rgba(251, 191, 36, 0.1);
--badge-queued-bg: rgba(139, 92, 246, 0.1);
--header-shadow: 0 4px 24px rgba(0, 0, 0, 0.3);
--shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.3);
--active-bg: rgba(249, 115, 22, 0.1);
--active-box: 0 0 12px rgba(249, 115, 22, 0.2);
--status-online-color: #10b981;
--status-online-glow: rgba(16, 185, 129, 0.2);
--scrollbar-track: transparent;
--scrollbar-thumb: #334155;
--icon-purple: #8b5cf6;
--icon-red: #ef4444;
--icon-gray: #9ca3af;
--accent-solid: #f97316;
--status-green: #10b981;
--icon-cyan: #06b6d4;
--empty-icon: var(--text-ghost);
}
:global(:root[data-theme="light"]) {
--bg-primary: #f8fafc;
--bg-secondary: #ffffff;
--bg-tertiary: #f1f5f9;
--bg-panel: #ffffff;
--border-color: #cbd5e1;
--border-subtle: #94a3b8;
--text-primary: #0f172a;
--text-secondary: #1e293b;
--text-muted: #475569;
--text-dim: #64748b;
--text-ghost: #94a3b8;
--accent: #ea580c;
--accent-glow: rgba(234, 88, 12, 0.2);
--accent-hover: #f97316;
--gradient-start: #ffffff;
--gradient-end: #f1f5f9;
--card-bg: #ffffff;
--item-bg: #f8fafc;
--btn-bg: #e2e8f0;
--btn-hover: #cbd5e1;
--status-online-bg: rgba(34, 197, 94, 0.08);
--status-online-border: rgba(34, 197, 94, 0.25);
--status-offline-bg: rgba(239, 68, 68, 0.08);
--status-offline-border: rgba(239, 68, 68, 0.25);
--badge-processing-bg: rgba(234, 88, 12, 0.1);
--badge-queued-bg: rgba(139, 92, 246, 0.1);
--header-shadow: 0 4px 24px rgba(0, 0, 0, 0.08);
--shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.06);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08);
--active-bg: rgba(234, 88, 12, 0.08);
--active-box: 0 0 12px rgba(234, 88, 12, 0.15);
--status-online-color: #059669;
--status-online-glow: rgba(5, 150, 105, 0.15);
--scrollbar-track: transparent;
--scrollbar-thumb: #cbd5e1;
--icon-purple: #7c3aed;
--icon-red: #ef4444;
--icon-gray: #6b7280;
--accent-solid: #ea580c;
--status-green: #059669;
--icon-cyan: #0891b2;
--empty-icon: var(--text-ghost);
}
.dashboard {
min-height: 100vh;
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
}
/* ── Body / Global ── */
:global(body) {
margin: 0;
font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', 'Consolas', monospace;
background: var(--bg-primary);
color: var(--text-secondary);
}
/* ── Header ── */
.header {
display: flex;
justify-content: space-between;
align-items: center;
background: linear-gradient(135deg, var(--gradient-start), var(--gradient-end));
border: 1px solid var(--border-color);
border-radius: 1rem;
padding: 1rem 1.5rem;
box-shadow: var(--header-shadow);
gap: 1rem;
}
.settings-btn {
display: grid;
place-content: center;
width: 36px;
height: 36px;
padding: 0.5rem;
background: var(--btn-bg);
border: 2px solid var(--border-color);
border-radius: 0.5rem;
color: var(--text-muted);
flex-shrink: 0;
}
.settings-btn:not(:disabled):hover {
background: var(--btn-hover);
border-color: var(--accent);
color: var(--accent);
box-shadow: 0 0 8px var(--accent-glow);
}
.settings-btn svg {
width: 18px;
height: 18px;
stroke: var(--text-muted);
}
.settings-btn:hover svg {
stroke: var(--accent);
}
.logo-area {
display: flex;
align-items: center;
gap: 0.75rem;
}
.logo-icon {
width: 48px;
height: 48px;
filter: drop-shadow(0 0 8px var(--accent-glow));
}
.logo-text h1 {
margin: 0;
font-size: 1.75rem;
font-weight: 800;
letter-spacing: 0.1em;
background: linear-gradient(135deg, var(--accent), #ea580c);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.subtitle {
font-size: 0.7rem;
color: var(--text-dim);
letter-spacing: 0.15em;
text-transform: uppercase;
}
.status-bar {
display: flex;
align-items: center;
gap: 1rem;
}
.robot-mode {
font-size: 0.75rem;
color: var(--text-ghost);
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
padding: 0.375rem 0.75rem;
border-radius: 999px;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.status-indicator {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.08em;
padding: 0.375rem 0.875rem;
border-radius: 999px;
}
.status-indicator.online {
color: #22c55e;
background: var(--status-online-bg);
border: 1px solid var(--status-online-border);
}
.status-indicator.offline {
color: #ef4444;
background: var(--status-offline-bg);
border: 1px solid var(--status-offline-border);
}
.pulse {
width: 8px;
height: 8px;
background: var(--accent);
border-radius: 50%;
animation: pulse-anim 2s ease-in-out infinite;
}
.pulse-red {
background: #ef4444 !important;
}
@keyframes pulse-anim {
0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(249, 115, 22, 0.4); }
50% { opacity: 0.6; box-shadow: 0 0 0 4px rgba(249, 115, 22, 0); }
}
/* ── Panels Grid ── */
.panels-grid {
display: flex;
flex-direction: column;
gap: 1rem;
flex: 1;
}
.row-top {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.panel {
background: var(--bg-panel);
border: 1px solid var(--border-color);
border-radius: 1rem;
padding: 1.25rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.panel-header {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.1em;
}
/* ── Buttons ── */
button {
font-family: inherit;
border: none;
cursor: pointer;
transition: all 0.2s ease;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-md {
width: 100%;
padding: 0.625rem 1rem;
font-size: 0.8rem;
font-weight: 700;
letter-spacing: 0.05em;
color: #fff;
background: linear-gradient(135deg, var(--accent), #ea580c);
border-radius: 0.625rem;
text-transform: uppercase;
}
.btn-md:not(:disabled):hover {
box-shadow: 0 4px 12px var(--accent-glow);
transform: translateY(-1px);
}
.btn-sm {
padding: 0.375rem 0.875rem;
font-size: 0.75rem;
font-weight: 600;
color: var(--text-secondary);
background: var(--btn-bg);
border-radius: 0.5rem;
align-self: flex-start;
}
.btn-sm:not(:disabled):hover {
background: var(--btn-hover);
}
.btn-danger {
background: linear-gradient(135deg, #ef4444, #dc2626) !important;
color: #fff !important;
}
/* ── Block Queue ── */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
color: var(--text-ghost);
font-size: 0.85rem;
padding: 1.5rem 0;
}
.block-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.block-item {
display: flex;
align-items: center;
gap: 0.75rem;
background: var(--item-bg);
border: 1px solid var(--border-color);
border-radius: 0.625rem;
padding: 0.625rem 0.75rem;
}
.block-number {
width: 24px;
height: 24px;
display: grid;
place-content: center;
font-size: 0.7rem;
font-weight: 800;
color: var(--text-dim);
background: var(--bg-secondary);
border-radius: 50%;
flex-shrink: 0;
}
.block-color-indicator {
width: 32px;
height: 32px;
border-radius: 0.5rem;
flex-shrink: 0;
position: relative;
box-shadow: var(--shadow-sm);
}
.processing-ring {
position: absolute;
inset: -3px;
border: 2px solid #fbbf24;
border-radius: 0.625rem;
border-top-color: transparent;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.block-info {
display: flex;
flex-direction: column;
gap: 0.25rem;
flex: 1;
min-width: 0;
}
.block-name {
font-size: 0.8rem;
font-weight: 600;
color: var(--text-secondary);
}
.status-badge {
font-size: 0.625rem;
padding: 0.125rem 0.5rem;
border-radius: 999px;
align-self: flex-start;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.status-badge.processing {
color: #f59e0b;
background: var(--badge-processing-bg);
}
.status-badge.queued {
color: #8b5cf6;
background: var(--badge-queued-bg);
}
.block-action {
font-size: 0.65rem;
color: var(--text-dim);
text-align: right;
}
/* ── D-Pad Motion Controls ── */
.d-pad {
display: grid;
grid-template-areas:
". up ."
"left center right"
". down .";
gap: 0.5rem;
width: 240px;
height: 240px;
margin: 0 auto;
}
.btn-direction {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.25rem;
background: linear-gradient(135deg, var(--bg-secondary), var(--item-bg));
border: 2px solid var(--border-color);
border-radius: 0.75rem;
color: var(--text-muted);
padding: 0;
}
.btn-direction svg {
width: 28px;
height: 28px;
}
.btn-direction span {
font-size: 0.65rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.btn-direction:not(:disabled):hover {
border-color: var(--accent);
color: var(--accent);
box-shadow: 0 4px 12px var(--accent-glow);
transform: scale(1.05);
}
.btn-direction.up { grid-area: up; }
.btn-direction.left { grid-area: left; }
.btn-direction.center {
grid-area: center;
background: linear-gradient(135deg, #dc2626, #991b1b);
border-color: #dc2626;
color: #fff;
}
.btn-direction.center svg {
width: 32px;
height: 32px;
border-radius: 4px;
}
.btn-direction.center:not(:disabled):hover {
box-shadow: 0 4px 16px rgba(220, 38, 38, 0.5);
}
.btn-direction.down { grid-area: down; }
.btn-direction.right { grid-area: right; }
.action-status {
text-align: center;
font-size: 0.75rem;
color: var(--accent);
margin: 0;
}
/* ── Eye Controls ── */
.eye-shape-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0.5rem;
}
.btn-shape {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.375rem;
padding: 0.625rem 0.375rem;
background: var(--item-bg);
border: 2px solid var(--border-color);
border-radius: 0.625rem;
color: var(--text-muted);
cursor: pointer;
}
.btn-shape.active {
border-color: var(--accent);
background: var(--active-bg);
color: var(--accent);
box-shadow: var(--active-box);
}
.btn-shape span {
font-size: 0.625rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.eye-preview {
width: 80px;
height: 40px;
display: grid;
place-content: center;
}
.eye-svg {
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3));
}
.section-label {
font-size: 0.7rem !important;
font-weight: 600;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.15em;
margin: 0.25rem 0 0.5rem;
}
/* ── Color Grid ── */
.color-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 0.375rem;
margin-bottom: 0.75rem;
}
.color-btn {
width: 24px;
height: 24px;
border-radius: 0.375rem;
border: 2px solid var(--border-subtle);
display: grid;
place-content: center;
cursor: pointer;
position: relative;
}
.color-btn.active {
border-color: var(--accent);
box-shadow: var(--active-box), 0 4px 12px rgba(0, 0, 0, 0.2);
transform: scale(1.15);
}
.color-btn.active .check-icon {
font-size: 0.7rem;
font-weight: 800;
color: #fff;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
}
.action-row {
display: flex;
gap: 0.5rem;
}
/* ── Gesture Panel ── */
.mode-card {
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 0.625rem;
padding: 0.75rem;
}
.mode-switch {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
}
.btn-mode {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 0.375rem;
padding: 0.5rem 0.75rem;
font-size: 0.7rem;
font-weight: 700;
color: var(--text-dim);
background: var(--bg-secondary);
border: 2px solid var(--border-color);
border-radius: 0.625rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.btn-mode.active {
color: var(--status-online-color);
border-color: var(--status-online-color);
background: var(--status-online-bg);
box-shadow: 0 0 12px var(--status-online-glow);
}
.gesture-list {
display: flex;
flex-direction: column;
gap: 0.375rem;
max-height: 200px;
overflow-y: auto;
}
.gesture-item {
display: flex;
align-items: center;
gap: 0.625rem;
padding: 0.5rem 0.625rem;
background: var(--item-bg);
border: 1px solid var(--border-color);
border-radius: 0.5rem;
border-left: 3px solid var(--border-color);
}
.gesture-icon {
font-size: 1.1rem;
width: 28px;
text-align: center;
flex-shrink: 0;
}
.gesture-info {
display: flex;
flex-direction: column;
gap: 0.125rem;
flex: 1;
}
.gesture-name {
font-size: 0.75rem;
font-weight: 600;
color: var(--text-secondary);
}
.gesture-action-text {
font-size: 0.625rem;
color: var(--text-dim);
}
.gesture-time {
font-size: 0.625rem;
color: var(--text-ghost);
flex-shrink: 0;
}
/* ── Gesture Reference ── */
.gesture-ref {
border-top: 1px solid var(--border-color);
padding-top: 0.75rem;
}
.ref-toggle {
font-size: 0.7rem;
font-weight: 700;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.1em;
cursor: pointer;
user-select: none;
}
.ref-toggle::-webkit-details-marker {
display: none;
}
.ref-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.25rem;
margin-top: 0.5rem;
}
.ref-item {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.65rem;
color: var(--text-muted);
padding: 0.25rem;
}
.ref-emoji {
font-size: 0.9rem;
}
/* ── Toast ── */
.toast {
position: fixed;
bottom: 2rem;
left: 50%;
transform: translateX(-50%);
padding: 0.75rem 1.5rem;
font-size: 0.8rem;
font-weight: 700;
border-radius: 999px;
z-index: 100;
letter-spacing: 0.05em;
}
.toast.success {
color: #fff;
background: linear-gradient(135deg, #22c55e, #16a34a);
box-shadow: 0 4px 16px rgba(34, 197, 94, 0.3);
}
.toast.error {
color: #fff;
background: linear-gradient(135deg, #ef4444, #dc2626);
box-shadow: 0 4px 16px rgba(239, 68, 68, 0.3);
}
.toast-enter-active,
.toast-leave-active {
transition: all 0.3s ease;
}
.toast-enter-from,
.toast-leave-to {
opacity: 0;
transform: translateX(-50%) translateY(1rem);
}
/* ── Scrollbar ── */
.gesture-list::-webkit-scrollbar {
width: 4px;
}
.gesture-list::-webkit-scrollbar-track {
background: var(--scrollbar-track);
}
.gesture-list::-webkit-scrollbar-thumb {
background: var(--scrollbar-thumb);
border-radius: 999px;
}
/* ── Responsive ── */
@media (max-width: 768px) {
.panels-grid {
flex-direction: column;
}
.row-top {
grid-template-columns: 1fr;
}
.header {
flex-direction: column;
gap: 0.75rem;
}
.lang-selector {
order: 0;
}
.d-pad {
width: 200px;
height: 200px;
}
.eye-shape-grid {
grid-template-columns: repeat(2, 1fr);
}
}
</style>