Initial commit: PilotVault multi-service project
Add API Server (Go/PocketBase), Web App (Go BFF + Vue), Fly App (Flutter/DJI MSDK), Adobe Plugin, and Docker/Docker AIO deployment configs. Design assets and build artifacts are gitignored. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
commit
afc6952eda
172 files changed
+24591
No files matched your search
@@ -0,0 +1,14 @@
|
||||
<script setup>
|
||||
// PilotVault Vector mark — two offset chevrons. Back wing = Signal Blue,
|
||||
// front wing = currentColor (navy on light, off-white on dark).
|
||||
defineProps({ size: { type: [Number, String], default: 28 } })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg :width="size" :height="size" viewBox="0 0 48 48" fill="none" aria-hidden="true">
|
||||
<g stroke-width="4" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="8,30 19,17 30,30" stroke="var(--accent)" />
|
||||
<polyline points="18,33 29,20 40,33" stroke="currentColor" />
|
||||
</g>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,647 @@
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import DeviceMap from './DeviceMap.vue'
|
||||
import BrandMark from './BrandMark.vue'
|
||||
import Icon from './Icon.vue'
|
||||
import Settings from './Settings.vue'
|
||||
import { getDevices, sendCommand } from '../api.js'
|
||||
import { formatTime } from '../prefs.js'
|
||||
|
||||
const props = defineProps({
|
||||
email: { type: String, default: '' },
|
||||
role: { type: String, default: 'user' },
|
||||
organization: { type: String, default: '' },
|
||||
organizationName: { type: String, default: '' },
|
||||
})
|
||||
const emit = defineEmits(['logout'])
|
||||
|
||||
const devices = reactive({}) // deviceId -> DeviceState
|
||||
const trails = reactive({}) // deviceId -> [[lat,lng], ...]
|
||||
const selectedId = ref(null)
|
||||
const live = ref(false)
|
||||
const log = reactive([])
|
||||
const search = ref('')
|
||||
|
||||
const active = ref('Overview')
|
||||
const NAV = [
|
||||
['grid', 'Overview'],
|
||||
['radio', 'Live flights'],
|
||||
['route', 'Routes'],
|
||||
['calendar', 'Schedule'],
|
||||
['book', 'Logbook'],
|
||||
['fileText', 'Documents'],
|
||||
['server', 'Drives'],
|
||||
['settings', 'Settings'],
|
||||
]
|
||||
// Icon for the currently active section (drives the shared placeholder header).
|
||||
const activeIcon = computed(() => (NAV.find(([, l]) => l === active.value) || ['grid'])[0])
|
||||
|
||||
const cmdName = ref('')
|
||||
const cmdPayload = ref('')
|
||||
const cmdResult = ref('')
|
||||
|
||||
let ws = null
|
||||
let retry = null
|
||||
let stopped = false
|
||||
|
||||
const ids = computed(() =>
|
||||
Object.keys(devices).sort(
|
||||
(a, b) => (devices[b].online ? 1 : 0) - (devices[a].online ? 1 : 0) || a.localeCompare(b),
|
||||
),
|
||||
)
|
||||
const sel = computed(() => (selectedId.value ? devices[selectedId.value] : null))
|
||||
const tel = computed(() => (sel.value && sel.value.telemetry) || {})
|
||||
const online = computed(() => !!(sel.value && sel.value.online))
|
||||
const position = computed(() => {
|
||||
const t = tel.value
|
||||
return typeof t.latitude === 'number' && typeof t.longitude === 'number' && (t.latitude || t.longitude)
|
||||
? { lat: t.latitude, lng: t.longitude }
|
||||
: null
|
||||
})
|
||||
const trail = computed(() => (selectedId.value && trails[selectedId.value]) || [])
|
||||
|
||||
const hSpeed = computed(() => {
|
||||
const t = tel.value
|
||||
return typeof t.velocityX === 'number' && typeof t.velocityY === 'number'
|
||||
? Math.hypot(t.velocityX, t.velocityY)
|
||||
: null
|
||||
})
|
||||
|
||||
/* ---------- fleet-level derived state (drives Overview) ---------- */
|
||||
|
||||
// Per-device presentation status → [label, tone].
|
||||
function statusOf(d) {
|
||||
if (!d.online) return ['Offline', 'neutral']
|
||||
if (d.connected) return ['In flight', 'success']
|
||||
return ['Standby', 'accent']
|
||||
}
|
||||
function speedOf(d) {
|
||||
const t = (d && d.telemetry) || {}
|
||||
return typeof t.velocityX === 'number' && typeof t.velocityY === 'number'
|
||||
? Math.hypot(t.velocityX, t.velocityY)
|
||||
: null
|
||||
}
|
||||
|
||||
const fleet = computed(() =>
|
||||
ids.value.map((id) => {
|
||||
const d = devices[id]
|
||||
const t = d.telemetry || {}
|
||||
const [status, tone] = statusOf(d)
|
||||
return {
|
||||
id,
|
||||
mission: d.model || (d.connected ? 'Drone linked' : d.online ? 'App online' : 'No signal'),
|
||||
status,
|
||||
tone,
|
||||
alt: typeof t.altitude === 'number' ? t.altitude.toFixed(0) + ' m' : '—',
|
||||
battery: typeof t.batteryPercent === 'number' ? t.batteryPercent : null,
|
||||
speed: speedOf(d),
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
const onlineCount = computed(() => ids.value.filter((id) => devices[id].online).length)
|
||||
const flyingCount = computed(() =>
|
||||
ids.value.filter((id) => devices[id].online && devices[id].connected).length,
|
||||
)
|
||||
const offlineCount = computed(() => ids.value.filter((id) => !devices[id].online).length)
|
||||
const avgBattery = computed(() => {
|
||||
const vals = ids.value
|
||||
.map((id) => devices[id].telemetry?.batteryPercent)
|
||||
.filter((v) => typeof v === 'number')
|
||||
if (!vals.length) return null
|
||||
return Math.round(vals.reduce((a, b) => a + b, 0) / vals.length)
|
||||
})
|
||||
|
||||
const stats = computed(() => [
|
||||
{
|
||||
label: 'Active flights',
|
||||
value: String(flyingCount.value),
|
||||
delta: `${onlineCount.value} online`,
|
||||
tone: 'success',
|
||||
icon: 'radio',
|
||||
},
|
||||
{
|
||||
label: 'Avg battery',
|
||||
value: avgBattery.value == null ? '—' : avgBattery.value + '%',
|
||||
delta: avgBattery.value == null ? 'no telemetry' : avgBattery.value < 40 ? 'low — watch' : 'nominal',
|
||||
tone: avgBattery.value != null && avgBattery.value < 40 ? 'danger' : 'neutral',
|
||||
icon: 'battery',
|
||||
},
|
||||
{
|
||||
label: 'Fleet size',
|
||||
value: String(ids.value.length),
|
||||
delta: `${flyingCount.value} in flight`,
|
||||
tone: 'neutral',
|
||||
icon: 'grid',
|
||||
},
|
||||
{
|
||||
label: 'Offline',
|
||||
value: String(offlineCount.value),
|
||||
delta: offlineCount.value ? 'needs attention' : 'all reachable',
|
||||
tone: offlineCount.value ? 'warning' : 'success',
|
||||
icon: 'signal',
|
||||
},
|
||||
])
|
||||
|
||||
const badgeClass = {
|
||||
success: 'bg-success-soft text-success-fg',
|
||||
warning: 'bg-amber-soft text-amber-fg',
|
||||
danger: 'bg-danger-soft text-danger-fg',
|
||||
accent: 'bg-accent-soft text-accent-soft-fg',
|
||||
neutral: 'bg-surface-2 text-ink-secondary',
|
||||
}
|
||||
const deltaClass = {
|
||||
success: 'text-success-fg',
|
||||
danger: 'text-danger-fg',
|
||||
warning: 'text-amber-fg',
|
||||
neutral: 'text-ink-muted',
|
||||
accent: 'text-accent-soft-fg',
|
||||
}
|
||||
|
||||
const initials = computed(() => {
|
||||
const base = (props.email || 'PV').split('@')[0]
|
||||
const parts = base.split(/[.\-_ ]+/).filter(Boolean)
|
||||
return ((parts[0]?.[0] || 'P') + (parts[1]?.[0] || parts[0]?.[1] || 'V')).toUpperCase()
|
||||
})
|
||||
|
||||
/* current user's role + organization, shown in the sidebar identity card */
|
||||
const ROLE_LABEL = { superadmin: 'Superadmin', admin: 'Admin', user: 'Operator' }
|
||||
const roleLabel = computed(() => ROLE_LABEL[props.role] || 'Operator')
|
||||
const orgLabel = computed(
|
||||
() => props.organizationName || (props.role === 'superadmin' ? 'All organizations' : 'No organization'),
|
||||
)
|
||||
|
||||
/* ---------- realtime plumbing ---------- */
|
||||
|
||||
function upsert(d) {
|
||||
devices[d.deviceId] = d
|
||||
const t = d.telemetry || {}
|
||||
if (typeof t.latitude === 'number' && typeof t.longitude === 'number' && (t.latitude || t.longitude)) {
|
||||
if (!trails[d.deviceId]) trails[d.deviceId] = []
|
||||
trails[d.deviceId].push([t.latitude, t.longitude])
|
||||
if (trails[d.deviceId].length > 1000) trails[d.deviceId].shift()
|
||||
}
|
||||
if (!selectedId.value || (d.online && !devices[selectedId.value]?.online)) selectedId.value = d.deviceId
|
||||
}
|
||||
function remove(id) {
|
||||
delete devices[id]
|
||||
delete trails[id]
|
||||
if (selectedId.value === id) selectedId.value = ids.value[0] || null
|
||||
}
|
||||
function pushLog(ev) {
|
||||
log.unshift({ t: formatTime(Date.now()), tag: ev.type || '?', text: JSON.stringify(strip(ev)) })
|
||||
if (log.length > 200) log.pop()
|
||||
}
|
||||
function strip(ev) {
|
||||
const c = { ...ev }
|
||||
delete c.type
|
||||
return c
|
||||
}
|
||||
|
||||
function connect() {
|
||||
const proto = location.protocol === 'https:' ? 'wss' : 'ws'
|
||||
ws = new WebSocket(`${proto}://${location.host}/bff/ws`)
|
||||
ws.onopen = () => (live.value = true)
|
||||
ws.onclose = () => {
|
||||
live.value = false
|
||||
if (!stopped) retry = setTimeout(connect, 1500)
|
||||
}
|
||||
ws.onerror = () => ws && ws.close()
|
||||
ws.onmessage = (e) => {
|
||||
let m
|
||||
try {
|
||||
m = JSON.parse(e.data)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (m.type === 'snapshot') (m.devices || []).forEach(upsert)
|
||||
else if (m.type === 'update' && m.device) {
|
||||
upsert(m.device)
|
||||
if (m.event && m.device.deviceId === selectedId.value) pushLog(m.event)
|
||||
} else if (m.type === 'removed' && m.deviceId) remove(m.deviceId)
|
||||
}
|
||||
}
|
||||
|
||||
async function doCommand() {
|
||||
if (!selectedId.value) return (cmdResult.value = 'No device selected.')
|
||||
if (!cmdName.value.trim()) return (cmdResult.value = 'Enter a command name.')
|
||||
let payload
|
||||
if (cmdPayload.value.trim()) {
|
||||
try {
|
||||
payload = JSON.parse(cmdPayload.value)
|
||||
} catch {
|
||||
return (cmdResult.value = 'Payload is not valid JSON.')
|
||||
}
|
||||
}
|
||||
const { ok, body } = await sendCommand(selectedId.value, cmdName.value.trim(), payload)
|
||||
cmdResult.value = ok ? `Sent "${cmdName.value.trim()}".` : `Error: ${body.error || 'failed'}`
|
||||
}
|
||||
|
||||
function fmt(v, d, s = '') {
|
||||
return typeof v === 'number' ? v.toFixed(d) + s : '—'
|
||||
}
|
||||
|
||||
function track(id) {
|
||||
selectedId.value = id
|
||||
active.value = 'Live flights'
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
;(await getDevices()).forEach(upsert)
|
||||
connect()
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
stopped = true
|
||||
if (retry) clearTimeout(retry)
|
||||
if (ws) ws.close()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid h-full grid-cols-[248px_1fr] max-[900px]:grid-cols-1">
|
||||
<!-- ============ Sidebar ============ -->
|
||||
<aside class="flex flex-col border-r border-line bg-surface-1 px-4 py-5 max-[900px]:hidden">
|
||||
<div class="flex items-center gap-2.5 px-2 pb-5">
|
||||
<BrandMark :size="26" />
|
||||
<span class="text-[19px] tracking-tightest"
|
||||
><span class="font-medium text-ink-secondary">Pilot</span><span class="font-bold text-ink">Vault</span></span
|
||||
>
|
||||
</div>
|
||||
|
||||
<nav class="flex flex-col gap-0.5">
|
||||
<button
|
||||
v-for="[ic, label] in NAV"
|
||||
:key="label"
|
||||
class="flex items-center gap-3 rounded px-3 py-2.5 text-left text-sm transition"
|
||||
:class="active === label
|
||||
? 'bg-accent-soft font-semibold text-accent-soft-fg'
|
||||
: 'font-medium text-ink-secondary hover:bg-surface-2'"
|
||||
@click="active = label"
|
||||
>
|
||||
<Icon :name="ic" :size="18" :stroke="active === label ? 2.2 : 1.8" />
|
||||
{{ label }}
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div class="mt-auto flex flex-col gap-2.5">
|
||||
<div class="rounded-lg bg-surface-2 p-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="h-2 w-2 rounded-full" :class="live ? 'bg-ready' : 'bg-caution'"></span>
|
||||
<span class="text-xs font-semibold text-ink">{{ live ? 'Link healthy' : 'Reconnecting…' }}</span>
|
||||
</div>
|
||||
<span class="mt-1.5 block font-mono text-[10.5px] text-ink-muted"
|
||||
>API gateway · {{ live ? 'streaming' : 'retrying' }}</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex items-center gap-2.5 px-2 py-1">
|
||||
<div
|
||||
class="grid h-8 w-8 place-items-center rounded-full bg-[var(--navy-800)] text-xs font-bold text-white"
|
||||
>
|
||||
{{ initials }}
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-[13px] font-semibold text-ink">{{ email || 'Operator' }}</div>
|
||||
<div class="flex items-center gap-1.5 text-[11px] text-ink-muted">
|
||||
<Icon name="grid" :size="11" class="shrink-0" />
|
||||
<span class="truncate" :title="`${roleLabel} · ${orgLabel}`">{{ roleLabel }} · {{ orgLabel }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="text-ink-muted transition hover:text-ink"
|
||||
title="Log out"
|
||||
aria-label="Log out"
|
||||
@click="emit('logout')"
|
||||
>
|
||||
<Icon name="logout" :size="16" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- ============ Main ============ -->
|
||||
<main class="overflow-y-auto">
|
||||
<!-- topbar -->
|
||||
<header
|
||||
class="sticky top-0 z-10 flex items-center gap-4 border-b border-line px-7 py-3.5"
|
||||
style="background: color-mix(in srgb, var(--bg-app) 82%, transparent); backdrop-filter: blur(10px)"
|
||||
>
|
||||
<div>
|
||||
<div class="eyebrow">Live operations</div>
|
||||
<h1 class="mt-0.5 text-[22px] font-bold tracking-tightest text-ink">{{ active }}</h1>
|
||||
</div>
|
||||
<div class="ml-auto flex items-center gap-3">
|
||||
<div
|
||||
class="flex h-10 w-60 items-center gap-2 rounded border border-line-strong bg-surface-1 px-3 max-[1100px]:hidden"
|
||||
>
|
||||
<Icon name="search" :size="16" class="text-ink-muted" />
|
||||
<input
|
||||
v-model="search"
|
||||
placeholder="Search drones, routes…"
|
||||
class="w-full border-none bg-transparent text-sm text-ink outline-none placeholder:text-ink-muted"
|
||||
/>
|
||||
</div>
|
||||
<button class="btn-accent flex items-center gap-2" @click="active = 'Live flights'">
|
||||
<Icon name="radio" :size="16" /> Live flights
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- ---------- Overview ---------- -->
|
||||
<div v-if="active === 'Overview'" class="mx-auto flex max-w-[1240px] flex-col gap-5 p-7">
|
||||
<!-- stat row -->
|
||||
<div class="grid grid-cols-4 gap-4 max-[1100px]:grid-cols-2 max-[560px]:grid-cols-1">
|
||||
<div v-for="s in stats" :key="s.label" class="panel p-5">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="eyebrow">{{ s.label }}</span>
|
||||
<Icon :name="s.icon" :size="16" class="text-ink-muted" />
|
||||
</div>
|
||||
<div class="mt-2.5 text-[34px] font-bold leading-none tracking-tightest text-ink">{{ s.value }}</div>
|
||||
<span class="mt-2 block font-mono text-[11px]" :class="deltaClass[s.tone]">{{ s.delta }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- map + schedule -->
|
||||
<div class="grid grid-cols-[1.6fr_1fr] gap-5 max-[1100px]:grid-cols-1">
|
||||
<div class="panel p-5">
|
||||
<div class="mb-3.5 flex items-center justify-between">
|
||||
<div>
|
||||
<div class="eyebrow">Airspace</div>
|
||||
<div class="mt-0.5 text-base font-semibold text-ink">Live map</div>
|
||||
</div>
|
||||
<span
|
||||
v-if="flyingCount"
|
||||
class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-semibold"
|
||||
:class="badgeClass.success"
|
||||
>
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-current"></span>{{ flyingCount }} airborne
|
||||
</span>
|
||||
</div>
|
||||
<DeviceMap :position="position" :trail="trail" />
|
||||
</div>
|
||||
|
||||
<div class="panel p-5">
|
||||
<div class="mb-3.5 flex items-center justify-between">
|
||||
<div>
|
||||
<div class="eyebrow">Today</div>
|
||||
<div class="mt-0.5 text-base font-semibold text-ink">Schedule</div>
|
||||
</div>
|
||||
<Icon name="clock" :size="16" class="text-ink-muted" />
|
||||
</div>
|
||||
<div class="grid place-items-center py-10 text-center">
|
||||
<Icon name="calendar" :size="24" class="text-ink-muted" />
|
||||
<div class="mt-2 text-sm font-medium text-ink-secondary">No missions scheduled</div>
|
||||
<div class="mt-0.5 text-xs text-ink-muted">Scheduling is not wired to a backend yet.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- fleet table -->
|
||||
<div class="panel overflow-hidden p-0">
|
||||
<div class="flex items-center justify-between px-5 py-4">
|
||||
<div>
|
||||
<div class="eyebrow">Fleet</div>
|
||||
<div class="mt-0.5 text-base font-semibold text-ink">Aircraft status</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<span
|
||||
class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-semibold"
|
||||
:class="badgeClass.success"
|
||||
>
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-current"></span>{{ flyingCount }} in flight
|
||||
</span>
|
||||
<span
|
||||
v-if="offlineCount"
|
||||
class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-semibold"
|
||||
:class="badgeClass.warning"
|
||||
>
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-current"></span>{{ offlineCount }} offline
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!fleet.length" class="px-5 py-12 text-center text-sm text-ink-muted">
|
||||
No aircraft connected yet. Devices appear here as they come online.
|
||||
</div>
|
||||
<div v-else class="overflow-x-auto">
|
||||
<table class="w-full border-collapse text-sm">
|
||||
<thead>
|
||||
<tr class="text-left">
|
||||
<th
|
||||
v-for="h in ['Aircraft', 'Mission', 'Status', 'Alt', 'Battery', 'Speed', '']"
|
||||
:key="h"
|
||||
class="border-y border-line bg-surface-2 px-5 py-2.5 font-mono text-[10.5px] font-normal uppercase tracking-caps text-ink-muted"
|
||||
>
|
||||
{{ h }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="(row, i) in fleet"
|
||||
:key="row.id"
|
||||
class="cursor-pointer transition hover:bg-surface-2"
|
||||
:class="i < fleet.length - 1 ? 'border-b border-line' : ''"
|
||||
@click="track(row.id)"
|
||||
>
|
||||
<td class="px-5 py-3 font-mono font-bold text-ink">{{ row.id }}</td>
|
||||
<td class="px-5 py-3 text-ink-secondary">{{ row.mission }}</td>
|
||||
<td class="px-5 py-3">
|
||||
<span
|
||||
class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-semibold"
|
||||
:class="badgeClass[row.tone]"
|
||||
>
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-current"></span>{{ row.status }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-5 py-3 font-mono text-ink-secondary">{{ row.alt }}</td>
|
||||
<td class="px-5 py-3">
|
||||
<div v-if="row.battery != null" class="flex items-center gap-2">
|
||||
<div class="h-1.5 w-12 overflow-hidden rounded bg-surface-2">
|
||||
<div
|
||||
class="h-full"
|
||||
:class="row.battery < 40 ? 'bg-caution' : 'bg-ready'"
|
||||
:style="{ width: row.battery + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
<span class="font-mono text-xs text-ink-secondary">{{ row.battery }}%</span>
|
||||
</div>
|
||||
<span v-else class="font-mono text-xs text-ink-muted">—</span>
|
||||
</td>
|
||||
<td class="px-5 py-3 font-mono text-ink-secondary">
|
||||
{{ row.speed == null ? '—' : row.speed.toFixed(1) }}
|
||||
<span class="text-ink-muted">m/s</span>
|
||||
</td>
|
||||
<td class="px-5 py-3 text-right">
|
||||
<button
|
||||
class="btn-ghost inline-flex items-center gap-1.5 whitespace-nowrap"
|
||||
@click.stop="track(row.id)"
|
||||
>
|
||||
<Icon name="play" :size="14" /> Track
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ---------- Live flights (detailed HUD) ---------- -->
|
||||
<div v-else-if="active === 'Live flights'" class="p-7">
|
||||
<div class="mb-4 flex flex-wrap items-center gap-3">
|
||||
<span class="font-mono text-mode font-bold text-ink">{{ selectedId || 'No device selected' }}</span>
|
||||
<span
|
||||
v-if="sel && !online"
|
||||
class="rounded-full bg-danger-soft px-2.5 py-0.5 font-mono text-[10px] font-bold uppercase tracking-caps text-danger-fg"
|
||||
>Offline</span
|
||||
>
|
||||
<!-- device switcher -->
|
||||
<div v-if="ids.length" class="ml-auto flex flex-wrap gap-1.5">
|
||||
<button
|
||||
v-for="id in ids"
|
||||
:key="id"
|
||||
class="flex items-center gap-2 rounded border px-3 py-1.5 font-mono text-xs font-bold transition"
|
||||
:class="id === selectedId
|
||||
? 'border-accent bg-accent-soft text-accent-soft-fg'
|
||||
: 'border-line bg-surface-1 text-ink-secondary hover:border-line-strong'"
|
||||
@click="selectedId = id"
|
||||
>
|
||||
<span class="h-2 w-2 rounded-full" :class="devices[id].online ? 'bg-ready' : 'bg-ink-muted'"></span>
|
||||
{{ id }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!ids.length" class="panel grid place-items-center p-16 text-center">
|
||||
<Icon name="radio" :size="28" class="text-ink-muted" />
|
||||
<div class="mt-3 text-sm font-medium text-ink-secondary">No aircraft online</div>
|
||||
<div class="mt-1 text-xs text-ink-muted">Live telemetry appears here once a drone connects.</div>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div
|
||||
class="mb-4 grid gap-3"
|
||||
:class="!online && sel ? 'opacity-60' : ''"
|
||||
style="grid-template-columns: repeat(auto-fit, minmax(150px, 1fr))"
|
||||
>
|
||||
<div class="pill">
|
||||
<div class="eyebrow">Registration</div>
|
||||
<div
|
||||
class="mt-1 text-sm font-semibold"
|
||||
:class="online ? (sel?.registration === 'success' ? 'text-success-fg' : 'text-danger-fg') : 'text-ink'"
|
||||
>
|
||||
{{ online && sel?.registration ? sel.registration : '—' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="pill">
|
||||
<div class="eyebrow">Drone link</div>
|
||||
<div
|
||||
class="mt-1 text-sm font-semibold"
|
||||
:class="!online ? 'text-ink' : sel?.connected ? 'text-success-fg' : 'text-danger-fg'"
|
||||
>
|
||||
{{ !sel ? '—' : !online ? 'app offline' : sel.connected ? 'connected' : 'no drone' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="pill">
|
||||
<div class="eyebrow">Model</div>
|
||||
<div class="mt-1 text-sm font-semibold text-ink">{{ sel?.model || '—' }}</div>
|
||||
</div>
|
||||
<div class="pill">
|
||||
<div class="eyebrow">Last update</div>
|
||||
<div class="mt-1 font-mono text-sm font-bold tabular text-ink">
|
||||
{{ sel?.lastSeenMs ? formatTime(sel.lastSeenMs) : '—' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4 max-[820px]:grid-cols-1">
|
||||
<div class="panel p-4">
|
||||
<div class="mb-3 eyebrow">Battery</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="h-9 flex-1 overflow-hidden rounded border border-line bg-surface-2">
|
||||
<div
|
||||
class="h-full transition-all"
|
||||
:style="{ width: (typeof tel.batteryPercent === 'number' ? tel.batteryPercent : 0) + '%' }"
|
||||
:class="typeof tel.batteryPercent === 'number'
|
||||
? tel.batteryPercent < 20 ? 'bg-warning' : tel.batteryPercent < 40 ? 'bg-caution' : 'bg-ready'
|
||||
: ''"
|
||||
></div>
|
||||
</div>
|
||||
<div class="readout">
|
||||
{{ typeof tel.batteryPercent === 'number' ? tel.batteryPercent : '—'
|
||||
}}<span class="text-sm text-ink-secondary">%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel p-4">
|
||||
<div class="mb-3 eyebrow">Altitude</div>
|
||||
<div class="readout">{{ fmt(tel.altitude, 1) }}<span class="text-sm text-ink-secondary"> m</span></div>
|
||||
</div>
|
||||
|
||||
<div class="panel p-4">
|
||||
<div class="mb-3 eyebrow">Flight</div>
|
||||
<div class="space-y-1.5 text-sm">
|
||||
<div class="flex justify-between"><span class="text-ink-secondary">Mode</span><b class="text-ink">{{ tel.flightMode || '—' }}</b></div>
|
||||
<div class="flex justify-between"><span class="text-ink-secondary">Flying</span><b class="text-ink">{{ tel.isFlying == null ? '—' : tel.isFlying ? 'yes' : 'no' }}</b></div>
|
||||
<div class="flex justify-between"><span class="text-ink-secondary">GPS sats</span><b class="font-mono tabular text-ink">{{ tel.satelliteCount == null ? '—' : tel.satelliteCount }}</b></div>
|
||||
<div class="flex justify-between"><span class="text-ink-secondary">Speed (H)</span><b class="font-mono tabular text-ink">{{ hSpeed == null ? '—' : fmt(hSpeed, 2, ' m/s') }}</b></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel p-4">
|
||||
<div class="mb-3 eyebrow">Position</div>
|
||||
<div class="space-y-1.5 text-sm">
|
||||
<div class="flex justify-between"><span class="text-ink-secondary">Latitude</span><b class="font-mono tabular text-ink">{{ fmt(tel.latitude, 6) }}</b></div>
|
||||
<div class="flex justify-between"><span class="text-ink-secondary">Longitude</span><b class="font-mono tabular text-ink">{{ fmt(tel.longitude, 6) }}</b></div>
|
||||
<div class="flex justify-between"><span class="text-ink-secondary">Vert. speed</span><b class="font-mono tabular text-ink">{{ fmt(typeof tel.velocityZ === 'number' ? -tel.velocityZ : undefined, 2, ' m/s') }}</b></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel col-span-2 p-4 max-[820px]:col-span-1">
|
||||
<div class="mb-3 eyebrow">Track</div>
|
||||
<DeviceMap :position="position" :trail="trail" />
|
||||
</div>
|
||||
|
||||
<div class="panel p-4">
|
||||
<div class="mb-3 eyebrow">Send command</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<input v-model="cmdName" class="field flex-1" placeholder="command (e.g. startConnection)" />
|
||||
<input v-model="cmdPayload" class="field flex-1" placeholder="payload JSON (optional)" />
|
||||
<button class="btn-accent" @click="doCommand">Send</button>
|
||||
</div>
|
||||
<div class="mt-2 min-h-[16px] text-xs text-ink-muted">{{ cmdResult }}</div>
|
||||
</div>
|
||||
|
||||
<div class="panel p-4">
|
||||
<div class="mb-3 eyebrow">Event log</div>
|
||||
<div class="h-[180px] overflow-y-auto font-mono text-xs">
|
||||
<div v-for="(e, i) in log" :key="i" class="border-b border-line py-1">
|
||||
<span class="text-ink-muted">{{ e.t }}</span>
|
||||
<span class="font-semibold text-accent"> {{ e.tag }} </span>
|
||||
<span class="break-all text-ink">{{ e.text }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- ---------- Settings ---------- -->
|
||||
<Settings v-else-if="active === 'Settings'" :email="email" :role="role" :organization="organization" :organization-name="organizationName" @logout="emit('logout')" />
|
||||
|
||||
<!-- ---------- Placeholder views (Routes / Schedule / Drives) ---------- -->
|
||||
<div v-else class="p-7">
|
||||
<div class="panel grid place-items-center p-16 text-center">
|
||||
<Icon :name="activeIcon" :size="28" class="text-ink-muted" />
|
||||
<div class="mt-3 text-sm font-medium text-ink-secondary">{{ active }}</div>
|
||||
<div v-if="active === 'Drives'" class="mt-1 text-xs text-ink-muted">
|
||||
Browse and transfer files here once a drive is connected. Configure drives in
|
||||
<button class="font-semibold text-accent hover:underline" @click="active = 'Settings'">Settings → Integrations</button>.
|
||||
</div>
|
||||
<div v-else class="mt-1 text-xs text-ink-muted">This section is part of the console shell and has no backend yet.</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,49 @@
|
||||
<script setup>
|
||||
import { onMounted, watch, ref } from 'vue'
|
||||
import L from 'leaflet'
|
||||
|
||||
const props = defineProps({
|
||||
position: { type: Object, default: null }, // { lat, lng }
|
||||
trail: { type: Array, default: () => [] },
|
||||
})
|
||||
|
||||
const el = ref(null)
|
||||
let map, marker, line
|
||||
|
||||
function draw() {
|
||||
if (!map) return
|
||||
const p = props.position
|
||||
if (p && (p.lat || p.lng)) {
|
||||
const pos = [p.lat, p.lng]
|
||||
if (!marker) {
|
||||
marker = L.marker(pos).addTo(map)
|
||||
map.setView(pos, 17)
|
||||
} else {
|
||||
marker.setLatLng(pos)
|
||||
}
|
||||
}
|
||||
if (line) line.remove()
|
||||
if (props.trail.length) {
|
||||
const accent =
|
||||
getComputedStyle(document.documentElement).getPropertyValue('--accent').trim() || '#3D7BF0'
|
||||
line = L.polyline(props.trail, { color: accent, weight: 3 }).addTo(map)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
map = L.map(el.value, { zoomControl: true }).setView([20, 0], 2)
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© OpenStreetMap',
|
||||
maxZoom: 19,
|
||||
}).addTo(map)
|
||||
setTimeout(() => map.invalidateSize(), 60)
|
||||
draw()
|
||||
})
|
||||
|
||||
watch(() => props.position, draw, { deep: true })
|
||||
watch(() => props.trail, draw, { deep: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="el" class="h-[320px] w-full rounded-lg"></div>
|
||||
</template>
|
||||
@@ -0,0 +1,69 @@
|
||||
<script setup>
|
||||
// Lucide-derived icon set (24x24, 2px stroke) — mirrors the DS web UI kit.
|
||||
const P = {
|
||||
grid: 'M3 3h7v7H3zM14 3h7v7h-7zM14 14h7v7h-7zM3 14h7v7H3z',
|
||||
radio: 'M4.9 19.1a10 10 0 0 1 0-14.2M7.8 16.2a6 6 0 0 1 0-8.4M16.2 7.8a6 6 0 0 1 0 8.4M19.1 4.9a10 10 0 0 1 0 14.2M12 13a1 1 0 1 0 0-2 1 1 0 0 0 0 2z',
|
||||
route: 'M6 19a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM18 11a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM6 13V9a4 4 0 0 1 4-4h4',
|
||||
calendar: 'M8 2v4M16 2v4M3 10h18M5 4h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2z',
|
||||
book: 'M4 19.5A2.5 2.5 0 0 1 6.5 17H20M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z',
|
||||
fileText: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8zM14 2v6h6M16 13H8M16 17H8M10 9H8',
|
||||
settings: 'M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z',
|
||||
search: 'M11 19a8 8 0 1 0 0-16 8 8 0 0 0 0 16zM21 21l-4.3-4.3',
|
||||
plus: 'M12 5v14M5 12h14',
|
||||
battery: 'M3 8h14a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H3a1 1 0 0 1-1-1V9a1 1 0 0 1 1-1zM22 11v2',
|
||||
signal: 'M2 20h.01M7 20v-4M12 20v-8M17 20V8M22 20V4',
|
||||
clock: 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM12 7v5l3 2',
|
||||
chevronRight: 'M9 6l6 6-6 6',
|
||||
play: 'M6 3l14 9-14 9V3z',
|
||||
logout: 'M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9',
|
||||
wind: 'M12.8 19.6A2 2 0 1 0 14 16H2M17.5 8a2.5 2.5 0 1 1 2 4H2M9.6 4.6A2 2 0 1 1 11 8H2',
|
||||
drone: 'M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8zM6 6 4 4M18 6l2-2M6 18l-2 2M18 18l2 2',
|
||||
user: 'M20 21a8 8 0 1 0-16 0M12 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z',
|
||||
users: 'M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75',
|
||||
shield: 'M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z',
|
||||
sliders: 'M4 21v-7M4 10V3M12 21v-9M12 8V3M20 21v-5M20 12V3M1 14h6M9 8h6M17 16h6',
|
||||
alertTriangle: 'M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0zM12 9v4M12 17h.01',
|
||||
download: 'M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3',
|
||||
upload: 'M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M17 8l-5-5-5 5M12 3v12',
|
||||
trash: 'M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6M10 11v6M14 11v6',
|
||||
check: 'M20 6 9 17l-5-5',
|
||||
mail: 'M4 4h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2zM22 6l-10 7L2 6',
|
||||
lock: 'M5 11h14a2 2 0 0 1 2 2v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-6a2 2 0 0 1 2-2zM7 11V7a5 5 0 0 1 10 0v4',
|
||||
monitor: 'M3 4h18a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1zM8 21h8M12 17v4',
|
||||
globe: 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM3 12h18M12 3a15 15 0 0 1 0 18 15 15 0 0 1 0-18z',
|
||||
smartphone: 'M7 2h10a1 1 0 0 1 1 1v18a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1zM11 18h2',
|
||||
image: 'M4 4h16a1 1 0 0 1 1 1v14a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1zM8.5 11a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3zM21 15l-5-5L5 21',
|
||||
type: 'M4 7V4h16v3M9 20h6M12 4v16',
|
||||
sun: 'M12 17a5 5 0 1 0 0-10 5 5 0 0 0 0 10zM12 1v2M12 21v2M4.2 4.2l1.4 1.4M18.4 18.4l1.4 1.4M1 12h2M21 12h2M4.2 19.8l1.4-1.4M18.4 5.6l1.4-1.4',
|
||||
moon: 'M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z',
|
||||
x: 'M18 6 6 18M6 6l12 12',
|
||||
server: 'M20 4H4a2 2 0 0 0-2 2v3a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2zM20 13H4a2 2 0 0 0-2 2v3a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-3a2 2 0 0 0-2-2zM6 7.5h.01M6 16.5h.01',
|
||||
cloud: 'M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9z',
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
name: { type: String, required: true },
|
||||
size: { type: [Number, String], default: 18 },
|
||||
stroke: { type: [Number, String], default: 2 },
|
||||
})
|
||||
|
||||
// Split the compound "M... M..." definitions into discrete paths.
|
||||
const segments = (P[props.name] || '').split(' M').map((seg, i) => (i ? 'M' + seg : seg))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
:width="size"
|
||||
:height="size"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
:stroke-width="stroke"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
style="flex: none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path v-for="(d, i) in segments" :key="i" :d="d" />
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,89 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { login } from '../api.js'
|
||||
import BrandMark from './BrandMark.vue'
|
||||
import ThemeToggle from './ThemeToggle.vue'
|
||||
|
||||
const props = defineProps({ defaultApiBase: { type: String, default: '' } })
|
||||
const emit = defineEmits(['signed-in'])
|
||||
|
||||
const email = ref('')
|
||||
const password = ref('')
|
||||
const server = ref(localStorage.getItem('api_url') || props.defaultApiBase || 'http://localhost:8080')
|
||||
const showServer = ref(false)
|
||||
const showPassword = ref(false)
|
||||
const busy = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
async function submit() {
|
||||
busy.value = true
|
||||
error.value = ''
|
||||
localStorage.setItem('api_url', server.value.trim())
|
||||
const { ok, status, body } = await login(email.value.trim(), password.value, server.value.trim())
|
||||
busy.value = false
|
||||
if (ok) {
|
||||
emit('signed-in', body.email)
|
||||
return
|
||||
}
|
||||
error.value =
|
||||
status === 400 ? 'Invalid email or password.'
|
||||
: status === 502 ? "API server can't reach PocketBase."
|
||||
: (body.message || body.error || 'Cannot reach the API server.')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative grid h-full place-items-center p-5">
|
||||
<div class="absolute right-5 top-5">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
|
||||
<form class="panel w-[380px] p-8 shadow-md" @submit.prevent="submit">
|
||||
<div class="mb-6 flex items-center gap-3 text-ink">
|
||||
<BrandMark :size="34" />
|
||||
<div class="leading-tight">
|
||||
<div class="text-mode">PilotVault</div>
|
||||
<div class="eyebrow mt-0.5">Control panel</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="eyebrow mb-1.5 block">Email</label>
|
||||
<input v-model="email" type="email" autocomplete="username" required class="field mb-4" placeholder="you@example.com" />
|
||||
|
||||
<label class="eyebrow mb-1.5 block">Password</label>
|
||||
<div class="relative mb-1">
|
||||
<input v-model="password" :type="showPassword ? 'text' : 'password'" autocomplete="current-password" required class="field w-full pr-10" placeholder="••••••••" />
|
||||
<button
|
||||
type="button"
|
||||
class="absolute inset-y-0 right-0 grid w-10 place-items-center text-ink-muted transition hover:text-ink-secondary"
|
||||
:aria-label="showPassword ? 'Hide password' : 'Show password'"
|
||||
:title="showPassword ? 'Hide password' : 'Show password'"
|
||||
@click="showPassword = !showPassword"
|
||||
>
|
||||
<svg v-if="showPassword" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-[18px] w-[18px]">
|
||||
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24" />
|
||||
<line x1="1" y1="1" x2="23" y2="23" />
|
||||
</svg>
|
||||
<svg v-else viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-[18px] w-[18px]">
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="showServer" class="mt-4">
|
||||
<label class="eyebrow mb-1.5 block">API Server</label>
|
||||
<input v-model="server" type="text" class="field font-mono" placeholder="10.2.1.101:8080" />
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="mt-4 rounded border border-line bg-danger-soft px-3 py-2 text-sm text-danger-fg">{{ error }}</p>
|
||||
|
||||
<button type="submit" class="btn-accent mt-6 w-full" :disabled="busy">
|
||||
{{ busy ? 'Signing in…' : 'Sign in' }}
|
||||
</button>
|
||||
<button type="button" class="mx-auto mt-3 block text-xs text-ink-muted transition hover:text-ink-secondary" @click="showServer = !showServer">
|
||||
{{ showServer ? 'Hide server settings' : 'Server settings' }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,25 @@
|
||||
<script setup>
|
||||
import { theme, toggleTheme } from '../theme.js'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
class="btn-icon"
|
||||
type="button"
|
||||
:title="theme === 'dark' ? 'Switch to light' : 'Switch to dark'"
|
||||
:aria-label="theme === 'dark' ? 'Switch to light theme' : 'Switch to dark theme'"
|
||||
@click="toggleTheme"
|
||||
>
|
||||
<!-- Lucide sun (shown in dark → switch to light) -->
|
||||
<svg v-if="theme === 'dark'" width="18" height="18" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41" />
|
||||
</svg>
|
||||
<!-- Lucide moon (shown in light → switch to dark) -->
|
||||
<svg v-else width="18" height="18" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9z" />
|
||||
</svg>
|
||||
</button>
|
||||
</template>
|
||||
@@ -0,0 +1,35 @@
|
||||
<script setup>
|
||||
import { computed, inject } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
title: { type: String, default: '' },
|
||||
desc: { type: String, default: '' },
|
||||
keywords: { type: String, default: '' },
|
||||
// Stack the control below the label instead of to the right (for wide controls).
|
||||
block: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
// Injected search query — the row hides itself when it doesn't match.
|
||||
const query = inject('settingsSearch', { value: '' })
|
||||
const visible = computed(() => {
|
||||
const q = (query.value || '').trim().toLowerCase()
|
||||
if (!q) return true
|
||||
return `${props.title} ${props.desc} ${props.keywords}`.toLowerCase().includes(q)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="visible"
|
||||
class="border-b border-line py-4 last:border-0"
|
||||
:class="block ? '' : 'flex items-center justify-between gap-6'"
|
||||
>
|
||||
<div :class="block ? 'mb-3' : 'min-w-0'">
|
||||
<div class="text-sm font-semibold text-ink">{{ title }}</div>
|
||||
<div v-if="desc" class="mt-0.5 text-xs text-ink-muted">{{ desc }}</div>
|
||||
</div>
|
||||
<div :class="block ? '' : 'shrink-0'">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,28 @@
|
||||
<script setup>
|
||||
import Icon from '../Icon.vue'
|
||||
|
||||
defineProps({
|
||||
modelValue: { type: [String, Number], default: '' },
|
||||
// options: [{ value, label, icon? }]
|
||||
options: { type: Array, default: () => [] },
|
||||
})
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="inline-flex rounded-[10px] border border-line bg-surface-2 p-0.5">
|
||||
<button
|
||||
v-for="opt in options"
|
||||
:key="opt.value"
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-[13px] font-semibold transition"
|
||||
:class="modelValue === opt.value
|
||||
? 'bg-surface-1 text-ink shadow-xs'
|
||||
: 'text-ink-secondary hover:text-ink'"
|
||||
@click="emit('update:modelValue', opt.value)"
|
||||
>
|
||||
<Icon v-if="opt.icon" :name="opt.icon" :size="15" />
|
||||
{{ opt.label }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,24 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
modelValue: { type: Boolean, default: false },
|
||||
disabled: { type: Boolean, default: false },
|
||||
})
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
:aria-checked="modelValue"
|
||||
:disabled="disabled"
|
||||
class="relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition disabled:opacity-40"
|
||||
:class="modelValue ? 'bg-accent' : 'bg-surface-2 border border-line-strong'"
|
||||
@click="emit('update:modelValue', !modelValue)"
|
||||
>
|
||||
<span
|
||||
class="inline-block h-4 w-4 transform rounded-full bg-white shadow-sm transition"
|
||||
:class="modelValue ? 'translate-x-6' : 'translate-x-1'"
|
||||
></span>
|
||||
</button>
|
||||
</template>
|
||||
Reference in new issue
Block a user