1410 lines
39 KiB
Vue
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">✓</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>
|