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

+53
View File
@@ -0,0 +1,53 @@
<script setup>
import { ref, onMounted } from 'vue'
import LoginView from './components/LoginView.vue'
import Dashboard from './components/Dashboard.vue'
import { getMe, getConfig, logout as apiLogout } from './api.js'
import { enableSync, disableSync } from './prefs.js'
const ready = ref(false)
const email = ref(null)
const role = ref('user')
const organization = ref('')
const organizationName = ref('')
const defaultApiBase = ref('')
function applyMe(me) {
role.value = (me && me.role) || 'user'
organization.value = (me && me.organization) || ''
organizationName.value = (me && me.organizationName) || ''
}
onMounted(async () => {
defaultApiBase.value = (await getConfig()).apiBase || ''
const me = await getMe()
if (me) {
email.value = me.email
applyMe(me)
await enableSync() // pull this user's saved preferences from PocketBase
}
ready.value = true
})
async function onSignedIn(e) {
email.value = e
applyMe(await getMe())
await enableSync()
}
async function onLogout() {
disableSync()
await apiLogout()
email.value = null
role.value = 'user'
organization.value = ''
organizationName.value = ''
}
</script>
<template>
<div v-if="ready" class="h-full">
<Dashboard v-if="email" :email="email" :role="role" :organization="organization" :organization-name="organizationName" @logout="onLogout" />
<LoginView v-else :default-api-base="defaultApiBase" @signed-in="onSignedIn" />
</div>
<div v-else class="grid h-full place-items-center text-ink-muted text-sm">Loading…</div>
</template>
+263
View File
@@ -0,0 +1,263 @@
// All requests go to the Web App's BFF (same origin). The BFF forwards to the
// API Server, which is the gateway to PocketBase + the live Mobile App data.
export async function getConfig() {
try {
const r = await fetch('/bff/config')
return r.ok ? await r.json() : { apiBase: '' }
} catch {
return { apiBase: '' }
}
}
export async function getMe() {
try {
const r = await fetch('/bff/me')
return r.ok ? await r.json() : null
} catch {
return null
}
}
export async function login(email, password, apiBase) {
const r = await fetch('/bff/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password, apiBase }),
})
return { ok: r.ok, status: r.status, body: await r.json().catch(() => ({})) }
}
export async function logout() {
try {
await fetch('/bff/logout', { method: 'POST' })
} catch {
/* ignore */
}
}
export async function getDevices() {
try {
const r = await fetch('/bff/devices')
return r.ok ? await r.json() : []
} catch {
return []
}
}
export async function getUsers() {
try {
const r = await fetch('/bff/users')
if (!r.ok) return { ok: false, status: r.status, users: [] }
const d = await r.json()
return { ok: true, status: 200, users: d.users || [] }
} catch {
return { ok: false, status: 0, users: [] }
}
}
export async function createUser(email, password, role, organization) {
const r = await fetch('/bff/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password, role, organization }),
})
return { ok: r.ok, status: r.status, body: await r.json().catch(() => ({})) }
}
export async function updateUser(id, changes) {
const r = await fetch(`/bff/users/${encodeURIComponent(id)}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(changes),
})
return { ok: r.ok, status: r.status, body: await r.json().catch(() => ({})) }
}
export async function deleteUser(id) {
const r = await fetch(`/bff/users/${encodeURIComponent(id)}`, { method: 'DELETE' })
return { ok: r.ok, status: r.status, body: await r.json().catch(() => ({})) }
}
export async function getOrgs() {
try {
const r = await fetch('/bff/orgs')
if (!r.ok) return { ok: false, status: r.status, organizations: [] }
const d = await r.json()
return { ok: true, status: 200, organizations: d.organizations || [] }
} catch {
return { ok: false, status: 0, organizations: [] }
}
}
export async function createOrg(name) {
const r = await fetch('/bff/orgs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
})
return { ok: r.ok, status: r.status, body: await r.json().catch(() => ({})) }
}
export async function updateOrg(id, name) {
const r = await fetch(`/bff/orgs/${encodeURIComponent(id)}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
})
return { ok: r.ok, status: r.status, body: await r.json().catch(() => ({})) }
}
export async function deleteOrg(id) {
const r = await fetch(`/bff/orgs/${encodeURIComponent(id)}`, { method: 'DELETE' })
return { ok: r.ok, status: r.status, body: await r.json().catch(() => ({})) }
}
export async function getPreferences() {
try {
const r = await fetch('/bff/preferences')
if (!r.ok) return null
const d = await r.json()
return d && typeof d.preferences === 'object' ? d.preferences : null
} catch {
return null
}
}
export async function savePreferences(preferences) {
try {
const r = await fetch('/bff/preferences', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ preferences }),
})
return r.ok
} catch {
return false
}
}
/* ---------- Plugin integrations: OpenSky ---------- */
// Resolved OpenSky settings for the current user (cascade + masked secrets).
export async function getOpenSky() {
try {
const r = await fetch('/bff/integrations/opensky')
if (!r.ok) return { ok: false, status: r.status, body: await r.json().catch(() => ({})) }
return { ok: true, status: 200, body: await r.json() }
} catch {
return { ok: false, status: 0, body: {} }
}
}
// Save the caller's editable layer. payload: { enabled?, config? }.
export async function saveOpenSky(payload) {
const r = await fetch('/bff/integrations/opensky', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
return { ok: r.ok, status: r.status, body: await r.json().catch(() => ({})) }
}
// Run a live health probe against the caller's resolved config.
export async function testOpenSky() {
const r = await fetch('/bff/integrations/opensky/health', { method: 'POST' })
return { ok: r.ok, status: r.status, body: await r.json().catch(() => ({})) }
}
/* ---------- Plugin integrations: File transfer (FTP/SFTP) ---------- */
// Resolved file-transfer settings for the current user (cascade + masked secrets).
export async function getFileTransfer() {
try {
const r = await fetch('/bff/integrations/filetransfer')
if (!r.ok) return { ok: false, status: r.status, body: await r.json().catch(() => ({})) }
return { ok: true, status: 200, body: await r.json() }
} catch {
return { ok: false, status: 0, body: {} }
}
}
// Save the caller's editable layer. payload: { scope?, enabled?, config? }.
export async function saveFileTransfer(payload) {
const r = await fetch('/bff/integrations/filetransfer', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
return { ok: r.ok, status: r.status, body: await r.json().catch(() => ({})) }
}
// Run a live connect/list probe against the caller's resolved config.
export async function testFileTransfer() {
const r = await fetch('/bff/integrations/filetransfer/health', { method: 'POST' })
return { ok: r.ok, status: r.status, body: await r.json().catch(() => ({})) }
}
/* ---------- Plugin integrations: Local storage (host filesystem) ---------- */
// Resolved local-storage settings for the current user (isolated folder + cascade).
export async function getLocalStorage() {
try {
const r = await fetch('/bff/integrations/localstorage')
if (!r.ok) return { ok: false, status: r.status, body: await r.json().catch(() => ({})) }
return { ok: true, status: 200, body: await r.json() }
} catch {
return { ok: false, status: 0, body: {} }
}
}
// Save the caller's editable layer. payload: { scope?, enabled?, config? }.
export async function saveLocalStorage(payload) {
const r = await fetch('/bff/integrations/localstorage', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
return { ok: r.ok, status: r.status, body: await r.json().catch(() => ({})) }
}
// Run a live probe against the caller's isolated folder.
export async function testLocalStorage() {
const r = await fetch('/bff/integrations/localstorage/health', { method: 'POST' })
return { ok: r.ok, status: r.status, body: await r.json().catch(() => ({})) }
}
/* ---------- Plugin integrations: WebDAV ---------- */
// Resolved WebDAV settings for the current user (cascade + masked secrets).
export async function getWebDav() {
try {
const r = await fetch('/bff/integrations/webdav')
if (!r.ok) return { ok: false, status: r.status, body: await r.json().catch(() => ({})) }
return { ok: true, status: 200, body: await r.json() }
} catch {
return { ok: false, status: 0, body: {} }
}
}
// Save the caller's editable layer. payload: { scope?, enabled?, config? }.
export async function saveWebDav(payload) {
const r = await fetch('/bff/integrations/webdav', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
return { ok: r.ok, status: r.status, body: await r.json().catch(() => ({})) }
}
// Run a live connect/list probe against the caller's resolved config.
export async function testWebDav() {
const r = await fetch('/bff/integrations/webdav/health', { method: 'POST' })
return { ok: r.ok, status: r.status, body: await r.json().catch(() => ({})) }
}
export async function sendCommand(id, command, payload) {
const r = await fetch(`/bff/devices/${encodeURIComponent(id)}/command`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ command, payload }),
})
return { ok: r.ok, body: await r.json().catch(() => ({})) }
}
+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>
+6
View File
@@ -0,0 +1,6 @@
import { createApp } from 'vue'
import './style.css'
import './prefs.js' // register + apply persisted preferences (font size, motion)
import App from './App.vue'
createApp(App).mount('#app')
+163
View File
@@ -0,0 +1,163 @@
// PilotVault client-side preferences store.
//
// Everything here persists in localStorage and applies immediately in the
// browser — no backend required. Account/profile/security fields that will
// eventually live on the account service are kept here too so the Settings
// panel is a working prototype: wire them to real endpoints when they exist.
import { reactive, watch } from 'vue'
import { themeMode, setThemeMode } from './theme.js'
import { getPreferences, savePreferences } from './api.js'
const KEY = 'pv_prefs'
const defaults = {
// Account (prototype — mirror to the account service when available)
name: '',
username: '',
// Profile
displayName: '',
bio: '',
avatar: '', // data URL
showEmail: false,
// Appearance / accessibility (fully client-side, applied live)
fontSize: 'md', // sm | md | lg
language: 'en',
region: 'US',
dateFormat: 'MDY', // MDY | DMY | YMD | ISO
timeFormat: '24', // 12 | 24
reduceMotion: false,
// Security (prototype)
twoFactor: false,
}
function load() {
try {
return { ...defaults, ...(JSON.parse(localStorage.getItem(KEY) || '{}') || {}) }
} catch {
return { ...defaults }
}
}
export const prefs = reactive(load())
export function savePrefs() {
try {
localStorage.setItem(KEY, JSON.stringify(prefs))
} catch {
/* quota / private mode — ignore */
}
}
export function resetPrefs() {
Object.assign(prefs, defaults)
}
export function importPrefs(obj) {
if (!obj || typeof obj !== 'object') return false
// Only accept known keys — ignore anything unexpected in the imported file.
for (const k of Object.keys(defaults)) {
if (k in obj) prefs[k] = obj[k]
}
return true
}
// ---- Font size (accessibility): scales the whole rem-based UI ----
const FONT_PX = { sm: 15, md: 16, lg: 18 }
export function applyFontSize(size) {
document.documentElement.style.fontSize = (FONT_PX[size] || 16) + 'px'
}
// ---- Reduce motion ----
export function applyReduceMotion(on) {
document.documentElement.classList.toggle('reduce-motion', !!on)
}
// ---- Date / time formatting (respects the user's format prefs) ----
function parts(ms) {
const d = new Date(ms)
const yyyy = d.getFullYear()
const mm = String(d.getMonth() + 1).padStart(2, '0')
const dd = String(d.getDate()).padStart(2, '0')
let date
switch (prefs.dateFormat) {
case 'DMY': date = `${dd}/${mm}/${yyyy}`; break
case 'YMD': date = `${yyyy}/${mm}/${dd}`; break
case 'ISO': date = `${yyyy}-${mm}-${dd}`; break
default: date = `${mm}/${dd}/${yyyy}`
}
let time
if (prefs.timeFormat === '12') {
time = d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', second: '2-digit', hour12: true })
} else {
const hh = String(d.getHours()).padStart(2, '0')
time = `${hh}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}`
}
return { date, time }
}
export function formatTime(ms) {
return parts(ms).time
}
export function formatDateTime(ms) {
const p = parts(ms)
return `${p.date} ${p.time}`
}
// ---- PocketBase sync ----
// localStorage is the always-on local cache (also drives pre-paint). When the
// user is authenticated we additionally mirror everything to their PocketBase
// user record: pull on sign-in (server wins), debounced push on every change.
// Theme mode lives in its own store but rides along in the same blob.
let syncing = false
let applyingRemote = false
let pushTimer = null
function snapshot() {
return { ...JSON.parse(JSON.stringify(prefs)), themeMode: themeMode.value }
}
function schedulePush() {
if (!syncing || applyingRemote) return
clearTimeout(pushTimer)
pushTimer = setTimeout(() => {
savePreferences(snapshot())
}, 600)
}
function applyRemote(remote) {
applyingRemote = true
try {
importPrefs(remote) // copies known keys only
if (remote.themeMode) setThemeMode(remote.themeMode)
applyFontSize(prefs.fontSize)
applyReduceMotion(prefs.reduceMotion)
savePrefs()
} finally {
applyingRemote = false
}
}
// Enable syncing and reconcile with the server. If the server has a saved blob
// it wins; if it's empty, seed it with whatever is stored locally.
export async function enableSync() {
syncing = true
const remote = await getPreferences()
if (remote && Object.keys(remote).length) {
applyRemote(remote)
} else {
schedulePush()
}
}
export function disableSync() {
syncing = false
clearTimeout(pushTimer)
}
// Persist locally, keep live-applied prefs in sync, and mirror to the server.
watch(prefs, () => {
savePrefs()
schedulePush()
}, { deep: true })
watch(themeMode, schedulePush)
watch(() => prefs.fontSize, applyFontSize, { immediate: true })
watch(() => prefs.reduceMotion, applyReduceMotion, { immediate: true })
+102
View File
@@ -0,0 +1,102 @@
@import './design/tokens.css';
@tailwind base;
@tailwind components;
@tailwind utilities;
html,
body,
#app {
height: 100%;
}
/* Paint the app ground on <html> (and #app): unlike <body>, html's background
fills the viewport canvas and reliably repaints when the theme custom
property flips on this same element. */
html {
@apply bg-surface-0;
transition: background-color var(--dur-base) var(--ease-standard);
}
body {
@apply text-ink font-sans antialiased;
}
#app {
@apply bg-surface-0;
min-height: 100vh; /* cover the viewport so the (canvas-cached) body ground never shows through */
transition: background-color var(--dur-base) var(--ease-standard);
}
@layer components {
.tabular {
font-variant-numeric: tabular-nums;
}
/* Mono ALL-CAPS eyebrow — telemetry field names & section labels */
.eyebrow {
@apply font-mono text-[11px] uppercase tracking-caps text-ink-muted;
}
/* Large mono telemetry value */
.readout {
@apply font-mono tabular text-telemetry text-ink;
}
/* Card surface — solid, hairline border, cool shadow */
.panel {
@apply rounded-lg border border-line bg-surface-1 shadow-xs;
}
/* Inset status tile */
.pill {
@apply rounded border border-line bg-surface-2 px-3 py-2;
}
/* Text input */
.field {
@apply w-full rounded border border-line bg-surface-2 px-3 py-2.5 text-sm text-ink
placeholder:text-ink-muted outline-none transition;
transition-duration: var(--dur-fast);
}
.field:focus {
@apply border-accent;
box-shadow: 0 0 0 3px var(--focus-ring);
}
/* Primary action — Signal Blue */
.btn-accent {
@apply rounded bg-accent px-4 py-2.5 text-sm font-semibold text-white transition
hover:bg-accent-hover active:translate-y-px disabled:opacity-50;
transition-duration: var(--dur-fast);
}
/* Secondary / ghost action — outlined surface */
.btn-ghost {
@apply rounded border border-line bg-surface-1 px-3 py-1.5 text-sm text-ink-secondary transition
hover:border-line-strong hover:text-ink active:translate-y-px;
transition-duration: var(--dur-fast);
}
/* Small square icon button (theme toggle etc.) */
.btn-icon {
@apply grid h-9 w-9 place-items-center rounded border border-line bg-surface-1 text-ink-secondary
transition hover:text-ink hover:border-line-strong;
transition-duration: var(--dur-fast);
}
}
/* Accessibility: honour the "reduce motion" preference (toggle in Settings). */
html.reduce-motion *,
html.reduce-motion *::before,
html.reduce-motion *::after {
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
scroll-behavior: auto !important;
}
/* Leaflet — themed map chrome */
.leaflet-container {
background: var(--surface-inset);
font-family: var(--font-sans);
}
.leaflet-control-attribution {
background: color-mix(in srgb, var(--surface) 82%, transparent) !important;
color: var(--text-tertiary) !important;
}
.leaflet-control-attribution a {
color: var(--text-secondary) !important;
}
+73
View File
@@ -0,0 +1,73 @@
// Theme store — light is the PilotVault default. The user picks a *mode*
// (light | dark | system); `theme` is the resolved value (light | dark) that
// actually drives [data-theme] on <html>. The pre-paint script in index.html
// resolves the same way to avoid a flash before hydration.
import { ref } from 'vue'
const KEY = 'pv_theme'
// Concrete ground colors (--bg-app per theme), set inline on <html> so the
// viewport canvas repaints reliably when the theme flips (Chromium quirk).
const GROUND = { light: '#EEF0F3', dark: '#0B1730' }
const media = typeof window !== 'undefined' && window.matchMedia
? window.matchMedia('(prefers-color-scheme: dark)')
: null
function systemTheme() {
return media && media.matches ? 'dark' : 'light'
}
function initialMode() {
try {
return localStorage.getItem(KEY) || 'light'
} catch {
return 'light'
}
}
function resolve(mode) {
return mode === 'system' ? systemTheme() : mode
}
function paint(resolved) {
const html = document.documentElement
html.setAttribute('data-theme', resolved)
html.style.backgroundColor = GROUND[resolved] || GROUND.light
}
// The user's chosen mode, and the concrete resolved theme.
export const themeMode = ref(initialMode())
export const theme = ref(resolve(themeMode.value))
export function setThemeMode(mode) {
themeMode.value = mode
const resolved = resolve(mode)
theme.value = resolved
paint(resolved)
try {
localStorage.setItem(KEY, mode)
} catch {
/* ignore */
}
}
// Back-compat alias used by older call sites.
export const applyTheme = setThemeMode
// Simple light/dark flip for the header toggle. From "system" it commits to the
// opposite of whatever is currently showing.
export function toggleTheme() {
setThemeMode(theme.value === 'dark' ? 'light' : 'dark')
}
// Follow the OS while in "system" mode.
if (media) {
media.addEventListener('change', () => {
if (themeMode.value === 'system') {
const resolved = systemTheme()
theme.value = resolved
paint(resolved)
}
})
}