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:
tajniak81andClaude Opus 4.8 committed 2026-07-13 11:43:33 +02:00
commit afc6952eda
172 files changed
+24591

No files matched your search

+14
View File
@@ -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>
+647
View File
@@ -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>
+49
View File
@@ -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>
+69
View File
@@ -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>
+89
View File
@@ -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>