Sync the Fly App's waypoint routes through the API Server
Routes saved in the Fly App's mission library now sync to a new per-user routes collection on the API Server, in the WaypointMissionSpec JSON format. The library pulls on load and pushes on every save and delete, merges last-writer-wins by updatedAt, keeps unacknowledged deletes as tombstones, and keeps working offline. Each signed-in account has its own copy on the device; routes saved while signed out go to the first account that signs in. The API Server gets list / get / upsert / delete route endpoints with a PocketBase migration, and the Web App a read-only Routes view on the dashboard (rebuilt dist). Tested against a fake PocketBase and a fake server only; not yet tried with a real sign-in on the tablet. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e49f318a0e
commit
93f4a580e9
18 files changed
+1525
-43
No files matched your search
@@ -0,0 +1,360 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Routes are the pilots' saved waypoint missions — the server side of the Fly
|
||||
// App's mission library (GO 4-parity plan P7). One `routes` record holds one
|
||||
// route verbatim, exactly as `WaypointMissionSpec.toJson()` writes it (format
|
||||
// version 1), keyed by the app's own route id so a tablet can sync without
|
||||
// knowing PocketBase record ids.
|
||||
//
|
||||
// Sync is last-writer-wins by the route's own `updatedAt`: a write older than
|
||||
// what is stored is refused with 409 and the stored route, so the device adopts
|
||||
// it. Deletes are kept as tombstones (deleted = true, with the delete time as
|
||||
// updatedAt) so an offline device learns of them instead of re-uploading.
|
||||
//
|
||||
// Scoping (as for flights):
|
||||
// - user → only the routes they own.
|
||||
// - admin → every route in their organization.
|
||||
// - superadmin → everything.
|
||||
//
|
||||
// `?mine=1` narrows any caller to their own routes (what a tablet syncs).
|
||||
|
||||
// routeFormatVersion is the WaypointMissionSpec.formatVersion this server stores.
|
||||
const routeFormatVersion = 1
|
||||
|
||||
// maxRouteBytes caps one route's JSON (99 waypoints with actions is ~40 KB).
|
||||
const maxRouteBytes = 2 << 20
|
||||
|
||||
// routeIDPattern bounds the app-generated ids ("wp-<base36>"). It also keeps
|
||||
// the id safe to splice into a PocketBase filter.
|
||||
var routeIDPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{1,100}$`)
|
||||
|
||||
type routeRecord struct {
|
||||
ID string `json:"id"`
|
||||
RouteID string `json:"route_id"`
|
||||
Name string `json:"name"`
|
||||
Spec json.RawMessage `json:"spec"`
|
||||
RouteUpdatedAt string `json:"route_updated_at"`
|
||||
Deleted bool `json:"deleted"`
|
||||
Owner string `json:"owner"`
|
||||
Organization string `json:"organization"`
|
||||
Created string `json:"created"`
|
||||
Updated string `json:"updated"`
|
||||
}
|
||||
|
||||
type routeView struct {
|
||||
ID string `json:"id"` // the app's route id
|
||||
RecordID string `json:"recordId"`
|
||||
Name string `json:"name"`
|
||||
Deleted bool `json:"deleted"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
PointCount int `json:"pointCount"`
|
||||
Owner string `json:"owner"`
|
||||
Mine bool `json:"mine"`
|
||||
Organization string `json:"organization"`
|
||||
Created string `json:"created"`
|
||||
Spec json.RawMessage `json:"spec,omitempty"`
|
||||
}
|
||||
|
||||
func (r routeRecord) view(who *callerIdentity) routeView {
|
||||
v := routeView{
|
||||
ID: r.RouteID, RecordID: r.ID, Name: r.Name, Deleted: r.Deleted,
|
||||
UpdatedAt: r.RouteUpdatedAt, Owner: r.Owner, Mine: who != nil && r.Owner == who.ID,
|
||||
Organization: r.Organization, Created: r.Created,
|
||||
}
|
||||
if len(r.Spec) > 0 && string(r.Spec) != "null" {
|
||||
v.Spec = r.Spec
|
||||
if s, err := parseRouteSpec(r.Spec); err == nil {
|
||||
v.PointCount = s.pointCount
|
||||
}
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// routeSpec is what the server needs to read out of a stored route; the rest
|
||||
// of the JSON is kept as-is.
|
||||
type routeSpec struct {
|
||||
id string
|
||||
name string
|
||||
updatedAt time.Time
|
||||
pointCount int
|
||||
}
|
||||
|
||||
// parseRouteSpec checks a route body is a format-1 WaypointMissionSpec with an
|
||||
// id and an updatedAt, and pulls out the fields the server indexes on.
|
||||
func parseRouteSpec(raw []byte) (routeSpec, error) {
|
||||
var in struct {
|
||||
Version *int `json:"version"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Points []json.RawMessage `json:"points"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &in); err != nil {
|
||||
return routeSpec{}, errRoute("route is not valid JSON")
|
||||
}
|
||||
if in.Version == nil || *in.Version != routeFormatVersion {
|
||||
return routeSpec{}, errRoute("unsupported route format version (want 1)")
|
||||
}
|
||||
if !routeIDPattern.MatchString(in.ID) {
|
||||
return routeSpec{}, errRoute("route id is missing or invalid")
|
||||
}
|
||||
t, ok := parseRouteTime(in.UpdatedAt)
|
||||
if !ok {
|
||||
return routeSpec{}, errRoute("route updatedAt is missing or not an RFC 3339 time")
|
||||
}
|
||||
return routeSpec{id: in.ID, name: strings.TrimSpace(in.Name), updatedAt: t, pointCount: len(in.Points)}, nil
|
||||
}
|
||||
|
||||
type errRoute string
|
||||
|
||||
func (e errRoute) Error() string { return string(e) }
|
||||
|
||||
// routeTimeLayout is how route_updated_at is stored: fixed-width UTC with
|
||||
// microseconds (Dart's DateTime precision), so it also sorts as text.
|
||||
const routeTimeLayout = "2006-01-02T15:04:05.000000Z"
|
||||
|
||||
func parseRouteTime(s string) (time.Time, bool) {
|
||||
t, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(s))
|
||||
return t, err == nil
|
||||
}
|
||||
|
||||
// routeIsNewer reports whether a stored route (or tombstone) stamped `stored`
|
||||
// supersedes a write stamped `incoming`. Equal stamps let the write through, so
|
||||
// a retried push is idempotent. An unreadable stored stamp never blocks.
|
||||
func routeIsNewer(stored string, incoming time.Time) bool {
|
||||
t, ok := parseRouteTime(stored)
|
||||
return ok && t.After(incoming)
|
||||
}
|
||||
|
||||
func routeScopeFilter(who *callerIdentity, mine bool) string {
|
||||
if mine || (!who.isSuperadmin() && !(who.isManager() && who.OrgID != "")) {
|
||||
return "owner = \"" + who.ID + "\""
|
||||
}
|
||||
if who.isSuperadmin() {
|
||||
return ""
|
||||
}
|
||||
return "organization = \"" + who.OrgID + "\""
|
||||
}
|
||||
|
||||
func canManageRoute(who *callerIdentity, r routeRecord) bool {
|
||||
if who.isSuperadmin() {
|
||||
return true
|
||||
}
|
||||
if who.isManager() && who.OrgID != "" && r.Organization == who.OrgID {
|
||||
return true
|
||||
}
|
||||
return r.Owner == who.ID
|
||||
}
|
||||
|
||||
// findRoute fetches a route by the app's route id (tombstones included).
|
||||
func (s *Server) findRoute(ctx context.Context, routeID string) (routeRecord, bool, error) {
|
||||
var list struct {
|
||||
Items []routeRecord `json:"items"`
|
||||
}
|
||||
status, err := s.listRecords(ctx, "routes", "route_id = \""+routeID+"\"", "", &list)
|
||||
if err != nil {
|
||||
return routeRecord{}, false, err
|
||||
}
|
||||
if status != http.StatusOK || len(list.Items) == 0 {
|
||||
return routeRecord{}, false, nil
|
||||
}
|
||||
return list.Items[0], true, nil
|
||||
}
|
||||
|
||||
// routePathID reads and validates the {id} path value.
|
||||
func routePathID(w http.ResponseWriter, r *http.Request) (string, bool) {
|
||||
id := r.PathValue("id")
|
||||
if !routeIDPattern.MatchString(id) {
|
||||
writeError(w, http.StatusBadRequest, "invalid route id")
|
||||
return "", false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
// GET /api/routes — the caller's in-scope routes, newest first. Tombstones are
|
||||
// left out unless ?includeDeleted=1 (a syncing device needs them).
|
||||
func (s *Server) handleListRoutes(w http.ResponseWriter, r *http.Request) {
|
||||
who := caller(r)
|
||||
q := r.URL.Query()
|
||||
filter := routeScopeFilter(who, q.Get("mine") == "1")
|
||||
if q.Get("includeDeleted") != "1" {
|
||||
if filter != "" {
|
||||
filter = "(" + filter + ") && "
|
||||
}
|
||||
filter += "deleted = false"
|
||||
}
|
||||
var list struct {
|
||||
Items []routeRecord `json:"items"`
|
||||
}
|
||||
status, err := s.listRecords(r.Context(), "routes", filter, "-route_updated_at", &list)
|
||||
if err != nil {
|
||||
gatewayError(w, err)
|
||||
return
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
writeError(w, http.StatusBadGateway, "could not list routes")
|
||||
return
|
||||
}
|
||||
out := make([]routeView, 0, len(list.Items))
|
||||
for _, rec := range list.Items {
|
||||
out = append(out, rec.view(who))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"routes": out})
|
||||
}
|
||||
|
||||
// GET /api/routes/{id} — one route (a tombstone reads as deleted: true).
|
||||
func (s *Server) handleGetRoute(w http.ResponseWriter, r *http.Request) {
|
||||
who := caller(r)
|
||||
id, ok := routePathID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
rec, found, err := s.findRoute(r.Context(), id)
|
||||
if err != nil {
|
||||
gatewayError(w, err)
|
||||
return
|
||||
}
|
||||
if !found || !canManageRoute(who, rec) {
|
||||
writeError(w, http.StatusNotFound, "route not found")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"route": rec.view(who)})
|
||||
}
|
||||
|
||||
// PUT /api/routes/{id} — create or update a route. The body is the route JSON
|
||||
// as WaypointMissionSpec.toJson() writes it; its id must match the path.
|
||||
func (s *Server) handlePutRoute(w http.ResponseWriter, r *http.Request) {
|
||||
who := caller(r)
|
||||
id, ok := routePathID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
raw, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxRouteBytes))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusRequestEntityTooLarge, "route is too large")
|
||||
return
|
||||
}
|
||||
spec, err := parseRouteSpec(raw)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusUnprocessableEntity, err.Error())
|
||||
return
|
||||
}
|
||||
if spec.id != id {
|
||||
writeError(w, http.StatusUnprocessableEntity, "route id does not match the URL")
|
||||
return
|
||||
}
|
||||
|
||||
existing, found, err := s.findRoute(r.Context(), id)
|
||||
if err != nil {
|
||||
gatewayError(w, err)
|
||||
return
|
||||
}
|
||||
payload := map[string]any{
|
||||
"route_id": id,
|
||||
"name": spec.name,
|
||||
"spec": json.RawMessage(raw),
|
||||
"route_updated_at": spec.updatedAt.UTC().Format(routeTimeLayout),
|
||||
"deleted": false,
|
||||
}
|
||||
|
||||
if !found {
|
||||
payload["owner"] = who.ID
|
||||
payload["organization"] = who.OrgID
|
||||
data, status, err := s.admin.do(r.Context(), http.MethodPost, "/api/collections/routes/records", payload)
|
||||
if err != nil {
|
||||
gatewayError(w, err)
|
||||
return
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
relayRaw(w, status, data)
|
||||
return
|
||||
}
|
||||
var rec routeRecord
|
||||
_ = json.Unmarshal(data, &rec)
|
||||
writeJSON(w, http.StatusCreated, map[string]any{"route": rec.view(who)})
|
||||
return
|
||||
}
|
||||
|
||||
if !canManageRoute(who, existing) {
|
||||
writeError(w, http.StatusConflict, "route id is already in use")
|
||||
return
|
||||
}
|
||||
if routeIsNewer(existing.RouteUpdatedAt, spec.updatedAt) {
|
||||
writeJSON(w, http.StatusConflict, map[string]any{"error": "a newer version of this route is stored", "route": existing.view(who)})
|
||||
return
|
||||
}
|
||||
// Owner + organization stay as created: an admin editing a pilot's route
|
||||
// does not take it over.
|
||||
data, status, err := s.admin.do(r.Context(), http.MethodPatch,
|
||||
"/api/collections/routes/records/"+url.PathEscape(existing.ID), payload)
|
||||
if err != nil {
|
||||
gatewayError(w, err)
|
||||
return
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
relayRaw(w, status, data)
|
||||
return
|
||||
}
|
||||
var rec routeRecord
|
||||
_ = json.Unmarshal(data, &rec)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"route": rec.view(who)})
|
||||
}
|
||||
|
||||
// DELETE /api/routes/{id}?at=<RFC 3339> — tombstone a route. `at` is when the
|
||||
// pilot deleted it (default: now); a route edited after that is kept (409).
|
||||
func (s *Server) handleDeleteRoute(w http.ResponseWriter, r *http.Request) {
|
||||
who := caller(r)
|
||||
id, ok := routePathID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
at := time.Now().UTC()
|
||||
if v := r.URL.Query().Get("at"); v != "" {
|
||||
t, ok := parseRouteTime(v)
|
||||
if !ok {
|
||||
writeError(w, http.StatusBadRequest, "at must be an RFC 3339 time")
|
||||
return
|
||||
}
|
||||
at = t
|
||||
}
|
||||
existing, found, err := s.findRoute(r.Context(), id)
|
||||
if err != nil {
|
||||
gatewayError(w, err)
|
||||
return
|
||||
}
|
||||
if !found || !canManageRoute(who, existing) {
|
||||
writeError(w, http.StatusNotFound, "route not found")
|
||||
return
|
||||
}
|
||||
if routeIsNewer(existing.RouteUpdatedAt, at) {
|
||||
writeJSON(w, http.StatusConflict, map[string]any{"error": "the route was changed after this delete", "route": existing.view(who)})
|
||||
return
|
||||
}
|
||||
data, status, err := s.admin.do(r.Context(), http.MethodPatch,
|
||||
"/api/collections/routes/records/"+url.PathEscape(existing.ID), map[string]any{
|
||||
"deleted": true,
|
||||
"route_updated_at": at.UTC().Format(routeTimeLayout),
|
||||
})
|
||||
if err != nil {
|
||||
gatewayError(w, err)
|
||||
return
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
relayRaw(w, status, data)
|
||||
return
|
||||
}
|
||||
var rec routeRecord
|
||||
_ = json.Unmarshal(data, &rec)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "route": rec.view(who)})
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A route as the Fly App's WaypointMissionSpec.toJson() writes it.
|
||||
const sampleRoute = `{"version":1,"id":"wp-m1x2y3","name":" Field ","points":[{"lat":55.1,"lon":12.2},{"lat":55.2,"lon":12.3}],"pois":[],` +
|
||||
`"cruiseSpeed":8.0,"headingMode":"auto","finishAction":"goHome","pathMode":"straight","exitOnSignalLost":true,` +
|
||||
`"gotoFirst":"safely","repeatTimes":1,"updatedAt":"2026-10-20T08:30:00.123Z"}`
|
||||
|
||||
func TestParseRouteSpec(t *testing.T) {
|
||||
s, err := parseRouteSpec([]byte(sampleRoute))
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if s.id != "wp-m1x2y3" || s.name != "Field" || s.pointCount != 2 {
|
||||
t.Fatalf("got %+v", s)
|
||||
}
|
||||
want := time.Date(2026, 10, 20, 8, 30, 0, 123e6, time.UTC)
|
||||
if !s.updatedAt.Equal(want) {
|
||||
t.Fatalf("updatedAt = %v, want %v", s.updatedAt, want)
|
||||
}
|
||||
|
||||
bad := map[string]string{
|
||||
"not json": `{`,
|
||||
"no version": `{"id":"wp-1","updatedAt":"2026-10-20T08:30:00Z"}`,
|
||||
"version 2": `{"version":2,"id":"wp-1","updatedAt":"2026-10-20T08:30:00Z"}`,
|
||||
"no id": `{"version":1,"updatedAt":"2026-10-20T08:30:00Z"}`,
|
||||
"filter quote": `{"version":1,"id":"a\" || 1=1","updatedAt":"2026-10-20T08:30:00Z"}`,
|
||||
"no updatedAt": `{"version":1,"id":"wp-1"}`,
|
||||
"bad updatedAt": `{"version":1,"id":"wp-1","updatedAt":"yesterday"}`,
|
||||
}
|
||||
for name, body := range bad {
|
||||
if _, err := parseRouteSpec([]byte(body)); err == nil {
|
||||
t.Errorf("%s: accepted", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouteIsNewer(t *testing.T) {
|
||||
at := time.Date(2026, 10, 20, 8, 30, 0, 0, time.UTC)
|
||||
if !routeIsNewer("2026-10-20T08:30:00.000001Z", at) {
|
||||
t.Error("a later stored stamp must win")
|
||||
}
|
||||
if routeIsNewer("2026-10-20T08:30:00.000000Z", at) {
|
||||
t.Error("an equal stamp lets the write through (idempotent retry)")
|
||||
}
|
||||
if routeIsNewer("2026-10-20T08:29:59Z", at) {
|
||||
t.Error("an older stored stamp loses")
|
||||
}
|
||||
if routeIsNewer("", at) {
|
||||
t.Error("a missing stored stamp never blocks")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouteTimeLayoutSortsAsText(t *testing.T) {
|
||||
a := time.Date(2026, 10, 20, 8, 30, 0, 0, time.UTC).Format(routeTimeLayout)
|
||||
b := time.Date(2026, 10, 20, 8, 30, 0, 500e6, time.UTC).Format(routeTimeLayout)
|
||||
if !(a < b) {
|
||||
t.Fatalf("%q should sort before %q", a, b)
|
||||
}
|
||||
if _, ok := parseRouteTime(a); !ok {
|
||||
t.Fatalf("stored stamp %q does not parse back", a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouteScope(t *testing.T) {
|
||||
user := &callerIdentity{ID: "u1", Role: roleUser, OrgID: "o1"}
|
||||
admin := &callerIdentity{ID: "a1", Role: roleAdmin, OrgID: "o1"}
|
||||
orgless := &callerIdentity{ID: "a2", Role: roleAdmin}
|
||||
super := &callerIdentity{ID: "s1", Role: roleSuperadmin}
|
||||
|
||||
cases := []struct {
|
||||
who *callerIdentity
|
||||
mine bool
|
||||
want string
|
||||
}{
|
||||
{user, false, `owner = "u1"`},
|
||||
{admin, false, `organization = "o1"`},
|
||||
{admin, true, `owner = "a1"`},
|
||||
{orgless, false, `owner = "a2"`},
|
||||
{super, false, ``},
|
||||
{super, true, `owner = "s1"`},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := routeScopeFilter(c.who, c.mine); got != c.want {
|
||||
t.Errorf("%s mine=%v: %q, want %q", c.who.ID, c.mine, got, c.want)
|
||||
}
|
||||
}
|
||||
|
||||
pilots := routeRecord{Owner: "u1", Organization: "o1"}
|
||||
other := routeRecord{Owner: "u9", Organization: "o2"}
|
||||
if !canManageRoute(user, pilots) || canManageRoute(user, routeRecord{Owner: "u2", Organization: "o1"}) {
|
||||
t.Error("a user manages only their own routes")
|
||||
}
|
||||
if !canManageRoute(admin, pilots) || canManageRoute(admin, other) {
|
||||
t.Error("an admin manages their organization's routes only")
|
||||
}
|
||||
if !canManageRoute(super, other) {
|
||||
t.Error("a superadmin manages everything")
|
||||
}
|
||||
}
|
||||
|
||||
// fakeRoutesPB is just enough PocketBase for the route handlers: superuser
|
||||
// auth, a route_id lookup, create and patch.
|
||||
type fakeRoutesPB struct {
|
||||
mu sync.Mutex
|
||||
recs map[string]map[string]any // record id → fields
|
||||
n int
|
||||
}
|
||||
|
||||
func (f *fakeRoutesPB) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
const coll = "/api/collections/routes/records"
|
||||
switch {
|
||||
case strings.HasSuffix(r.URL.Path, "/auth-with-password"):
|
||||
writeJSON(w, 200, map[string]any{"token": "su"})
|
||||
case r.Method == http.MethodGet && r.URL.Path == coll:
|
||||
items := []map[string]any{}
|
||||
filter := r.URL.Query().Get("filter")
|
||||
for _, rec := range f.recs {
|
||||
if strings.HasPrefix(filter, "route_id = ") && filter != `route_id = "`+rec["route_id"].(string)+`"` {
|
||||
continue
|
||||
}
|
||||
items = append(items, rec)
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"items": items})
|
||||
case r.Method == http.MethodPost && r.URL.Path == coll:
|
||||
var rec map[string]any
|
||||
_ = json.NewDecoder(r.Body).Decode(&rec)
|
||||
f.n++
|
||||
rec["id"] = "rec" + strconv.Itoa(f.n)
|
||||
f.recs[rec["id"].(string)] = rec
|
||||
writeJSON(w, 200, rec)
|
||||
case r.Method == http.MethodPatch && strings.HasPrefix(r.URL.Path, coll+"/"):
|
||||
rec, ok := f.recs[strings.TrimPrefix(r.URL.Path, coll+"/")]
|
||||
if !ok {
|
||||
writeError(w, 404, "missing")
|
||||
return
|
||||
}
|
||||
var patch map[string]any
|
||||
_ = json.NewDecoder(r.Body).Decode(&patch)
|
||||
for k, v := range patch {
|
||||
rec[k] = v
|
||||
}
|
||||
writeJSON(w, 200, rec)
|
||||
default:
|
||||
writeError(w, 404, "unexpected "+r.Method+" "+r.URL.Path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouteHandlersLastWriterWins(t *testing.T) {
|
||||
pb := &fakeRoutesPB{recs: map[string]map[string]any{}}
|
||||
ts := httptest.NewServer(pb)
|
||||
defer ts.Close()
|
||||
s := &Server{admin: newAdminClient(ts.URL, "svc@x", "pw")}
|
||||
pilot := &callerIdentity{ID: "u1", Role: roleUser, OrgID: "o1"}
|
||||
stranger := &callerIdentity{ID: "u2", Role: roleUser, OrgID: "o2"}
|
||||
|
||||
call := func(who *callerIdentity, h http.HandlerFunc, method, target, body string) (int, map[string]any) {
|
||||
req := httptest.NewRequest(method, target, strings.NewReader(body))
|
||||
req.SetPathValue("id", strings.Split(strings.TrimPrefix(req.URL.Path, "/api/routes/"), "?")[0])
|
||||
req = req.WithContext(context.WithValue(req.Context(), ctxCaller, who))
|
||||
rec := httptest.NewRecorder()
|
||||
h(rec, req)
|
||||
var out map[string]any
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &out)
|
||||
return rec.Code, out
|
||||
}
|
||||
withTime := func(at string) string {
|
||||
return strings.Replace(sampleRoute, "2026-10-20T08:30:00.123Z", at, 1)
|
||||
}
|
||||
|
||||
if code, _ := call(pilot, s.handlePutRoute, "PUT", "/api/routes/wp-other", sampleRoute); code != 422 {
|
||||
t.Fatalf("id mismatch: %d", code)
|
||||
}
|
||||
code, out := call(pilot, s.handlePutRoute, "PUT", "/api/routes/wp-m1x2y3", sampleRoute)
|
||||
if code != 201 {
|
||||
t.Fatalf("create: %d %v", code, out)
|
||||
}
|
||||
route := out["route"].(map[string]any)
|
||||
if route["id"] != "wp-m1x2y3" || route["name"] != "Field" || route["mine"] != true || route["pointCount"].(float64) != 2 {
|
||||
t.Fatalf("created view: %v", route)
|
||||
}
|
||||
stored := pb.recs["rec1"]
|
||||
if stored["owner"] != "u1" || stored["organization"] != "o1" {
|
||||
t.Fatalf("owner/org not stamped: %v", stored)
|
||||
}
|
||||
|
||||
if code, out := call(pilot, s.handlePutRoute, "PUT", "/api/routes/wp-m1x2y3", withTime("2026-10-20T08:00:00Z")); code != 409 || out["route"] == nil {
|
||||
t.Fatalf("older write: %d %v", code, out)
|
||||
}
|
||||
if code, _ := call(pilot, s.handlePutRoute, "PUT", "/api/routes/wp-m1x2y3", sampleRoute); code != 200 {
|
||||
t.Fatalf("idempotent retry: %d", code)
|
||||
}
|
||||
if code, _ := call(stranger, s.handlePutRoute, "PUT", "/api/routes/wp-m1x2y3", withTime("2026-10-21T00:00:00Z")); code != 409 {
|
||||
t.Fatalf("someone else's id: %d", code)
|
||||
}
|
||||
if code, _ := call(stranger, s.handleGetRoute, "GET", "/api/routes/wp-m1x2y3", ""); code != 404 {
|
||||
t.Fatalf("stranger get: %d", code)
|
||||
}
|
||||
|
||||
if code, _ := call(pilot, s.handleDeleteRoute, "DELETE", "/api/routes/wp-m1x2y3?at=2026-10-20T08:00:00Z", ""); code != 409 {
|
||||
t.Fatalf("delete older than the edit: %d", code)
|
||||
}
|
||||
code, out = call(pilot, s.handleDeleteRoute, "DELETE", "/api/routes/wp-m1x2y3?at=2026-10-20T09:00:00Z", "")
|
||||
if code != 200 || out["route"].(map[string]any)["deleted"] != true {
|
||||
t.Fatalf("delete: %d %v", code, out)
|
||||
}
|
||||
if stored["route_updated_at"] != "2026-10-20T09:00:00.000000Z" {
|
||||
t.Fatalf("tombstone stamp: %v", stored["route_updated_at"])
|
||||
}
|
||||
// A save newer than the delete brings the route back.
|
||||
code, out = call(pilot, s.handlePutRoute, "PUT", "/api/routes/wp-m1x2y3", withTime("2026-10-20T10:00:00Z"))
|
||||
if code != 200 || out["route"].(map[string]any)["deleted"] != false {
|
||||
t.Fatalf("resurrect: %d %v", code, out)
|
||||
}
|
||||
}
|
||||
@@ -168,6 +168,14 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("DELETE /api/documents/{id}", s.requireUser(s.handleDeleteDocument))
|
||||
mux.HandleFunc("GET /api/documents/{id}/file", s.requireUser(s.handleDownloadDocument))
|
||||
|
||||
// Routes — saved waypoint missions, synced with the Fly App's mission
|
||||
// library (last-writer-wins by the route's updatedAt; deletes tombstoned).
|
||||
// Per-role scoping is enforced inside the handlers.
|
||||
mux.HandleFunc("GET /api/routes", s.requireUser(s.handleListRoutes))
|
||||
mux.HandleFunc("GET /api/routes/{id}", s.requireUser(s.handleGetRoute))
|
||||
mux.HandleFunc("PUT /api/routes/{id}", s.requireUser(s.handlePutRoute))
|
||||
mux.HandleFunc("DELETE /api/routes/{id}", s.requireUser(s.handleDeleteRoute))
|
||||
|
||||
// Device / dashboard API.
|
||||
mux.HandleFunc("GET /api/devices", s.handleListDevices)
|
||||
mux.HandleFunc("GET /api/devices/{id}/track", s.handleTrack)
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
|
||||
// Creates the `routes` collection: the pilots' saved waypoint missions (the Fly
|
||||
// App's mission library, GO 4-parity plan P7). Each row holds one route exactly
|
||||
// as the Fly App's `WaypointMissionSpec.toJson()` writes it (format version 1)
|
||||
// in `spec`, keyed by the app-generated `route_id` so a tablet can push and pull
|
||||
// without knowing PocketBase record ids.
|
||||
//
|
||||
// Sync is last-writer-wins by the route's own `updatedAt` (copied into
|
||||
// `route_updated_at` so the API Server can compare without parsing `spec`).
|
||||
// Deletes are kept as tombstones (`deleted` = true) so a device that was
|
||||
// offline learns about them instead of re-uploading the route.
|
||||
//
|
||||
// Like the logbook and documents, the collection is reached only through the
|
||||
// API Server's superuser service account, so its API rules stay locked
|
||||
// (superusers only); the API Server enforces per-role scoping in Go.
|
||||
//
|
||||
// Apply by copying into your PocketBase deployment's `pb_migrations/` directory
|
||||
// and restarting. Written for PocketBase v0.22+/v0.23. Idempotent: the
|
||||
// collection is created only if absent, so re-running is a no-op.
|
||||
//
|
||||
// Depends on 1720300200_add_organizations.js (organizations) and the `users`
|
||||
// auth collection.
|
||||
migrate(
|
||||
(app) => {
|
||||
// Idempotency guard.
|
||||
try {
|
||||
app.findCollectionByNameOrId('routes')
|
||||
return // already present
|
||||
} catch (_) {
|
||||
// create below
|
||||
}
|
||||
|
||||
const orgs = app.findCollectionByNameOrId('organizations')
|
||||
const users = app.findCollectionByNameOrId('users')
|
||||
|
||||
const routes = new Collection({
|
||||
type: 'base',
|
||||
name: 'routes',
|
||||
fields: [
|
||||
// The Fly App's own id (e.g. "wp-m1x2y3z"); unique across the store.
|
||||
{ name: 'route_id', type: 'text', required: true, max: 100 },
|
||||
{ name: 'name', type: 'text', max: 200, presentable: true },
|
||||
// The route itself, verbatim WaypointMissionSpec.toJson().
|
||||
{ name: 'spec', type: 'json', maxSize: 2000000 },
|
||||
// The spec's updatedAt (RFC 3339, UTC) — the last-writer-wins clock.
|
||||
{ name: 'route_updated_at', type: 'text', max: 40 },
|
||||
{ name: 'deleted', type: 'bool' },
|
||||
{
|
||||
name: 'owner',
|
||||
type: 'relation',
|
||||
required: false,
|
||||
collectionId: users.id,
|
||||
cascadeDelete: false,
|
||||
minSelect: 0,
|
||||
maxSelect: 1,
|
||||
presentable: false,
|
||||
},
|
||||
{
|
||||
name: 'organization',
|
||||
type: 'relation',
|
||||
required: false,
|
||||
collectionId: orgs.id,
|
||||
cascadeDelete: false,
|
||||
minSelect: 0,
|
||||
maxSelect: 1,
|
||||
presentable: false,
|
||||
},
|
||||
{ name: 'created', type: 'autodate', onCreate: true, onUpdate: false },
|
||||
{ name: 'updated', type: 'autodate', onCreate: true, onUpdate: true },
|
||||
],
|
||||
indexes: [
|
||||
'CREATE UNIQUE INDEX `idx_routes_route_id` ON `routes` (`route_id`)',
|
||||
'CREATE INDEX `idx_routes_owner` ON `routes` (`owner`)',
|
||||
'CREATE INDEX `idx_routes_org` ON `routes` (`organization`)',
|
||||
],
|
||||
})
|
||||
app.save(routes)
|
||||
},
|
||||
(app) => {
|
||||
try {
|
||||
app.delete(app.findCollectionByNameOrId('routes'))
|
||||
} catch (_) {
|
||||
// already gone
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -1,24 +1,56 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'mission_sync.dart';
|
||||
import 'waypoint_mission.dart';
|
||||
|
||||
/// How the library stands with the API Server.
|
||||
enum MissionSyncStatus {
|
||||
/// Signed out (or no server): routes live on this device only.
|
||||
local,
|
||||
syncing,
|
||||
synced,
|
||||
|
||||
/// The last sync failed (offline, server down); routes are safe locally and
|
||||
/// go up on the next sync.
|
||||
failed,
|
||||
}
|
||||
|
||||
/// The pilot's saved waypoint routes (GO 4's mission library, plan P7).
|
||||
///
|
||||
/// Kept on this device as JSON in shared preferences, in the
|
||||
/// [WaypointMissionSpec.toJson] format the API Server will store once route
|
||||
/// sync lands. Newest first.
|
||||
/// [WaypointMissionSpec.toJson] format the API Server stores. Newest first.
|
||||
///
|
||||
/// When signed in, the library syncs with the server through [MissionRemote]:
|
||||
/// [load] pulls, every [save] and [delete] syncs again, and the two sides are
|
||||
/// merged last-writer-wins by `updatedAt` ([mergeMissions]). Deletes the server
|
||||
/// has not acknowledged are kept as local tombstones. Offline, everything still
|
||||
/// works on the device and goes up on the next successful sync.
|
||||
///
|
||||
/// Each account keeps its own copy on the device, so pilots sharing a tablet
|
||||
/// never see (or upload) each other's routes. Routes saved while signed out
|
||||
/// are claimed by the first account that signs in.
|
||||
class MissionLibrary extends ChangeNotifier {
|
||||
MissionLibrary({SharedPreferences? prefs}) : _prefs = prefs;
|
||||
MissionLibrary({SharedPreferences? prefs, MissionRemote? remote})
|
||||
: _prefs = prefs,
|
||||
_remote = remote;
|
||||
|
||||
static const String _key = 'missions.waypoint';
|
||||
|
||||
SharedPreferences? _prefs;
|
||||
final MissionRemote? _remote;
|
||||
List<WaypointMissionSpec> _missions = <WaypointMissionSpec>[];
|
||||
Map<String, DateTime> _tombstones = <String, DateTime>{};
|
||||
bool _loaded = false;
|
||||
|
||||
/// The account whose copy is loaded ('' = signed out).
|
||||
String _account = '';
|
||||
MissionSyncStatus _status = MissionSyncStatus.local;
|
||||
Future<void> _chain = Future<void>.value();
|
||||
|
||||
/// The flight screen can close while [load] still waits for storage.
|
||||
bool _disposed = false;
|
||||
|
||||
@@ -35,21 +67,69 @@ class MissionLibrary extends ChangeNotifier {
|
||||
|
||||
List<WaypointMissionSpec> get missions => List<WaypointMissionSpec>.unmodifiable(_missions);
|
||||
bool get loaded => _loaded;
|
||||
MissionSyncStatus get syncStatus => _status;
|
||||
|
||||
/// Completes once every sync started so far has finished.
|
||||
Future<void> get idle => _chain;
|
||||
|
||||
static String _keyFor(String account) => account.isEmpty ? _key : '$_key@$account';
|
||||
|
||||
Future<void> load() async {
|
||||
try {
|
||||
_prefs ??= await SharedPreferences.getInstance();
|
||||
final String? raw = _prefs!.getString(_key);
|
||||
_missions = raw == null ? <WaypointMissionSpec>[] : decode(raw);
|
||||
} catch (_) {
|
||||
_missions = <WaypointMissionSpec>[];
|
||||
}
|
||||
_account = _remote?.account ?? '';
|
||||
await _loadLocal();
|
||||
_loaded = true;
|
||||
notifyListeners();
|
||||
await sync();
|
||||
}
|
||||
|
||||
Future<void> _loadLocal() async {
|
||||
try {
|
||||
_prefs ??= await SharedPreferences.getInstance();
|
||||
_missions = _read(_keyFor(_account));
|
||||
_tombstones = _readTombstones(_keyFor(_account));
|
||||
if (_account.isNotEmpty) {
|
||||
// Routes saved while signed out now belong to this account.
|
||||
final List<WaypointMissionSpec> unclaimed = _read(_key);
|
||||
if (unclaimed.isNotEmpty) {
|
||||
final Set<String> have = <String>{for (final WaypointMissionSpec m in _missions) m.id};
|
||||
_missions = <WaypointMissionSpec>[
|
||||
..._missions,
|
||||
for (final WaypointMissionSpec m in unclaimed) if (!have.contains(m.id)) m,
|
||||
];
|
||||
await _store();
|
||||
await _prefs!.remove(_key);
|
||||
await _prefs!.remove('$_key.deleted');
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
_missions = <WaypointMissionSpec>[];
|
||||
_tombstones = <String, DateTime>{};
|
||||
}
|
||||
}
|
||||
|
||||
List<WaypointMissionSpec> _read(String key) {
|
||||
final String? raw = _prefs!.getString(key);
|
||||
return raw == null ? <WaypointMissionSpec>[] : decode(raw);
|
||||
}
|
||||
|
||||
Map<String, DateTime> _readTombstones(String key) {
|
||||
final String? raw = _prefs!.getString('$key.deleted');
|
||||
if (raw == null) return <String, DateTime>{};
|
||||
try {
|
||||
final Object? m = jsonDecode(raw);
|
||||
if (m is! Map) return <String, DateTime>{};
|
||||
return <String, DateTime>{
|
||||
for (final MapEntry<Object?, Object?> e in m.entries)
|
||||
if (e.key is String && DateTime.tryParse('${e.value}') != null) e.key! as String: DateTime.parse('${e.value}'),
|
||||
};
|
||||
} catch (_) {
|
||||
return <String, DateTime>{};
|
||||
}
|
||||
}
|
||||
|
||||
/// Saves [m] (replacing the one with the same id) and returns it as stored:
|
||||
/// with an id and a fresh [WaypointMissionSpec.updatedAt].
|
||||
/// with an id and a fresh [WaypointMissionSpec.updatedAt]. Syncs in the
|
||||
/// background.
|
||||
Future<WaypointMissionSpec> save(WaypointMissionSpec m, {DateTime? now}) async {
|
||||
final WaypointMissionSpec stored = m.copyWith(
|
||||
id: m.id.isEmpty ? newId(now) : m.id,
|
||||
@@ -59,25 +139,115 @@ class MissionLibrary extends ChangeNotifier {
|
||||
stored,
|
||||
for (final WaypointMissionSpec o in _missions) if (o.id != stored.id) o,
|
||||
];
|
||||
_tombstones.remove(stored.id);
|
||||
await _persist();
|
||||
unawaited(sync());
|
||||
return stored;
|
||||
}
|
||||
|
||||
Future<void> delete(String id) async {
|
||||
Future<void> delete(String id, {DateTime? now}) async {
|
||||
_missions = <WaypointMissionSpec>[for (final WaypointMissionSpec o in _missions) if (o.id != id) o];
|
||||
// Only a signed-in library has a server copy to tell.
|
||||
if (_account.isNotEmpty) _tombstones[id] = (now ?? DateTime.now()).toUtc();
|
||||
await _persist();
|
||||
unawaited(sync());
|
||||
}
|
||||
|
||||
/// Pulls the server's routes, merges, and pushes what the device has newer.
|
||||
/// Runs one at a time; a no-op while signed out. Never throws.
|
||||
Future<void> sync() => _chain = _chain.then((_) => _sync()).catchError((Object _) {});
|
||||
|
||||
Future<void> _sync() async {
|
||||
final MissionRemote? remote = _remote;
|
||||
if (remote == null || _disposed) return;
|
||||
final String account = remote.account;
|
||||
if (account != _account) {
|
||||
// Signed in, out, or as someone else since the last load.
|
||||
_account = account;
|
||||
await _loadLocal();
|
||||
notifyListeners();
|
||||
}
|
||||
if (account.isEmpty) {
|
||||
_setStatus(MissionSyncStatus.local);
|
||||
return;
|
||||
}
|
||||
_setStatus(MissionSyncStatus.syncing);
|
||||
try {
|
||||
final List<RemoteRoute> theirs = await remote.pull();
|
||||
if (remote.account != _account) return; // signed out mid-pull
|
||||
// Merged against the library as it is now, not as it was before the
|
||||
// pull: a save made meanwhile is newer and gets pushed.
|
||||
final MissionMerge merge = mergeMissions(local: _missions, tombstones: _tombstones, remote: theirs);
|
||||
_missions = merge.missions;
|
||||
_tombstones = Map<String, DateTime>.of(merge.tombstones);
|
||||
await _persist();
|
||||
|
||||
bool ok = true;
|
||||
for (final WaypointMissionSpec m in merge.toPush) {
|
||||
try {
|
||||
final RemoteRoute? newer = await remote.push(m);
|
||||
if (newer != null) _adopt(newer);
|
||||
} catch (_) {
|
||||
ok = false;
|
||||
}
|
||||
}
|
||||
for (final MapEntry<String, DateTime> d in merge.toDelete.entries) {
|
||||
try {
|
||||
final RemoteRoute? newer = await remote.pushDelete(d.key, d.value);
|
||||
if (_tombstones[d.key] == d.value) _tombstones.remove(d.key);
|
||||
if (newer != null) _adopt(newer);
|
||||
} catch (_) {
|
||||
ok = false;
|
||||
}
|
||||
}
|
||||
await _persist();
|
||||
_setStatus(ok ? MissionSyncStatus.synced : MissionSyncStatus.failed);
|
||||
} catch (_) {
|
||||
_setStatus(MissionSyncStatus.failed);
|
||||
}
|
||||
}
|
||||
|
||||
/// Takes the server's copy of a route it refused to overwrite — unless the
|
||||
/// pilot changed the route again while the push was in flight.
|
||||
void _adopt(RemoteRoute r) {
|
||||
final WaypointMissionSpec? mine = _missions.where((WaypointMissionSpec m) => m.id == r.id).firstOrNull;
|
||||
final DateTime? mineAt = mine?.updatedAt ?? _tombstones[r.id];
|
||||
if (mineAt != null && mineAt.isAfter(r.updatedAt)) return;
|
||||
_tombstones.remove(r.id);
|
||||
_missions = <WaypointMissionSpec>[
|
||||
if (r.spec != null) r.spec!,
|
||||
for (final WaypointMissionSpec o in _missions) if (o.id != r.id) o,
|
||||
]..sort((WaypointMissionSpec a, WaypointMissionSpec b) =>
|
||||
(b.updatedAt ?? DateTime(0)).compareTo(a.updatedAt ?? DateTime(0)));
|
||||
}
|
||||
|
||||
void _setStatus(MissionSyncStatus s) {
|
||||
if (_status == s) return;
|
||||
_status = s;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> _persist() async {
|
||||
notifyListeners();
|
||||
try {
|
||||
_prefs ??= await SharedPreferences.getInstance();
|
||||
await _prefs!.setString(_key, encode(_missions));
|
||||
await _store();
|
||||
} catch (_) {
|
||||
// Storage unavailable: the library lives for this session only.
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _store() async {
|
||||
_prefs ??= await SharedPreferences.getInstance();
|
||||
final String key = _keyFor(_account);
|
||||
await _prefs!.setString(key, encode(_missions));
|
||||
if (_tombstones.isEmpty) {
|
||||
await _prefs!.remove('$key.deleted');
|
||||
} else {
|
||||
await _prefs!.setString('$key.deleted',
|
||||
jsonEncode(<String, String>{for (final MapEntry<String, DateTime> e in _tombstones.entries) e.key: e.value.toUtc().toIso8601String()}));
|
||||
}
|
||||
}
|
||||
|
||||
static String newId([DateTime? now]) => 'wp-${(now ?? DateTime.now()).microsecondsSinceEpoch.toRadixString(36)}';
|
||||
|
||||
static String encode(List<WaypointMissionSpec> missions) =>
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import '../../pb_auth.dart';
|
||||
import 'waypoint_mission.dart';
|
||||
|
||||
/// One route as the API Server reports it: a [spec], or a tombstone (null
|
||||
/// [spec]) left by a delete on some device. [updatedAt] is the
|
||||
/// last-writer-wins clock either way.
|
||||
class RemoteRoute {
|
||||
const RemoteRoute(this.id, this.updatedAt, this.spec);
|
||||
|
||||
final String id;
|
||||
final DateTime updatedAt;
|
||||
final WaypointMissionSpec? spec;
|
||||
|
||||
bool get deleted => spec == null;
|
||||
|
||||
/// Reads one entry of the server's `/api/routes` view. Null when unusable.
|
||||
static RemoteRoute? fromView(Map<String, dynamic> v) {
|
||||
final String id = v['id'] as String? ?? '';
|
||||
final DateTime? at = DateTime.tryParse(v['updatedAt'] as String? ?? '');
|
||||
if (id.isEmpty || at == null) return null;
|
||||
if (v['deleted'] == true) return RemoteRoute(id, at, null);
|
||||
final Object? spec = v['spec'];
|
||||
if (spec is! Map) return null;
|
||||
try {
|
||||
final WaypointMissionSpec m = WaypointMissionSpec.fromJson(Map<String, dynamic>.from(spec));
|
||||
return RemoteRoute(id, at, m.copyWith(id: id, updatedAt: m.updatedAt ?? at));
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The server half of the mission library. [ApiMissionRemote] is the real one.
|
||||
abstract class MissionRemote {
|
||||
/// Who the routes belong to; empty while signed out (nothing syncs).
|
||||
String get account;
|
||||
|
||||
/// Every route the account owns, tombstones included.
|
||||
Future<List<RemoteRoute>> pull();
|
||||
|
||||
/// Stores [m]. Returns the server's copy when it holds a newer one (the
|
||||
/// write was refused), else null.
|
||||
Future<RemoteRoute?> push(WaypointMissionSpec m);
|
||||
|
||||
/// Tombstones route [id] as deleted at [at]. Returns the server's copy when
|
||||
/// the route was changed after [at] (the delete was refused), else null.
|
||||
Future<RemoteRoute?> pushDelete(String id, DateTime at);
|
||||
}
|
||||
|
||||
/// What [mergeMissions] decided.
|
||||
class MissionMerge {
|
||||
const MissionMerge({required this.missions, required this.tombstones, required this.toPush, required this.toDelete});
|
||||
|
||||
/// The library after the merge, newest first.
|
||||
final List<WaypointMissionSpec> missions;
|
||||
|
||||
/// Local deletes the server has not heard of yet.
|
||||
final Map<String, DateTime> tombstones;
|
||||
|
||||
/// Local routes newer than the server's copy (or unknown to it).
|
||||
final List<WaypointMissionSpec> toPush;
|
||||
|
||||
/// Local deletes newer than the server's live copy.
|
||||
final Map<String, DateTime> toDelete;
|
||||
}
|
||||
|
||||
final DateTime _epoch = DateTime.fromMillisecondsSinceEpoch(0, isUtc: true);
|
||||
|
||||
/// Last-writer-wins merge of the device's library with the server's, per
|
||||
/// route id, by `updatedAt`. A delete counts as a write at the time it was
|
||||
/// made, on either side. On a tie the server wins.
|
||||
///
|
||||
/// A route saved before it had an `updatedAt` is treated as the oldest
|
||||
/// possible write (and stamped so it can be pushed).
|
||||
MissionMerge mergeMissions({
|
||||
required List<WaypointMissionSpec> local,
|
||||
required Map<String, DateTime> tombstones,
|
||||
required List<RemoteRoute> remote,
|
||||
}) {
|
||||
final Map<String, WaypointMissionSpec> mine = <String, WaypointMissionSpec>{
|
||||
for (final WaypointMissionSpec m in local) m.id: m.updatedAt == null ? m.copyWith(updatedAt: _epoch) : m,
|
||||
};
|
||||
final Map<String, RemoteRoute> theirs = <String, RemoteRoute>{for (final RemoteRoute r in remote) r.id: r};
|
||||
|
||||
final Map<String, WaypointMissionSpec> out = <String, WaypointMissionSpec>{};
|
||||
final Map<String, DateTime> keepTombstones = <String, DateTime>{};
|
||||
final List<WaypointMissionSpec> toPush = <WaypointMissionSpec>[];
|
||||
final Map<String, DateTime> toDelete = <String, DateTime>{};
|
||||
|
||||
final Set<String> ids = <String>{...mine.keys, ...tombstones.keys, ...theirs.keys};
|
||||
for (final String id in ids) {
|
||||
final WaypointMissionSpec? l = mine[id];
|
||||
final DateTime? t = tombstones[id];
|
||||
final RemoteRoute? r = theirs[id];
|
||||
|
||||
// The device's own latest word on this route: a save, or a delete.
|
||||
final bool localDeleted = t != null && (l == null || !t.isBefore(l.updatedAt!));
|
||||
final DateTime? localAt = localDeleted ? t : l?.updatedAt;
|
||||
|
||||
if (r == null) {
|
||||
// The server never had it: push a save; a delete has nothing to undo.
|
||||
if (!localDeleted && l != null) {
|
||||
out[id] = l;
|
||||
toPush.add(l);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (localAt == null || !localAt.isAfter(r.updatedAt)) {
|
||||
// The server's copy (or its delete) is the newest word.
|
||||
if (!r.deleted) out[id] = r.spec!;
|
||||
continue;
|
||||
}
|
||||
if (localDeleted) {
|
||||
if (!r.deleted) {
|
||||
keepTombstones[id] = t;
|
||||
toDelete[id] = t;
|
||||
}
|
||||
} else {
|
||||
out[id] = l!;
|
||||
toPush.add(l);
|
||||
}
|
||||
}
|
||||
|
||||
final List<WaypointMissionSpec> missions = out.values.toList()
|
||||
..sort((WaypointMissionSpec a, WaypointMissionSpec b) => b.updatedAt!.compareTo(a.updatedAt!));
|
||||
return MissionMerge(missions: missions, tombstones: keepTombstones, toPush: toPush, toDelete: toDelete);
|
||||
}
|
||||
|
||||
/// [MissionRemote] over the API Server's `/api/routes`, as the signed-in
|
||||
/// pilot ([auth]).
|
||||
class ApiMissionRemote implements MissionRemote {
|
||||
ApiMissionRemote({PbAuth? auth}) : _auth = auth;
|
||||
|
||||
final PbAuth? _auth;
|
||||
PbAuth get _a => _auth ?? auth;
|
||||
|
||||
static const Duration _timeout = Duration(seconds: 10);
|
||||
|
||||
@override
|
||||
String get account => _a.isAuthed ? (_a.userEmail.isEmpty ? '_' : _a.userEmail) : '';
|
||||
|
||||
@override
|
||||
Future<List<RemoteRoute>> pull() async {
|
||||
final (int status, Map<String, dynamic> body) = await _send('GET', '/api/routes?mine=1&includeDeleted=1');
|
||||
if (status != 200) throw MissionSyncException(status, body);
|
||||
return <RemoteRoute>[
|
||||
for (final Object? v in (body['routes'] as List<Object?>? ?? const <Object?>[]))
|
||||
if (v is Map)
|
||||
if (RemoteRoute.fromView(Map<String, dynamic>.from(v)) case final RemoteRoute r) r,
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<RemoteRoute?> push(WaypointMissionSpec m) async {
|
||||
final (int status, Map<String, dynamic> body) =
|
||||
await _send('PUT', '/api/routes/${Uri.encodeComponent(m.id)}', body: m.toJson());
|
||||
if (status == 200 || status == 201) return null;
|
||||
return _conflict(status, body);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<RemoteRoute?> pushDelete(String id, DateTime at) async {
|
||||
final String when = Uri.encodeQueryComponent(at.toUtc().toIso8601String());
|
||||
final (int status, Map<String, dynamic> body) = await _send('DELETE', '/api/routes/${Uri.encodeComponent(id)}?at=$when');
|
||||
// 404: the server never had it, which is as deleted as it gets.
|
||||
if (status == 200 || status == 404) return null;
|
||||
return _conflict(status, body);
|
||||
}
|
||||
|
||||
RemoteRoute _conflict(int status, Map<String, dynamic> body) {
|
||||
final Object? route = body['route'];
|
||||
final RemoteRoute? r = status == 409 && route is Map ? RemoteRoute.fromView(Map<String, dynamic>.from(route)) : null;
|
||||
if (r == null) throw MissionSyncException(status, body);
|
||||
return r;
|
||||
}
|
||||
|
||||
Future<(int, Map<String, dynamic>)> _send(String method, String path, {Object? body}) async {
|
||||
final HttpClient http = HttpClient()..connectionTimeout = _timeout;
|
||||
try {
|
||||
final HttpClientRequest req = await http.openUrl(method, Uri.parse('${_a.serverUrl}$path'));
|
||||
req.headers.set(HttpHeaders.authorizationHeader, _a.token);
|
||||
if (body != null) {
|
||||
req.headers.contentType = ContentType.json;
|
||||
req.add(utf8.encode(jsonEncode(body)));
|
||||
}
|
||||
final HttpClientResponse resp = await req.close().timeout(_timeout);
|
||||
final String text = await resp.transform(utf8.decoder).join().timeout(_timeout);
|
||||
Map<String, dynamic> json = <String, dynamic>{};
|
||||
try {
|
||||
final Object? decoded = text.isEmpty ? null : jsonDecode(text);
|
||||
if (decoded is Map) json = Map<String, dynamic>.from(decoded);
|
||||
} catch (_) {}
|
||||
return (resp.statusCode, json);
|
||||
} finally {
|
||||
http.close(force: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class MissionSyncException implements Exception {
|
||||
const MissionSyncException(this.status, this.body);
|
||||
final int status;
|
||||
final Map<String, dynamic> body;
|
||||
|
||||
@override
|
||||
String toString() => (body['error'] ?? 'Route sync failed ($status)').toString();
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import '../../fly/flight_trail.dart';
|
||||
import '../../fly/fly_prefs.dart';
|
||||
import '../../fly/hud_features.dart';
|
||||
import '../../fly/modes/mission_library.dart';
|
||||
import '../../fly/modes/mission_sync.dart' show ApiMissionRemote;
|
||||
import '../../fly/modes/vision_modes.dart';
|
||||
import '../../fly/modes/waypoint_editor.dart';
|
||||
import '../../fly/modes/waypoint_mission.dart' show MissionLimits;
|
||||
@@ -110,7 +111,7 @@ class _FlyScreenState extends State<FlyScreen> {
|
||||
IntelligentMode? _mode;
|
||||
late final WaypointEditor _waypoints = widget.waypoints ?? WaypointEditor();
|
||||
final VisionSession _vision = VisionSession();
|
||||
late final MissionLibrary _library = MissionLibrary()..load();
|
||||
late final MissionLibrary _library = MissionLibrary(remote: ApiMissionRemote())..load();
|
||||
MissionLimits _limits = const MissionLimits();
|
||||
int _seenFinishes = 0;
|
||||
|
||||
|
||||
@@ -190,8 +190,11 @@ class IssueRow extends StatelessWidget {
|
||||
// ── Mission library ────────────────────────────────────────────────────────
|
||||
|
||||
/// The saved routes: save the current one, start a new one, load or delete.
|
||||
Future<void> showMissionLibrary(BuildContext context, {required WaypointEditor editor, required MissionLibrary library}) =>
|
||||
_showSheet(context, title: 'Mission library', icon: 'album', body: MissionLibraryList(editor: editor, library: library));
|
||||
/// Opening it also syncs, so routes saved on another device show up.
|
||||
Future<void> showMissionLibrary(BuildContext context, {required WaypointEditor editor, required MissionLibrary library}) {
|
||||
library.sync();
|
||||
return _showSheet(context, title: 'Mission library', icon: 'album', body: MissionLibraryList(editor: editor, library: library));
|
||||
}
|
||||
|
||||
class MissionLibraryList extends StatelessWidget {
|
||||
const MissionLibraryList({super.key, required this.editor, required this.library});
|
||||
@@ -209,6 +212,13 @@ class MissionLibraryList extends StatelessWidget {
|
||||
editor.markSaved(await library.save(m));
|
||||
}
|
||||
|
||||
static String _heading(MissionSyncStatus s) => switch (s) {
|
||||
MissionSyncStatus.local => 'SAVED ON THIS DEVICE',
|
||||
MissionSyncStatus.syncing => 'SAVED ROUTES · SYNCING…',
|
||||
MissionSyncStatus.synced => 'SAVED ROUTES · SYNCED',
|
||||
MissionSyncStatus.failed => 'SAVED ROUTES · OFFLINE, SYNCS LATER',
|
||||
};
|
||||
|
||||
Future<bool> _discardOk(BuildContext context) async {
|
||||
if (!editor.dirty || editor.mission.points.isEmpty) return true;
|
||||
return confirmHud(context, title: 'Discard changes?', body: 'The route on the map has unsaved changes.', action: 'Discard');
|
||||
@@ -239,7 +249,7 @@ class MissionLibraryList extends StatelessWidget {
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 14, 16, 4),
|
||||
child: Text('SAVED ON THIS DEVICE', style: HudText.label),
|
||||
child: Text(_heading(library.syncStatus), style: HudText.label),
|
||||
),
|
||||
if (library.missions.isEmpty)
|
||||
Padding(
|
||||
@@ -256,7 +266,9 @@ class MissionLibraryList extends StatelessWidget {
|
||||
if (context.mounted) Navigator.of(context).pop();
|
||||
},
|
||||
onDelete: () async {
|
||||
if (await confirmHud(context, title: 'Delete "${m.name}"?', body: 'The saved route is removed from this device.', action: 'Delete')) {
|
||||
if (await confirmHud(context, title: 'Delete "${m.name}"?', body: library.syncStatus == MissionSyncStatus.local
|
||||
? 'The saved route is removed from this device.'
|
||||
: 'The saved route is removed from this device and your account.', action: 'Delete')) {
|
||||
await library.delete(m.id);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:pilotvault_fly/fly/action_rail_state.dart';
|
||||
import 'package:pilotvault_fly/fly/fly_prefs.dart';
|
||||
import 'package:pilotvault_fly/fly/modes/intelligent_modes.dart';
|
||||
import 'package:pilotvault_fly/fly/modes/mission_library.dart';
|
||||
import 'package:pilotvault_fly/fly/modes/mission_sync.dart';
|
||||
import 'package:pilotvault_fly/fly/modes/waypoint_editor.dart';
|
||||
import 'package:pilotvault_fly/fly/modes/waypoint_mission.dart';
|
||||
import 'package:pilotvault_fly/fly/product_profile.dart';
|
||||
@@ -290,6 +291,156 @@ void main() {
|
||||
final List<WaypointMissionSpec> out = MissionLibrary.decode('[{"name":"ok","points":[]}, {"points": 7}, 3]');
|
||||
expect(out.map((WaypointMissionSpec m) => m.name), <String>['ok']);
|
||||
});
|
||||
|
||||
group('sync', () {
|
||||
WaypointMissionSpec named(String id, String name, int day) =>
|
||||
route(<LatLng>[base, at(0, 100)]).copyWith(id: id, name: name, updatedAt: DateTime.utc(2026, 1, day));
|
||||
RemoteRoute live(WaypointMissionSpec m) => RemoteRoute(m.id, m.updatedAt!, m);
|
||||
RemoteRoute gone(String id, int day) => RemoteRoute(id, DateTime.utc(2026, 1, day), null);
|
||||
List<String> names(Iterable<WaypointMissionSpec> ms) => <String>[for (final WaypointMissionSpec m in ms) m.name];
|
||||
|
||||
test('merge: newest write wins per route, either side', () {
|
||||
final MissionMerge m = mergeMissions(
|
||||
local: <WaypointMissionSpec>[named('a', 'A local', 5), named('b', 'B local', 2), named('c', 'C only here', 3)],
|
||||
tombstones: const <String, DateTime>{},
|
||||
remote: <RemoteRoute>[live(named('a', 'A server', 4)), live(named('b', 'B server', 6)), live(named('d', 'D only there', 1))],
|
||||
);
|
||||
expect(names(m.missions), <String>['B server', 'A local', 'C only here', 'D only there'], reason: 'newest first');
|
||||
expect(names(m.toPush), unorderedEquals(<String>['A local', 'C only here']));
|
||||
expect(m.toDelete, isEmpty);
|
||||
});
|
||||
|
||||
test('merge: a tie goes to the server and pushes nothing', () {
|
||||
final MissionMerge m = mergeMissions(
|
||||
local: <WaypointMissionSpec>[named('a', 'mine', 3)],
|
||||
tombstones: const <String, DateTime>{},
|
||||
remote: <RemoteRoute>[live(named('a', 'theirs', 3))],
|
||||
);
|
||||
expect(names(m.missions), <String>['theirs']);
|
||||
expect(m.toPush, isEmpty);
|
||||
});
|
||||
|
||||
test('merge: deletes are writes too', () {
|
||||
final MissionMerge m = mergeMissions(
|
||||
local: <WaypointMissionSpec>[named('kept', 'edited after their delete', 5), named('dropped', 'stale', 2)],
|
||||
tombstones: <String, DateTime>{
|
||||
'del': DateTime.utc(2026, 1, 5), // deleted here after their edit
|
||||
'lost': DateTime.utc(2026, 1, 1), // deleted here, but edited there since
|
||||
'never': DateTime.utc(2026, 1, 1), // deleted before it ever reached the server
|
||||
'both': DateTime.utc(2026, 1, 4), // deleted on both sides
|
||||
},
|
||||
remote: <RemoteRoute>[
|
||||
gone('kept', 4),
|
||||
gone('dropped', 3),
|
||||
live(named('del', 'old', 4)),
|
||||
live(named('lost', 'edited there', 2)),
|
||||
gone('both', 2),
|
||||
],
|
||||
);
|
||||
expect(names(m.missions), <String>['edited after their delete', 'edited there']);
|
||||
expect(names(m.toPush), <String>['edited after their delete'], reason: 'resurrects it on the server');
|
||||
expect(m.toDelete, <String, DateTime>{'del': DateTime.utc(2026, 1, 5)});
|
||||
expect(m.tombstones.keys, <String>['del'], reason: 'only unacknowledged deletes stay');
|
||||
});
|
||||
|
||||
test('merge: a route with no updatedAt is the oldest write, and gets one to push', () {
|
||||
final MissionMerge m = mergeMissions(
|
||||
local: <WaypointMissionSpec>[route(<LatLng>[base]).copyWith(id: 'legacy', name: 'legacy')],
|
||||
tombstones: const <String, DateTime>{},
|
||||
remote: const <RemoteRoute>[],
|
||||
);
|
||||
expect(m.toPush.single.updatedAt, isNotNull);
|
||||
});
|
||||
|
||||
test('the server view parses, tombstones included', () {
|
||||
final WaypointMissionSpec spec = named('wp-1', 'R', 3);
|
||||
final RemoteRoute? r = RemoteRoute.fromView(<String, dynamic>{
|
||||
'id': 'wp-1',
|
||||
'updatedAt': '2026-01-03T00:00:00.000000Z',
|
||||
'deleted': false,
|
||||
'spec': spec.toJson(),
|
||||
});
|
||||
expect(r!.spec!.toJson(), spec.toJson());
|
||||
final RemoteRoute? t = RemoteRoute.fromView(<String, dynamic>{'id': 'wp-1', 'updatedAt': '2026-01-04T00:00:00Z', 'deleted': true});
|
||||
expect(t!.deleted, isTrue);
|
||||
expect(RemoteRoute.fromView(<String, dynamic>{'id': 'wp-1', 'deleted': false, 'spec': spec.toJson()}), isNull);
|
||||
});
|
||||
|
||||
test('a route saved on one tablet is there after a reinstall + sign-in', () async {
|
||||
final _FakeRemote server = _FakeRemote('pilot@x');
|
||||
final MissionLibrary tablet = MissionLibrary(remote: server);
|
||||
await tablet.load();
|
||||
final WaypointMissionSpec saved = await tablet.save(route(<LatLng>[base, at(0, 100)]).copyWith(name: 'Survey'));
|
||||
await tablet.idle;
|
||||
expect(server.routes.keys, <String>[saved.id]);
|
||||
expect(tablet.syncStatus, MissionSyncStatus.synced);
|
||||
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{}); // reinstall
|
||||
final MissionLibrary fresh = MissionLibrary(remote: server);
|
||||
await fresh.load();
|
||||
expect(fresh.missions.single.toJson(), saved.toJson());
|
||||
|
||||
await fresh.delete(saved.id);
|
||||
await fresh.idle;
|
||||
expect(server.routes[saved.id]!.deleted, isTrue);
|
||||
await tablet.sync();
|
||||
expect(tablet.missions, isEmpty, reason: 'the other tablet learns of the delete');
|
||||
});
|
||||
|
||||
test('offline: saves and deletes stay local and go up on the next sync', () async {
|
||||
final _FakeRemote server = _FakeRemote('pilot@x')..offline = true;
|
||||
final MissionLibrary lib = MissionLibrary(remote: server);
|
||||
await lib.load();
|
||||
expect(lib.syncStatus, MissionSyncStatus.failed);
|
||||
final WaypointMissionSpec a = await lib.save(route(<LatLng>[base]).copyWith(name: 'A'), now: DateTime.utc(2026, 1, 1));
|
||||
final WaypointMissionSpec b = await lib.save(route(<LatLng>[base]).copyWith(name: 'B'), now: DateTime.utc(2026, 1, 2));
|
||||
server.routes[b.id] = RemoteRoute(b.id, DateTime.utc(2026, 1, 2), b); // reached the server earlier
|
||||
await lib.delete(b.id, now: DateTime.utc(2026, 1, 3));
|
||||
await lib.idle;
|
||||
expect(names(lib.missions), <String>['A'], reason: 'works offline');
|
||||
|
||||
final MissionLibrary reopened = MissionLibrary(remote: server);
|
||||
await reopened.load();
|
||||
expect(names(reopened.missions), <String>['A'], reason: 'the pending delete survives a restart');
|
||||
|
||||
server.offline = false;
|
||||
await reopened.sync();
|
||||
expect(server.routes[a.id]!.spec!.name, 'A');
|
||||
expect(server.routes[b.id]!.deleted, isTrue);
|
||||
expect(reopened.syncStatus, MissionSyncStatus.synced);
|
||||
});
|
||||
|
||||
test('a refused push adopts the newer server copy', () async {
|
||||
final _FakeRemote server = _FakeRemote('pilot@x');
|
||||
final MissionLibrary lib = MissionLibrary(remote: server);
|
||||
await lib.load();
|
||||
final WaypointMissionSpec a = await lib.save(route(<LatLng>[base]).copyWith(name: 'mine'), now: DateTime.utc(2026, 1, 1));
|
||||
// Someone else's newer save lands between our pull and our push.
|
||||
server.beforePush = () => server.routes[a.id] = live(a.copyWith(name: 'theirs', updatedAt: DateTime.utc(2026, 1, 9)));
|
||||
await lib.idle;
|
||||
expect(names(lib.missions), <String>['theirs']);
|
||||
});
|
||||
|
||||
test('signed-out routes are claimed by the first account; accounts stay apart', () async {
|
||||
final _FakeRemote server = _FakeRemote('');
|
||||
final MissionLibrary lib = MissionLibrary(remote: server);
|
||||
await lib.load();
|
||||
await lib.save(route(<LatLng>[base]).copyWith(name: 'before sign-in'));
|
||||
await lib.idle;
|
||||
expect(lib.syncStatus, MissionSyncStatus.local);
|
||||
expect(server.routes, isEmpty);
|
||||
|
||||
server.who = 'a@x';
|
||||
await lib.sync();
|
||||
expect(names(lib.missions), <String>['before sign-in']);
|
||||
expect(server.routes.values.single.spec!.name, 'before sign-in');
|
||||
|
||||
server.who = 'b@x';
|
||||
server.routes.clear(); // b's own (empty) server library
|
||||
await lib.sync();
|
||||
expect(lib.missions, isEmpty, reason: "b never sees a's routes");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
group('WaypointEditor', () {
|
||||
@@ -647,3 +798,46 @@ void main() {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// An in-memory API Server route store, with the server's last-writer-wins rule.
|
||||
class _FakeRemote implements MissionRemote {
|
||||
_FakeRemote(this.who);
|
||||
|
||||
String who;
|
||||
bool offline = false;
|
||||
void Function()? beforePush;
|
||||
final Map<String, RemoteRoute> routes = <String, RemoteRoute>{};
|
||||
|
||||
@override
|
||||
String get account => who;
|
||||
|
||||
void _check() {
|
||||
if (offline) throw const MissionSyncException(0, <String, dynamic>{'error': 'offline'});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<RemoteRoute>> pull() async {
|
||||
_check();
|
||||
return routes.values.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<RemoteRoute?> push(WaypointMissionSpec m) async {
|
||||
_check();
|
||||
beforePush?.call();
|
||||
final RemoteRoute? have = routes[m.id];
|
||||
if (have != null && have.updatedAt.isAfter(m.updatedAt!)) return have;
|
||||
routes[m.id] = RemoteRoute(m.id, m.updatedAt!, m);
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<RemoteRoute?> pushDelete(String id, DateTime at) async {
|
||||
_check();
|
||||
final RemoteRoute? have = routes[id];
|
||||
if (have == null) return null;
|
||||
if (have.updatedAt.isAfter(at)) return have;
|
||||
routes[id] = RemoteRoute(id, at, null);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -527,6 +527,19 @@ func (a *App) handleExportLogbook(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = io.Copy(w, resp.Body)
|
||||
}
|
||||
|
||||
/* ---------- Routes ---------- */
|
||||
|
||||
// GET /bff/routes → API Server /api/routes (preserves ?mine=1).
|
||||
func (a *App) handleListRoutes(w http.ResponseWriter, r *http.Request) {
|
||||
target := a.apiBaseFor(r) + "/api/routes"
|
||||
if r.URL.RawQuery != "" {
|
||||
target += "?" + r.URL.RawQuery
|
||||
}
|
||||
req, _ := http.NewRequest(http.MethodGet, target, nil)
|
||||
req.Header.Set("Authorization", tokenOf(r))
|
||||
a.doRelay(w, req)
|
||||
}
|
||||
|
||||
/* ---------- Documents ---------- */
|
||||
|
||||
// GET /bff/documents → API Server /api/documents (preserves ?expiring=N).
|
||||
|
||||
Vendored
+1
-1
@@ -1 +1 @@
|
||||
:root,[data-theme=light]{--navy-950: #0B1730;--navy-900: #0F1E3D;--navy-800: #1B2E52;--navy-700: #26406E;--blue-50: #EAF1FE;--blue-100: #D6E3FD;--blue-200: #B4CDFA;--blue-300: #8FB4F6;--blue-400: #5B93F5;--blue-500: #3D7BF0;--blue-600: #2B62CC;--blue-700: #1F4CA0;--slate-0: #FFFFFF;--slate-50: #F6F7F9;--slate-100: #EEF0F3;--slate-150: #E6E9EE;--slate-200: #DCE0E7;--slate-300: #C5CCD7;--slate-400: #97A1B0;--slate-500: #6B7688;--slate-600: #4C5566;--slate-700: #333B4A;--slate-800: #1E2635;--slate-900: #131A28;--slate-950: #0B111C;--steel: #5A6B85;--green-500: #1F8A5B;--green-100: #DCF1E7;--green-600:#177049;--amber-500: #D9852B;--amber-100: #FBEBD5;--amber-600:#B86C1B;--red-500: #D64545;--red-100: #FBE0E0;--red-600: #B83232;--bg-app: var(--slate-100);--bg-subtle: var(--slate-50);--surface: var(--slate-0);--surface-2: var(--slate-50);--surface-inset: var(--slate-100);--border: var(--slate-200);--border-strong: var(--slate-300);--border-subtle: var(--slate-150);--text-primary: var(--navy-900);--text-secondary:var(--steel);--text-tertiary: var(--slate-400);--text-inverse: var(--slate-0);--text-on-accent:#FFFFFF;--accent: var(--blue-500);--accent-hover: var(--blue-600);--accent-active: var(--blue-700);--accent-soft: var(--blue-50);--accent-soft-fg:var(--blue-700);--focus-ring: color-mix(in srgb, var(--blue-500) 45%, transparent);--success: var(--green-500);--success-soft: var(--green-100);--success-fg: var(--green-600);--warning: var(--amber-500);--warning-soft: var(--amber-100);--warning-fg: var(--amber-600);--danger: var(--red-500);--danger-soft: var(--red-100);--danger-fg: var(--red-600);--overlay: color-mix(in srgb, var(--navy-950) 55%, transparent);--font-sans: "Space Grotesk", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;--font-mono: "Space Mono", ui-monospace, "SF Mono", "JetBrains Mono", monospace;--radius-xs: 4px;--radius-sm: 6px;--radius-md: 10px;--radius-lg: 14px;--radius-xl: 20px;--radius-pill: 999px;--shadow-xs: 0 1px 2px rgba(15,30,61,.06);--shadow-sm: 0 1px 2px rgba(15,30,61,.06), 0 1px 3px rgba(15,30,61,.04);--shadow-md: 0 2px 4px rgba(15,30,61,.06), 0 6px 16px rgba(15,30,61,.08);--shadow-lg: 0 8px 24px rgba(15,30,61,.1), 0 2px 6px rgba(15,30,61,.06);--ease-standard: cubic-bezier(.4, 0, .2, 1);--ease-out: cubic-bezier(.16, 1, .3, 1);--dur-fast: .12s;--dur-base: .2s;color-scheme:light}[data-theme=dark]{--bg-app: var(--navy-950);--bg-subtle: var(--slate-950);--surface: #10203F;--surface-2: #142748;--surface-inset: var(--navy-950);--border: color-mix(in srgb, #FFFFFF 10%, transparent);--border-strong: color-mix(in srgb, #FFFFFF 18%, transparent);--border-subtle: color-mix(in srgb, #FFFFFF 6%, transparent);--text-primary: #F4F7FC;--text-secondary:#8FA0BE;--text-tertiary: #5E6E8C;--text-inverse: var(--navy-900);--text-on-accent:#FFFFFF;--accent: var(--blue-400);--accent-hover: var(--blue-300);--accent-active: var(--blue-200);--accent-soft: color-mix(in srgb, var(--blue-500) 18%, transparent);--accent-soft-fg:var(--blue-300);--focus-ring: color-mix(in srgb, var(--blue-400) 55%, transparent);--success:var(--green-500);--success-soft: color-mix(in srgb, var(--green-500) 22%, transparent);--success-fg:#5FD3A0;--warning:var(--amber-500);--warning-soft: color-mix(in srgb, var(--amber-500) 22%, transparent);--warning-fg:#F0B26A;--danger: var(--red-500);--danger-soft: color-mix(in srgb, var(--red-500) 22%, transparent);--danger-fg: #F08A8A;--overlay: color-mix(in srgb, #000000 62%, transparent);--shadow-xs: 0 1px 2px rgba(0,0,0,.35);--shadow-sm: 0 1px 3px rgba(0,0,0,.4);--shadow-md: 0 4px 12px rgba(0,0,0,.45);--shadow-lg: 0 12px 32px rgba(0,0,0,.5);color-scheme:dark}*,:before,:after{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-Line truncated
|
||||
:root,[data-theme=light]{--navy-950: #0B1730;--navy-900: #0F1E3D;--navy-800: #1B2E52;--navy-700: #26406E;--blue-50: #EAF1FE;--blue-100: #D6E3FD;--blue-200: #B4CDFA;--blue-300: #8FB4F6;--blue-400: #5B93F5;--blue-500: #3D7BF0;--blue-600: #2B62CC;--blue-700: #1F4CA0;--slate-0: #FFFFFF;--slate-50: #F6F7F9;--slate-100: #EEF0F3;--slate-150: #E6E9EE;--slate-200: #DCE0E7;--slate-300: #C5CCD7;--slate-400: #97A1B0;--slate-500: #6B7688;--slate-600: #4C5566;--slate-700: #333B4A;--slate-800: #1E2635;--slate-900: #131A28;--slate-950: #0B111C;--steel: #5A6B85;--green-500: #1F8A5B;--green-100: #DCF1E7;--green-600:#177049;--amber-500: #D9852B;--amber-100: #FBEBD5;--amber-600:#B86C1B;--red-500: #D64545;--red-100: #FBE0E0;--red-600: #B83232;--bg-app: var(--slate-100);--bg-subtle: var(--slate-50);--surface: var(--slate-0);--surface-2: var(--slate-50);--surface-inset: var(--slate-100);--border: var(--slate-200);--border-strong: var(--slate-300);--border-subtle: var(--slate-150);--text-primary: var(--navy-900);--text-secondary:var(--steel);--text-tertiary: var(--slate-400);--text-inverse: var(--slate-0);--text-on-accent:#FFFFFF;--accent: var(--blue-500);--accent-hover: var(--blue-600);--accent-active: var(--blue-700);--accent-soft: var(--blue-50);--accent-soft-fg:var(--blue-700);--focus-ring: color-mix(in srgb, var(--blue-500) 45%, transparent);--success: var(--green-500);--success-soft: var(--green-100);--success-fg: var(--green-600);--warning: var(--amber-500);--warning-soft: var(--amber-100);--warning-fg: var(--amber-600);--danger: var(--red-500);--danger-soft: var(--red-100);--danger-fg: var(--red-600);--overlay: color-mix(in srgb, var(--navy-950) 55%, transparent);--font-sans: "Space Grotesk", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;--font-mono: "Space Mono", ui-monospace, "SF Mono", "JetBrains Mono", monospace;--radius-xs: 4px;--radius-sm: 6px;--radius-md: 10px;--radius-lg: 14px;--radius-xl: 20px;--radius-pill: 999px;--shadow-xs: 0 1px 2px rgba(15,30,61,.06);--shadow-sm: 0 1px 2px rgba(15,30,61,.06), 0 1px 3px rgba(15,30,61,.04);--shadow-md: 0 2px 4px rgba(15,30,61,.06), 0 6px 16px rgba(15,30,61,.08);--shadow-lg: 0 8px 24px rgba(15,30,61,.1), 0 2px 6px rgba(15,30,61,.06);--ease-standard: cubic-bezier(.4, 0, .2, 1);--ease-out: cubic-bezier(.16, 1, .3, 1);--dur-fast: .12s;--dur-base: .2s;color-scheme:light}[data-theme=dark]{--bg-app: var(--navy-950);--bg-subtle: var(--slate-950);--surface: #10203F;--surface-2: #142748;--surface-inset: var(--navy-950);--border: color-mix(in srgb, #FFFFFF 10%, transparent);--border-strong: color-mix(in srgb, #FFFFFF 18%, transparent);--border-subtle: color-mix(in srgb, #FFFFFF 6%, transparent);--text-primary: #F4F7FC;--text-secondary:#8FA0BE;--text-tertiary: #5E6E8C;--text-inverse: var(--navy-900);--text-on-accent:#FFFFFF;--accent: var(--blue-400);--accent-hover: var(--blue-300);--accent-active: var(--blue-200);--accent-soft: color-mix(in srgb, var(--blue-500) 18%, transparent);--accent-soft-fg:var(--blue-300);--focus-ring: color-mix(in srgb, var(--blue-400) 55%, transparent);--success:var(--green-500);--success-soft: color-mix(in srgb, var(--green-500) 22%, transparent);--success-fg:#5FD3A0;--warning:var(--amber-500);--warning-soft: color-mix(in srgb, var(--amber-500) 22%, transparent);--warning-fg:#F0B26A;--danger: var(--red-500);--danger-soft: color-mix(in srgb, var(--red-500) 22%, transparent);--danger-fg: #F08A8A;--overlay: color-mix(in srgb, #000000 62%, transparent);--shadow-xs: 0 1px 2px rgba(0,0,0,.35);--shadow-sm: 0 1px 3px rgba(0,0,0,.4);--shadow-md: 0 4px 12px rgba(0,0,0,.45);--shadow-lg: 0 12px 32px rgba(0,0,0,.5);color-scheme:dark}*,:before,:after{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-Line truncated
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
(function(){const i=document.createElement("link").relList;if(i&&i.supports&&i.supports("modulepreload"))return;for(const u of document.querySelectorAll('link[rel="modulepreload"]'))l(u);new MutationObserver(u=>{for(const f of u)if(f.type==="childList")for(const h of f.addedNodes)h.tagName==="LINK"&&h.rel==="modulepreload"&&l(h)}).observe(document,{childList:!0,subtree:!0});function s(u){const f={};return u.integrity&&(f.integrity=u.integrity),u.referrerPolicy&&(f.referrerPolicy=u.referrerPolicy),u.crossOrigin==="use-credentials"?f.credentials="include":u.crossOrigin==="anonymous"?f.credentials="omit":f.credentials="same-origin",f}function l(u){if(u.ep)return;u.ep=!0;const f=s(u);fetch(u.href,f)}})();/**
|
||||
* @vue/shared v3.5.39
|
||||
* (c) 2018-present Yuxi (Evan) You and Vue contributors
|
||||
* @license MIT
|
||||
**/function Xr(t){const i=Object.create(null);for(const s of t.split(","))i[s]=1;return s=>s in i}const vt={},es=[],ci=()=>{},zu=()=>!1,ja=t=>t.charCodeAt(0)===111&&t.charCodeAt(1)===110&&(t.charCodeAt(2)>122||t.charCodeAt(2)<97),Wa=t=>t.startsWith("onUpdate:"),Kt=Object.assign,Qr=(t,i)=>{const s=t.indexOf(i);s>-1&&t.splice(s,1)},pd=Object.prototype.hasOwnProperty,ct=(t,i)=>pd.call(t,i),Ne=Array.isArray,ts=t=>ea(t)==="[object Map]",ls=t=>ea(t)==="[object Set]",Al=t=>ea(t)==="[object Date]",je=t=>typeof t=="function",Tt=t=>typeof t=="string",Qn=t=>typeof t=="symbol",dt=t=>t!==null&&typeof t=="object",$u=t=>(dt(t)||je(t))&&je(t.then)&&je(t.catch),Iu=Object.prototype.toString,ea=t=>Iu.call(t),md=t=>ea(t).slice(8,-1),Nu=t=>ea(t)==="[object Object]",el=t=>Tt(t)&&t!=="NaN"&&t[0]!=="-"&&""+parseInt(t,10)===t,Rs=Xr(",key,ref,ref_for,ref_key,onVnodeBeforeMount,onVnodeMounted,onVnodeBeforeUpdate,onVnodeUpdated,onVnodeBeforeUnmount,onVnodeUnmounted"),Ka=t=>{const i=Object.create(null);return(s=>i[s]||(i[s]=t(s)))},gd=/-\w/g,Jn=Ka(t=>t.replace(gd,i=>i.slice(1).toUpperCase())),vd=/\B([A-Z])/g,to=Ka(t=>t.replace(vd,"-$1").toLowerCase()),Fu=Ka(t=>t.charAt(0).toUpperCase()+t.slice(1)),xr=Ka(t=>t?`on${Fu(t)}`:""),ui=(t,i)=>!Object.is(t,i),Ea=(t,...i)=>{for(let s=0;s<t.length;s++)t[s](...i)},Du=(t,i,s,l=!1)=>{Object.defineProperty(t,i,{configurable:!0,enumerable:!1,writable:l,value:s})},Ga=t=>{const i=parseFloat(t);return isNaN(i)?t:i},_d=t=>{const i=Tt(t)?Number(t):NaN;return isNaN(i)?t:i};let Ml;const qa=()=>Ml||(Ml=typeof globalThis<"u"?globalThis:typeof self<"u"?self:typeof window<"u"?window:typeof global<"u"?global:{});function Eo(t){if(Ne(t)){const i={};for(let s=0;s<t.length;s++){const l=t[s],u=Tt(l)?wd(l):Eo(l);if(u)for(const f in u)i[f]=u[f]}return i}else if(Tt(t)||dt(t))return t}const bd=/;(?![^(]*\))/g,yd=/:([^]+)/,xd=/\/\*[^]*?\*\//g;function wd(t){const i={};return t.replace(xd,"").split(bd).forEach(s=>{if(s){const l=s.split(yd);l.length>1&&(i[l[0].trim()]=l[1].trim())}}),i}function Pe(t){let i="";if(Tt(t))i=t;else if(Ne(t))for(let s=0;s<t.length;s++){const l=Pe(t[s]);l&&(i+=l+" ")}else if(dt(t))for(const s in t)t[s]&&(i+=s+" ");return i.trim()}const kd="itemscope,allowfullscreen,formnovalidate,ismap,nomodule,novalidate,readonly",Sd=Xr(kd);function Ru(t){return!!t||t===""}function Td(t,i){if(t.length!==i.length)return!1;let s=!0;for(let l=0;s&&l<t.length;l++)s=Xi(t[l],i[l]);return s}function Xi(t,i){if(t===i)return!0;let s=Al(t),l=Al(i);if(s||l)return s&&l?t.getTime()===i.getTime():!1;if(s=Qn(t),l=Qn(i),s||l)return t===i;if(s=Ne(t),l=Ne(i),s||l)return s&&l?Td(t,i):!1;if(s=dt(t),l=dt(i),s||l){if(!s||!l)return!1;const u=Object.keys(t).length,f=Object.keys(i).length;if(u!==f)return!1;for(const h in t){const _=t.hasOwnProperty(h),b=i.hasOwnProperty(h);if(_&&!b||!_&&b||!Xi(t[h],i[h]))return!1}}return String(t)===String(i)}function tl(t,i){return t.findIndex(s=>Xi(s,i))}const Bu=t=>!!(t&&t.__v_isRef===!0),x=t=>Tt(t)?t:t==null?"":Ne(t)||dt(t)&&(t.toString===Iu||!je(t.toString))?Bu(t)?x(t.value):JSON.stringify(t,Uu,2):String(t),Uu=(t,i)=>Bu(i)?Uu(t,i.value):ts(i)?{[`Map(${i.size})`]:[...i.entries()].reduce((s,[l,u],f)=>(s[wr(l,f)+" =>"]=u,s),{})}:ls(i)?{[`Set(${i.size})`]:[...i.values()].map(s=>wr(s))}:Qn(i)?wr(i):dt(i)&&!Ne(i)&&!Nu(i)?String(i):i,wr=(t,i="")=>{var s;return Qn(t)?`Symbol(${(s=t.description)!=null?s:i})`:t};/**
|
||||
* @vue/reactivity v3.5.39
|
||||
* (c) 2018-present Yuxi (Evan) You and Vue contributors
|
||||
* @license MIT
|
||||
**/let Yt;class Pd{constructor(i=!1){this.detached=i,this._active=!0,this._on=0,this.effects=[],this.cleanups=[],this._isPaused=!1,this._warnOnRun=!0,this.__v_skip=!0,!i&&Yt&&(Yt.active?(this.parent=Yt,this.index=(Yt.scopes||(Yt.scopes=[])).push(this)-1):(this._active=!1,this._warnOnRun=!1))}get active(){return this._active}pause(){if(this._active){this._isPaused=!0;let i,s;if(this.scopes)for(i=0,s=this.scopes.length;i<s;i++)this.scopes[i].pause();for(i=0,s=this.effects.length;i<s;i++)this.effects[i].pause()}}resume(){if(this._active&&this._isPaused){this._isPaused=!1;let i,s;if(this.scopes)for(i=0,s=this.scopes.length;i<s;i++)this.scopes[i].resume();for(i=0,s=this.effects.length;i<s;i++)this.effects[i].resume()}}run(i){if(this._active){const s=Yt;try{return Yt=this,i()}finally{Yt=s}}}on(){++this._on===1&&(this.prevScope=Yt,Yt=this)}off(){if(this._on>0&&--this._on===0){if(Yt===this)Yt=this.prevScope;else{let i=Yt;for(;i;){if(i.prevScope===this){i.prevScope=this.prevScope;break}i=i.prevScope}}this.prevScope=void 0}}stop(i){if(this._active){this._active=!1;let s,l;for(s=0,l=this.effects.length;s<l;s++)this.effects[s].stop();for(this.effects.length=0,s=0,l=this.cleanups.length;s<l;s++)this.cleanups[s]();if(this.cleanups.length=0,this.scopes){for(s=0,l=this.scopes.length;s<l;s++)this.scopes[s].stop(!0);this.scopes.length=0}if(!this.detached&&this.parent&&!i){const u=this.parent.scopes.pop();u&&u!==this&&(this.parent.scopes[this.index]=u,u.index=this.index)}this.parent=void 0}}}function Cd(){return Yt}let xt;const kr=new WeakSet;class Vu{constructor(i){this.fn=i,this.deps=void 0,this.depsTail=void 0,this.flags=5,this.next=void 0,this.cleanup=void 0,this.scheduler=void 0,Yt&&(Yt.active?Yt.effects.push(this):this.flags&=-2)}pause(){this.flags|=64}resume(){this.flags&64&&(this.flags&=-65,kr.has(this)&&(kr.delete(this),this.trigger()))}notify(){this.flags&2&&!(this.flags&32)||this.flags&8||Hu(this)}run(){if(!(this.flags&1))return this.fn();this.flags|=2,El(this),ju(this);const i=xt,s=Xn;xt=this,Xn=!0;try{return this.fn()}finally{Wu(this),xt=i,Xn=s,this.flags&=-3}}stop(){if(this.flags&1){for(let i=this.deps;i;i=i.nextDep)ol(i);this.deps=this.depsTail=void 0,El(this),this.onStop&&this.onStop(),this.flags&=-2}}trigger(){this.flags&64?kr.add(this):this.scheduler?this.scheduler():this.runIfDirty()}runIfDirty(){Ir(this)&&this.run()}get dirty(){return Ir(this)}}let Zu=0,Bs,Us;function Hu(t,i=!1){if(t.flags|=8,i){t.next=Us,Us=t;return}t.next=Bs,Bs=t}function nl(){Zu++}function il(){if(--Zu>0)return;if(Us){let i=Us;for(Us=void 0;i;){const s=i.next;i.next=void 0,i.flags&=-9,i=s}}let t;for(;Bs;){let i=Bs;for(Bs=void 0;i;){const s=i.next;if(i.next=void 0,i.flags&=-9,i.flags&1)try{i.trigger()}catch(l){t||(t=l)}i=s}}if(t)throw t}function ju(t){for(let i=t.deps;i;i=i.nextDep)i.version=-1,i.prevActiveLink=i.dep.activeLink,i.dep.activeLink=i}function Wu(t){let i,s=t.depsTail,l=s;for(;l;){const u=l.prevDep;l.version===-1?(l===s&&(s=u),ol(l),Ld(l)):i=l,l.dep.activeLink=l.prevActiveLink,l.prevActiveLink=void 0,l=u}t.deps=i,t.depsTail=s}function Ir(t){for(let i=t.deps;i;i=i.nextDep)if(i.dep.version!==i.version||i.dep.computed&&(Ku(i.dep.computed)||i.dep.version!==i.version))return!0;return!!t._dirty}function Ku(t){if(t.flags&4&&!(t.flags&16)||(t.flags&=-17,t.globalVersion===Ws)||(t.globalVersion=Ws,!t.isSSR&&t.flags&128&&(!t.deps&&!t._dirty||!Ir(t))))return;t.flags|=2;const i=t.dep,s=xt,l=Xn;xt=t,Xn=!0;try{ju(t);const u=t.fn(t._value);(i.version===0||ui(u,t._value))&&(t.flags|=128,t._value=u,i.version++)}catch(u){throw i.version++,u}finally{xt=s,Xn=l,Wu(t),t.flags&=-3}}function ol(t,i=!1){const{dep:s,prevSub:l,nextSub:u}=t;if(l&&(l.nextSub=u,t.prevSub=void 0),u&&(u.prevSub=l,t.nextSub=void 0),s.subs===t&&(s.subs=l,!l&&s.computed)){s.computed.flags&=-5;for(let f=s.computed.deps;f;f=f.nextDep)ol(f,!0)}!i&&!--s.sc&&s.map&&s.map.delete(s.key)}function Ld(t){const{prevDep:i,nextDep:s}=t;i&&(i.nextDep=s,t.prevDep=void 0),s&&(s.prevDep=i,t.nextDep=void 0)}let Xn=!0;const Gu=[];function di(){Gu.push(Xn),Xn=!1}function fi(){const t=Gu.pop();Xn=t===void 0?!0:t}function El(t){const{cleanup:i}=t;if(t.cleanup=void 0,i){const s=xt;xt=void 0;try{i()}finally{xt=s}}}let Ws=0;class Ad{constructor(i,s){this.sub=i,this.dep=s,this.version=s.version,this.nextDep=this.prevDep=this.nextSub=this.prevSub=this.prevActiveLink=void 0}}class sl{constructor(i){this.computed=i,this.version=0,this.activeLink=void 0,this.subs=void 0,this.map=void 0,this.key=void 0,this.sc=0,this.__v_skip=!0}track(i){if(!xt||!Xn||xt===this.computed)return;let s=this.activeLink;if(s===void 0||s.sub!==xt)s=this.activeLink=new Ad(xt,this),xt.deps?(s.prevDep=xt.depsTail,xt.depsTail.nextDep=s,xt.depsTail=s):xt.deps=xt.depsTail=s,qu(s);else if(s.version===-1&&(s.version=this.version,s.nextDep)){const l=s.nextDep;l.prevDep=s.prevDep,s.prevDep&&(s.prevDep.nextDep=l),s.prevDep=xt.depsTail,s.nextDep=void 0,xt.depsTail.nextDep=s,xt.depsTail=s,xt.deps===s&&(xt.deps=l)}return s}trigger(i){this.versiLine truncated
|
||||
* @vue/runtime-core v3.5.39
|
||||
* (c) 2018-present Yuxi (Evan) You and Vue contributors
|
||||
* @license MIT
|
||||
**/function ta(t,i,s,l){try{return l?t(...l):t()}catch(u){Ja(u,i,s)}}function Hn(t,i,s,l){if(je(t)){const u=ta(t,i,s,l);return u&&$u(u)&&u.catch(f=>{Ja(f,i,s)}),u}if(Ne(t)){const u=[];for(let f=0;f<t.length;f++)u.push(Hn(t[f],i,s,l));return u}}function Ja(t,i,s,l=!0){const u=i?i.vnode:null,{errorHandler:f,throwUnhandledErrorInProduction:h}=i&&i.appContext.config||vt;if(i){let _=i.parent;const b=i.proxy,C=`https://vuejs.org/error-reference/#runtime-${s}`;for(;_;){const T=_.ec;if(T){for(let M=0;M<T.length;M++)if(T[M](t,b,C)===!1)return}_=_.parent}if(f){di(),ta(f,null,10,[t,b,C]),fi();return}}ef(t,s,u,l,h)}function ef(t,i,s,l=!0,u=!1){if(u)throw t;console.error(t)}const fn=[];let ai=-1;const ns=[];let Ji=null,Qo=0;const ic=Promise.resolve();let $a=null;function oc(t){const i=$a||ic;return t?i.then(this?t.bind(this):t):i}function tf(t){let i=ai+1,s=fn.length;for(;i<s;){const l=i+s>>>1,u=fn[l],f=Gs(u);f<t||f===t&&u.flags&2?i=l+1:s=l}return i}function ul(t){if(!(t.flags&1)){const i=Gs(t),s=fn[fn.length-1];!s||!(t.flags&2)&&i>=Gs(s)?fn.push(t):fn.splice(tf(i),0,t),t.flags|=1,sc()}}function sc(){$a||($a=ic.then(rc))}function nf(t){Ne(t)?ns.push(...t):Ji&&t.id===-1?Ji.splice(Qo+1,0,t):t.flags&1||(ns.push(t),t.flags|=1),sc()}function zl(t,i,s=ai+1){for(;s<fn.length;s++){const l=fn[s];if(l&&l.flags&2){if(t&&l.id!==t.uid)continue;fn.splice(s,1),s--,l.flags&4&&(l.flags&=-2),l(),l.flags&4||(l.flags&=-2)}}}function ac(t){if(ns.length){const i=[...new Set(ns)].sort((s,l)=>Gs(s)-Gs(l));if(ns.length=0,Ji){Ji.push(...i);return}for(Ji=i,Qo=0;Qo<Ji.length;Qo++){const s=Ji[Qo];s.flags&4&&(s.flags&=-2),s.flags&8||s(),s.flags&=-2}Ji=null,Qo=0}}const Gs=t=>t.id==null?t.flags&2?-1:1/0:t.id;function rc(t){try{for(ai=0;ai<fn.length;ai++){const i=fn[ai];i&&!(i.flags&8)&&(i.flags&4&&(i.flags&=-2),ta(i,i.i,i.i?15:14),i.flags&4||(i.flags&=-2))}}finally{for(;ai<fn.length;ai++){const i=fn[ai];i&&(i.flags&=-2)}ai=-1,fn.length=0,ac(),$a=null,(fn.length||ns.length)&&rc()}}let an=null,lc=null;function Ia(t){const i=an;return an=t,lc=t&&t.type.__scopeId||null,i}function ye(t,i=an,s){if(!i||t._n)return t;const l=(...u)=>{l._d&&Da(-1);const f=Ia(i);let h;try{h=t(...u)}finally{Ia(f),l._d&&Da(1)}return h};return l._n=!0,l._c=!0,l._d=!0,l}function ie(t,i){if(an===null)return t;const s=nr(an),l=t.dirs||(t.dirs=[]);for(let u=0;u<i.length;u++){let[f,h,_,b=vt]=i[u];f&&(je(f)&&(f={mounted:f,updated:f}),f.deep&&Ai(h),l.push({dir:f,instance:s,value:h,oldValue:void 0,arg:_,modifiers:b}))}return t}function wo(t,i,s,l){const u=t.dirs,f=i&&i.dirs;for(let h=0;h<u.length;h++){const _=u[h];f&&(_.oldValue=f[h].value);let b=_.dir[l];b&&(di(),Hn(b,s,8,[t.el,_,t,i]),fi())}}function uc(t,i){if(hn){let s=hn.provides;const l=hn.parent&&hn.parent.provides;l===s&&(s=hn.provides=Object.create(l)),s[t]=i}}function Vs(t,i,s=!1){const l=Uc();if(l||os){let u=os?os._context.provides:l?l.parent==null||l.ce?l.vnode.appContext&&l.vnode.appContext.provides:l.parent.provides:void 0;if(u&&t in u)return u[t];if(arguments.length>1)return s&&je(i)?i.call(l&&l.proxy):i}}const of=Symbol.for("v-scx"),sf=()=>Vs(of);function Bt(t,i,s){return cc(t,i,s)}function cc(t,i,s=vt){const{immediate:l,deep:u,flush:f,once:h}=s,_=Kt({},s),b=i&&l||!i&&f!=="post";let C;if(Xs){if(f==="sync"){const j=sf();C=j.__watcherHandles||(j.__watcherHandles=[])}else if(!b){const j=()=>{};return j.stop=ci,j.resume=ci,j.pause=ci,j}}const T=hn;_.call=(j,G,N)=>Hn(j,T,G,N);let M=!1;f==="post"?_.scheduler=j=>{dn(j,T&&T.suspense)}:f!=="sync"&&(M=!0,_.scheduler=(j,G)=>{G?j():ul(j)}),_.augmentJob=j=>{i&&(j.flags|=4),M&&(j.flags|=2,T&&(j.id=T.uid,j.i=T))};const H=Qd(t,i,_);return Xs&&(C?C.push(H):b&&H()),H}function af(t,i,s){const l=this.proxy,u=Tt(t)?t.includes(".")?dc(l,t):()=>l[t]:t.bind(l,l);let f;je(i)?f=i:(f=i.handler,s=i);const h=na(this),_=cc(u,f.bind(l),s);return h(),_}function dc(t,i){const s=i.split(".");return()=>{let l=t;for(let u=0;u<s.length&&l;u++)l=l[s[u]];return l}}const Yi=new WeakMap,fc=Symbol("_vte"),hc=t=>t.__isTeleport,Co=t=>t&&(t.disabled||t.disabled===""),rf=t=>t&&(t.defer||t.defer===""),$l=t=>typeof SVGElement<"u"&&t instanceof SVGElement,Il=t=>typeof MathMLElement=="function"&&t instanceof MathMLElement,Br=(t,i)=>{const s=t&&t.to;return Tt(s)?i?i(s):null:s},lf={name:"Teleport",__isTeleport:!0,process(t,i,s,l,u,f,h,_,b,C){const{mc:T,pc:M,pbc:H,o:{insert:j,querySelector:G,createText:N,createComment:U,parentNode:R}}=C,oe=Co(i.props);let{dynamicChildren:we}=i;const ue=(Le,Ze,he)=>{Le.shapeFlag&16&&T(Le.children,Ze,he,u,f,h,_,b)},ke=(Le=i)=>{const Ze=Co(Le.props),he=Le.target=Br(Le.props,G),ee=Ur(he,Le,N,j);he&&(h!=="svg"&&$l(he)?h="svg":h!=="mathml"&&Il(he)&&(h="mathml"),u&&u.isCE&&(u.ce._teleportTargets||(u.ce._teleportTargets=new Set)).add(he),Ze||(ue(Le,he,ee),Is(Le,!1)))},Ce=Le=>{const Ze=()=>{if(Yi.get(Le)===Ze){if(Yi.delete(Le),Co(Le.props)){const he=R(Le.el)||s;ue(Le,he,Le.anchor),Is(Le,!0)}ke(Le)}};Yi.set(Le,Ze),dn(Ze,f)};if(t==null){const Le=i.el=N(""),Ze=i.anchor=N("");if(j(Le,s,l),j(Ze,s,l),rf(i
Line truncated
|
||||
* @vue/runtime-dom v3.5.39
|
||||
* (c) 2018-present Yuxi (Evan) You and Vue contributors
|
||||
* @license MIT
|
||||
**/let Gr;const ql=typeof window<"u"&&window.trustedTypes;if(ql)try{Gr=ql.createPolicy("vue",{createHTML:t=>t})}catch{}const Hc=Gr?t=>Gr.createHTML(t):t=>t,fh="http://www.w3.org/2000/svg",hh="http://www.w3.org/1998/Math/MathML",Pi=typeof document<"u"?document:null,Yl=Pi&&Pi.createElement("template"),ph={insert:(t,i,s)=>{i.insertBefore(t,s||null)},remove:t=>{const i=t.parentNode;i&&i.removeChild(t)},createElement:(t,i,s,l)=>{const u=i==="svg"?Pi.createElementNS(fh,t):i==="mathml"?Pi.createElementNS(hh,t):s?Pi.createElement(t,{is:s}):Pi.createElement(t);return t==="select"&&l&&l.multiple!=null&&u.setAttribute("multiple",l.multiple),u},createText:t=>Pi.createTextNode(t),createComment:t=>Pi.createComment(t),setText:(t,i)=>{t.nodeValue=i},setElementText:(t,i)=>{t.textContent=i},parentNode:t=>t.parentNode,nextSibling:t=>t.nextSibling,querySelector:t=>Pi.querySelector(t),setScopeId(t,i){t.setAttribute(i,"")},insertStaticContent(t,i,s,l,u,f){const h=s?s.previousSibling:i.lastChild;if(u&&(u===f||u.nextSibling))for(;i.insertBefore(u.cloneNode(!0),s),!(u===f||!(u=u.nextSibling)););else{Yl.innerHTML=Hc(l==="svg"?`<svg>${t}</svg>`:l==="mathml"?`<math>${t}</math>`:t);const _=Yl.content;if(l==="svg"||l==="mathml"){const b=_.firstChild;for(;b.firstChild;)_.appendChild(b.firstChild);_.removeChild(b)}i.insertBefore(_,s)}return[h?h.nextSibling:i.firstChild,s?s.previousSibling:i.lastChild]}},Gi="transition",zs="animation",Qs=Symbol("_vtc"),jc={name:String,type:String,css:{type:Boolean,default:!0},duration:[String,Number,Object],enterFromClass:String,enterActiveClass:String,enterToClass:String,appearFromClass:String,appearActiveClass:String,appearToClass:String,leaveFromClass:String,leaveActiveClass:String,leaveToClass:String},mh=Kt({},pc,jc),gh=t=>(t.displayName="Transition",t.props=mh,t),vh=gh((t,{slots:i})=>ch(hf,_h(t),i)),So=(t,i=[])=>{Ne(t)?t.forEach(s=>s(...i)):t&&t(...i)},Jl=t=>t?Ne(t)?t.some(i=>i.length>1):t.length>1:!1;function _h(t){const i={};for(const O in t)O in jc||(i[O]=t[O]);if(t.css===!1)return i;const{name:s="v",type:l,duration:u,enterFromClass:f=`${s}-enter-from`,enterActiveClass:h=`${s}-enter-active`,enterToClass:_=`${s}-enter-to`,appearFromClass:b=f,appearActiveClass:C=h,appearToClass:T=_,leaveFromClass:M=`${s}-leave-from`,leaveActiveClass:H=`${s}-leave-active`,leaveToClass:j=`${s}-leave-to`}=t,G=bh(u),N=G&&G[0],U=G&&G[1],{onBeforeEnter:R,onEnter:oe,onEnterCancelled:we,onLeave:ue,onLeaveCancelled:ke,onBeforeAppear:Ce=R,onAppear:Le=oe,onAppearCancelled:Ze=we}=i,he=(O,F,$,Ye)=>{O._enterCancelled=Ye,To(O,F?T:_),To(O,F?C:h),$&&$()},ee=(O,F)=>{O._isLeaving=!1,To(O,M),To(O,j),To(O,H),F&&F()},V=O=>(F,$)=>{const Ye=O?Le:oe,Se=()=>he(F,O,$);So(Ye,[F,Se]),Xl(()=>{To(F,O?b:f),Ti(F,O?T:_),Jl(Ye)||Ql(F,l,N,Se)})};return Kt(i,{onBeforeEnter(O){So(R,[O]),Ti(O,f),Ti(O,h)},onBeforeAppear(O){So(Ce,[O]),Ti(O,b),Ti(O,C)},onEnter:V(!1),onAppear:V(!0),onLeave(O,F){O._isLeaving=!0;const $=()=>ee(O,F);Ti(O,M),O._enterCancelled?(Ti(O,H),nu(O)):(nu(O),Ti(O,H)),Xl(()=>{O._isLeaving&&(To(O,M),Ti(O,j),Jl(ue)||Ql(O,l,U,$))}),So(ue,[O,$])},onEnterCancelled(O){he(O,!1,void 0,!0),So(we,[O])},onAppearCancelled(O){he(O,!0,void 0,!0),So(Ze,[O])},onLeaveCancelled(O){ee(O),So(ke,[O])}})}function bh(t){if(t==null)return null;if(dt(t))return[Mr(t.enter),Mr(t.leave)];{const i=Mr(t);return[i,i]}}function Mr(t){return _d(t)}function Ti(t,i){i.split(/\s+/).forEach(s=>s&&t.classList.add(s)),(t[Qs]||(t[Qs]=new Set)).add(i)}function To(t,i){i.split(/\s+/).forEach(l=>l&&t.classList.remove(l));const s=t[Qs];s&&(s.delete(i),s.size||(t[Qs]=void 0))}function Xl(t){requestAnimationFrame(()=>{requestAnimationFrame(t)})}let yh=0;function Ql(t,i,s,l){const u=t._endId=++yh,f=()=>{u===t._endId&&l()};if(s!=null)return setTimeout(f,s);const{type:h,timeout:_,propCount:b}=xh(t,i);if(!h)return l();const C=h+"end";let T=0;const M=()=>{t.removeEventListener(C,H),f()},H=j=>{j.target===t&&++T>=b&&M()};setTimeout(()=>{T<b&&M()},_+1),t.addEventListener(C,H)}function xh(t,i){const s=window.getComputedStyle(t),l=G=>(s[G]||"").split(", "),u=l(`${Gi}Delay`),f=l(`${Gi}Duration`),h=eu(u,f),_=l(`${zs}Delay`),b=l(`${zs}Duration`),C=eu(_,b);let T=null,M=0,H=0;i===Gi?h>0&&(T=Gi,M=h,H=f.length):i===zs?C>0&&(T=zs,M=C,H=b.length):(M=Math.max(h,C),T=M>0?h>C?Gi:zs:null,H=T?T===Gi?f.length:b.length:0);const j=T===Gi&&/\b(?:transform|all)(?:,|$)/.test(l(`${Gi}Property`).toString());return{type:T,timeout:M,propCount:H,hasTransform:j}}function eu(t,i){for(;t.length<i.length;)t=t.concat(t);return Math.max(...i.map((s,l)=>tu(s)+tu(t[l])))}function tu(t){return t==="auto"?0:Number(t.slice(0,-1).replace(",","."))*1e3}function nu(t){return(t?t.ownerDocument:document).body.offsetHeight}function wh(t,i,s){const l=t[Qs];l&&(i=(i?[i,...l]:[...l]).join(" ")),i==null?t.removeAttribute("class"):s?t.setAttribute("class",i):t.className=i}const Ua=Symbol("_vod"),Wc=Symbol("_vsh"),kh={name:"show",beforeMount(t,{value:i},{transition:s}){t[Ua]=t.style.display==="none"?"":t.style.display,s&&i?s.beforeEnter(t):$
Line truncated
|
||||
* Leaflet 1.9.4, a JS library for interactive maps. https://leafletjs.com
|
||||
* (c) 2010-2023 Vladimir Agafonkin, (c) 2010-2011 CloudMade
|
||||
*/var cm=Ds.exports,Pu;function dm(){return Pu||(Pu=1,(function(t,i){(function(s,l){l(i)})(cm,(function(s){var l="1.9.4";function u(e){var n,o,r,d;for(o=1,r=arguments.length;o<r;o++){d=arguments[o];for(n in d)e[n]=d[n]}return e}var f=Object.create||(function(){function e(){}return function(n){return e.prototype=n,new e}})();function h(e,n){var o=Array.prototype.slice;if(e.bind)return e.bind.apply(e,o.call(arguments,1));var r=o.call(arguments,2);return function(){return e.apply(n,r.length?r.concat(o.call(arguments)):arguments)}}var _=0;function b(e){return"_leaflet_id"in e||(e._leaflet_id=++_),e._leaflet_id}function C(e,n,o){var r,d,v,P;return P=function(){r=!1,d&&(v.apply(o,d),d=!1)},v=function(){r?d=arguments:(e.apply(o,arguments),setTimeout(P,n),r=!0)},v}function T(e,n,o){var r=n[1],d=n[0],v=r-d;return e===r&&o?e:((e-d)%v+v)%v+d}function M(){return!1}function H(e,n){if(n===!1)return e;var o=Math.pow(10,n===void 0?6:n);return Math.round(e*o)/o}function j(e){return e.trim?e.trim():e.replace(/^\s+|\s+$/g,"")}function G(e){return j(e).split(/\s+/)}function N(e,n){Object.prototype.hasOwnProperty.call(e,"options")||(e.options=e.options?f(e.options):{});for(var o in n)e.options[o]=n[o];return e.options}function U(e,n,o){var r=[];for(var d in e)r.push(encodeURIComponent(o?d.toUpperCase():d)+"="+encodeURIComponent(e[d]));return(!n||n.indexOf("?")===-1?"?":"&")+r.join("&")}var R=/\{ *([\w_ -]+) *\}/g;function oe(e,n){return e.replace(R,function(o,r){var d=n[r];if(d===void 0)throw new Error("No value provided for variable "+o);return typeof d=="function"&&(d=d(n)),d})}var we=Array.isArray||function(e){return Object.prototype.toString.call(e)==="[object Array]"};function ue(e,n){for(var o=0;o<e.length;o++)if(e[o]===n)return o;return-1}var ke="data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=";function Ce(e){return window["webkit"+e]||window["moz"+e]||window["ms"+e]}var Le=0;function Ze(e){var n=+new Date,o=Math.max(0,16-(n-Le));return Le=n+o,window.setTimeout(e,o)}var he=window.requestAnimationFrame||Ce("RequestAnimationFrame")||Ze,ee=window.cancelAnimationFrame||Ce("CancelAnimationFrame")||Ce("CancelRequestAnimationFrame")||function(e){window.clearTimeout(e)};function V(e,n,o){if(o&&he===Ze)e.call(n);else return he.call(window,h(e,n))}function O(e){e&&ee.call(window,e)}var F={__proto__:null,extend:u,create:f,bind:h,get lastId(){return _},stamp:b,throttle:C,wrapNum:T,falseFn:M,formatNum:H,trim:j,splitWords:G,setOptions:N,getParamString:U,template:oe,isArray:we,indexOf:ue,emptyImageUrl:ke,requestFn:he,cancelFn:ee,requestAnimFrame:V,cancelAnimFrame:O};function $(){}$.extend=function(e){var n=function(){N(this),this.initialize&&this.initialize.apply(this,arguments),this.callInitHooks()},o=n.__super__=this.prototype,r=f(o);r.constructor=n,n.prototype=r;for(var d in this)Object.prototype.hasOwnProperty.call(this,d)&&d!=="prototype"&&d!=="__super__"&&(n[d]=this[d]);return e.statics&&u(n,e.statics),e.includes&&(Ye(e.includes),u.apply(null,[r].concat(e.includes))),u(r,e),delete r.statics,delete r.includes,r.options&&(r.options=o.options?f(o.options):{},u(r.options,e.options)),r._initHooks=[],r.callInitHooks=function(){if(!this._initHooksCalled){o.callInitHooks&&o.callInitHooks.call(this),this._initHooksCalled=!0;for(var v=0,P=r._initHooks.length;v<P;v++)r._initHooks[v].call(this)}},n},$.include=function(e){var n=this.prototype.options;return u(this.prototype,e),e.options&&(this.prototype.options=n,this.mergeOptions(e.options)),this},$.mergeOptions=function(e){return u(this.prototype.options,e),this},$.addInitHook=function(e){var n=Array.prototype.slice.call(arguments,1),o=typeof e=="function"?e:function(){this[e].apply(this,n)};return this.prototype._initHooks=this.prototype._initHooks||[],this.prototype._initHooks.push(o),this};function Ye(e){if(!(typeof L>"u"||!L||!L.Mixin)){e=we(e)?e:[e];for(var n=0;n<e.length;n++)e[n]===L.Mixin.Events&&console.warn("Deprecated include of L.Mixin.Events: this property will be removed in future releases, please inherit from L.Evented instead.",new Error().stack)}}var Se={on:function(e,n,o){if(typeof e=="object")for(var r in e)this._on(r,e[r],n);else{e=G(e);for(var d=0,v=e.length;d<v;d++)this._on(e[d],n,o)}return this},off:function(e,n,o){if(!arguments.length)delete this._events;else if(typeof e=="object")for(var r in e)this._off(r,e[r],n);else{e=G(e);for(var d=arguments.length===1,v=0,P=e.length;v<P;v++)d?this._off(e[v]):this._off(e[v],n,o)}return this},_on:function(e,n,o,r){if(typeof n!="function"){console.warn("wrong listener type: "+typeof n);return}if(this._listens(e,n,o)===!1){o===this&&(o=void 0);var d={fn:n,ctx:o};r&&(d.once=!0),this._events=this._events||{},this._events[e]=this._events[e]||[],this._events[e].push(d)}},_off:function(e,n,o){var r,d,v;if(this._events&&(r=this._events[e],!!r)){if(arguments.length===1){if(this._firingCount)for(d=0,v=r.length;d<v;d++)r[d].fn=M;delete this._events[e];return}if(typeof n!="function"){console.warn("wrong listener type: "+typeof n)Line truncated
|
||||
-20
@@ -1,20 +0,0 @@
|
||||
(function(){const i=document.createElement("link").relList;if(i&&i.supports&&i.supports("modulepreload"))return;for(const u of document.querySelectorAll('link[rel="modulepreload"]'))l(u);new MutationObserver(u=>{for(const f of u)if(f.type==="childList")for(const h of f.addedNodes)h.tagName==="LINK"&&h.rel==="modulepreload"&&l(h)}).observe(document,{childList:!0,subtree:!0});function s(u){const f={};return u.integrity&&(f.integrity=u.integrity),u.referrerPolicy&&(f.referrerPolicy=u.referrerPolicy),u.crossOrigin==="use-credentials"?f.credentials="include":u.crossOrigin==="anonymous"?f.credentials="omit":f.credentials="same-origin",f}function l(u){if(u.ep)return;u.ep=!0;const f=s(u);fetch(u.href,f)}})();/**
|
||||
* @vue/shared v3.5.39
|
||||
* (c) 2018-present Yuxi (Evan) You and Vue contributors
|
||||
* @license MIT
|
||||
**/function Xr(t){const i=Object.create(null);for(const s of t.split(","))i[s]=1;return s=>s in i}const vt={},es=[],ui=()=>{},zu=()=>!1,ja=t=>t.charCodeAt(0)===111&&t.charCodeAt(1)===110&&(t.charCodeAt(2)>122||t.charCodeAt(2)<97),Wa=t=>t.startsWith("onUpdate:"),Kt=Object.assign,Qr=(t,i)=>{const s=t.indexOf(i);s>-1&&t.splice(s,1)},pd=Object.prototype.hasOwnProperty,ct=(t,i)=>pd.call(t,i),$e=Array.isArray,ts=t=>ea(t)==="[object Map]",ls=t=>ea(t)==="[object Set]",Al=t=>ea(t)==="[object Date]",je=t=>typeof t=="function",Tt=t=>typeof t=="string",Qn=t=>typeof t=="symbol",dt=t=>t!==null&&typeof t=="object",$u=t=>(dt(t)||je(t))&&je(t.then)&&je(t.catch),Iu=Object.prototype.toString,ea=t=>Iu.call(t),md=t=>ea(t).slice(8,-1),Nu=t=>ea(t)==="[object Object]",el=t=>Tt(t)&&t!=="NaN"&&t[0]!=="-"&&""+parseInt(t,10)===t,Rs=Xr(",key,ref,ref_for,ref_key,onVnodeBeforeMount,onVnodeMounted,onVnodeBeforeUpdate,onVnodeUpdated,onVnodeBeforeUnmount,onVnodeUnmounted"),Ka=t=>{const i=Object.create(null);return(s=>i[s]||(i[s]=t(s)))},gd=/-\w/g,Jn=Ka(t=>t.replace(gd,i=>i.slice(1).toUpperCase())),vd=/\B([A-Z])/g,to=Ka(t=>t.replace(vd,"-$1").toLowerCase()),Du=Ka(t=>t.charAt(0).toUpperCase()+t.slice(1)),xr=Ka(t=>t?`on${Du(t)}`:""),li=(t,i)=>!Object.is(t,i),Ea=(t,...i)=>{for(let s=0;s<t.length;s++)t[s](...i)},Fu=(t,i,s,l=!1)=>{Object.defineProperty(t,i,{configurable:!0,enumerable:!1,writable:l,value:s})},Ga=t=>{const i=parseFloat(t);return isNaN(i)?t:i},_d=t=>{const i=Tt(t)?Number(t):NaN;return isNaN(i)?t:i};let Ml;const qa=()=>Ml||(Ml=typeof globalThis<"u"?globalThis:typeof self<"u"?self:typeof window<"u"?window:typeof global<"u"?global:{});function Eo(t){if($e(t)){const i={};for(let s=0;s<t.length;s++){const l=t[s],u=Tt(l)?wd(l):Eo(l);if(u)for(const f in u)i[f]=u[f]}return i}else if(Tt(t)||dt(t))return t}const bd=/;(?![^(]*\))/g,yd=/:([^]+)/,xd=/\/\*[^]*?\*\//g;function wd(t){const i={};return t.replace(xd,"").split(bd).forEach(s=>{if(s){const l=s.split(yd);l.length>1&&(i[l[0].trim()]=l[1].trim())}}),i}function Ce(t){let i="";if(Tt(t))i=t;else if($e(t))for(let s=0;s<t.length;s++){const l=Ce(t[s]);l&&(i+=l+" ")}else if(dt(t))for(const s in t)t[s]&&(i+=s+" ");return i.trim()}const kd="itemscope,allowfullscreen,formnovalidate,ismap,nomodule,novalidate,readonly",Sd=Xr(kd);function Ru(t){return!!t||t===""}function Td(t,i){if(t.length!==i.length)return!1;let s=!0;for(let l=0;s&&l<t.length;l++)s=Xi(t[l],i[l]);return s}function Xi(t,i){if(t===i)return!0;let s=Al(t),l=Al(i);if(s||l)return s&&l?t.getTime()===i.getTime():!1;if(s=Qn(t),l=Qn(i),s||l)return t===i;if(s=$e(t),l=$e(i),s||l)return s&&l?Td(t,i):!1;if(s=dt(t),l=dt(i),s||l){if(!s||!l)return!1;const u=Object.keys(t).length,f=Object.keys(i).length;if(u!==f)return!1;for(const h in t){const _=t.hasOwnProperty(h),y=i.hasOwnProperty(h);if(_&&!y||!_&&y||!Xi(t[h],i[h]))return!1}}return String(t)===String(i)}function tl(t,i){return t.findIndex(s=>Xi(s,i))}const Bu=t=>!!(t&&t.__v_isRef===!0),w=t=>Tt(t)?t:t==null?"":$e(t)||dt(t)&&(t.toString===Iu||!je(t.toString))?Bu(t)?w(t.value):JSON.stringify(t,Uu,2):String(t),Uu=(t,i)=>Bu(i)?Uu(t,i.value):ts(i)?{[`Map(${i.size})`]:[...i.entries()].reduce((s,[l,u],f)=>(s[wr(l,f)+" =>"]=u,s),{})}:ls(i)?{[`Set(${i.size})`]:[...i.values()].map(s=>wr(s))}:Qn(i)?wr(i):dt(i)&&!$e(i)&&!Nu(i)?String(i):i,wr=(t,i="")=>{var s;return Qn(t)?`Symbol(${(s=t.description)!=null?s:i})`:t};/**
|
||||
* @vue/reactivity v3.5.39
|
||||
* (c) 2018-present Yuxi (Evan) You and Vue contributors
|
||||
* @license MIT
|
||||
**/let Yt;class Pd{constructor(i=!1){this.detached=i,this._active=!0,this._on=0,this.effects=[],this.cleanups=[],this._isPaused=!1,this._warnOnRun=!0,this.__v_skip=!0,!i&&Yt&&(Yt.active?(this.parent=Yt,this.index=(Yt.scopes||(Yt.scopes=[])).push(this)-1):(this._active=!1,this._warnOnRun=!1))}get active(){return this._active}pause(){if(this._active){this._isPaused=!0;let i,s;if(this.scopes)for(i=0,s=this.scopes.length;i<s;i++)this.scopes[i].pause();for(i=0,s=this.effects.length;i<s;i++)this.effects[i].pause()}}resume(){if(this._active&&this._isPaused){this._isPaused=!1;let i,s;if(this.scopes)for(i=0,s=this.scopes.length;i<s;i++)this.scopes[i].resume();for(i=0,s=this.effects.length;i<s;i++)this.effects[i].resume()}}run(i){if(this._active){const s=Yt;try{return Yt=this,i()}finally{Yt=s}}}on(){++this._on===1&&(this.prevScope=Yt,Yt=this)}off(){if(this._on>0&&--this._on===0){if(Yt===this)Yt=this.prevScope;else{let i=Yt;for(;i;){if(i.prevScope===this){i.prevScope=this.prevScope;break}i=i.prevScope}}this.prevScope=void 0}}stop(i){if(this._active){this._active=!1;let s,l;for(s=0,l=this.effects.length;s<l;s++)this.effects[s].stop();for(this.effects.length=0,s=0,l=this.cleanups.length;s<l;s++)this.cleanups[s]();if(this.cleanups.length=0,this.scopes){for(s=0,l=this.scopes.length;s<l;s++)this.scopes[s].stop(!0);this.scopes.length=0}if(!this.detached&&this.parent&&!i){const u=this.parent.scopes.pop();u&&u!==this&&(this.parent.scopes[this.index]=u,u.index=this.index)}this.parent=void 0}}}function Cd(){return Yt}let xt;const kr=new WeakSet;class Vu{constructor(i){this.fn=i,this.deps=void 0,this.depsTail=void 0,this.flags=5,this.next=void 0,this.cleanup=void 0,this.scheduler=void 0,Yt&&(Yt.active?Yt.effects.push(this):this.flags&=-2)}pause(){this.flags|=64}resume(){this.flags&64&&(this.flags&=-65,kr.has(this)&&(kr.delete(this),this.trigger()))}notify(){this.flags&2&&!(this.flags&32)||this.flags&8||Hu(this)}run(){if(!(this.flags&1))return this.fn();this.flags|=2,El(this),ju(this);const i=xt,s=Xn;xt=this,Xn=!0;try{return this.fn()}finally{Wu(this),xt=i,Xn=s,this.flags&=-3}}stop(){if(this.flags&1){for(let i=this.deps;i;i=i.nextDep)ol(i);this.deps=this.depsTail=void 0,El(this),this.onStop&&this.onStop(),this.flags&=-2}}trigger(){this.flags&64?kr.add(this):this.scheduler?this.scheduler():this.runIfDirty()}runIfDirty(){Ir(this)&&this.run()}get dirty(){return Ir(this)}}let Zu=0,Bs,Us;function Hu(t,i=!1){if(t.flags|=8,i){t.next=Us,Us=t;return}t.next=Bs,Bs=t}function nl(){Zu++}function il(){if(--Zu>0)return;if(Us){let i=Us;for(Us=void 0;i;){const s=i.next;i.next=void 0,i.flags&=-9,i=s}}let t;for(;Bs;){let i=Bs;for(Bs=void 0;i;){const s=i.next;if(i.next=void 0,i.flags&=-9,i.flags&1)try{i.trigger()}catch(l){t||(t=l)}i=s}}if(t)throw t}function ju(t){for(let i=t.deps;i;i=i.nextDep)i.version=-1,i.prevActiveLink=i.dep.activeLink,i.dep.activeLink=i}function Wu(t){let i,s=t.depsTail,l=s;for(;l;){const u=l.prevDep;l.version===-1?(l===s&&(s=u),ol(l),Ld(l)):i=l,l.dep.activeLink=l.prevActiveLink,l.prevActiveLink=void 0,l=u}t.deps=i,t.depsTail=s}function Ir(t){for(let i=t.deps;i;i=i.nextDep)if(i.dep.version!==i.version||i.dep.computed&&(Ku(i.dep.computed)||i.dep.version!==i.version))return!0;return!!t._dirty}function Ku(t){if(t.flags&4&&!(t.flags&16)||(t.flags&=-17,t.globalVersion===Ws)||(t.globalVersion=Ws,!t.isSSR&&t.flags&128&&(!t.deps&&!t._dirty||!Ir(t))))return;t.flags|=2;const i=t.dep,s=xt,l=Xn;xt=t,Xn=!0;try{ju(t);const u=t.fn(t._value);(i.version===0||li(u,t._value))&&(t.flags|=128,t._value=u,i.version++)}catch(u){throw i.version++,u}finally{xt=s,Xn=l,Wu(t),t.flags&=-3}}function ol(t,i=!1){const{dep:s,prevSub:l,nextSub:u}=t;if(l&&(l.nextSub=u,t.prevSub=void 0),u&&(u.prevSub=l,t.nextSub=void 0),s.subs===t&&(s.subs=l,!l&&s.computed)){s.computed.flags&=-5;for(let f=s.computed.deps;f;f=f.nextDep)ol(f,!0)}!i&&!--s.sc&&s.map&&s.map.delete(s.key)}function Ld(t){const{prevDep:i,nextDep:s}=t;i&&(i.nextDep=s,t.prevDep=void 0),s&&(s.prevDep=i,t.nextDep=void 0)}let Xn=!0;const Gu=[];function ci(){Gu.push(Xn),Xn=!1}function di(){const t=Gu.pop();Xn=t===void 0?!0:t}function El(t){const{cleanup:i}=t;if(t.cleanup=void 0,i){const s=xt;xt=void 0;try{i()}finally{xt=s}}}let Ws=0;class Ad{constructor(i,s){this.sub=i,this.dep=s,this.version=s.version,this.nextDep=this.prevDep=this.nextSub=this.prevSub=this.prevActiveLink=void 0}}class sl{constructor(i){this.computed=i,this.version=0,this.activeLink=void 0,this.subs=void 0,this.map=void 0,this.key=void 0,this.sc=0,this.__v_skip=!0}track(i){if(!xt||!Xn||xt===this.computed)return;let s=this.activeLink;if(s===void 0||s.sub!==xt)s=this.activeLink=new Ad(xt,this),xt.deps?(s.prevDep=xt.depsTail,xt.depsTail.nextDep=s,xt.depsTail=s):xt.deps=xt.depsTail=s,qu(s);else if(s.version===-1&&(s.version=this.version,s.nextDep)){const l=s.nextDep;l.prevDep=s.prevDep,s.prevDep&&(s.prevDep.nextDep=l),s.prevDep=xt.depsTail,s.nextDep=void 0,xt.depsTail.nextDep=s,xt.depsTail=s,xt.deps===s&&(xt.deps=l)}return s}trigger(i){this.versiLine truncated
|
||||
* @vue/runtime-core v3.5.39
|
||||
* (c) 2018-present Yuxi (Evan) You and Vue contributors
|
||||
* @license MIT
|
||||
**/function ta(t,i,s,l){try{return l?t(...l):t()}catch(u){Ja(u,i,s)}}function Hn(t,i,s,l){if(je(t)){const u=ta(t,i,s,l);return u&&$u(u)&&u.catch(f=>{Ja(f,i,s)}),u}if($e(t)){const u=[];for(let f=0;f<t.length;f++)u.push(Hn(t[f],i,s,l));return u}}function Ja(t,i,s,l=!0){const u=i?i.vnode:null,{errorHandler:f,throwUnhandledErrorInProduction:h}=i&&i.appContext.config||vt;if(i){let _=i.parent;const y=i.proxy,C=`https://vuejs.org/error-reference/#runtime-${s}`;for(;_;){const T=_.ec;if(T){for(let M=0;M<T.length;M++)if(T[M](t,y,C)===!1)return}_=_.parent}if(f){ci(),ta(f,null,10,[t,y,C]),di();return}}ef(t,s,u,l,h)}function ef(t,i,s,l=!0,u=!1){if(u)throw t;console.error(t)}const fn=[];let si=-1;const ns=[];let Ji=null,Qo=0;const ic=Promise.resolve();let $a=null;function oc(t){const i=$a||ic;return t?i.then(this?t.bind(this):t):i}function tf(t){let i=si+1,s=fn.length;for(;i<s;){const l=i+s>>>1,u=fn[l],f=Gs(u);f<t||f===t&&u.flags&2?i=l+1:s=l}return i}function ul(t){if(!(t.flags&1)){const i=Gs(t),s=fn[fn.length-1];!s||!(t.flags&2)&&i>=Gs(s)?fn.push(t):fn.splice(tf(i),0,t),t.flags|=1,sc()}}function sc(){$a||($a=ic.then(rc))}function nf(t){$e(t)?ns.push(...t):Ji&&t.id===-1?Ji.splice(Qo+1,0,t):t.flags&1||(ns.push(t),t.flags|=1),sc()}function zl(t,i,s=si+1){for(;s<fn.length;s++){const l=fn[s];if(l&&l.flags&2){if(t&&l.id!==t.uid)continue;fn.splice(s,1),s--,l.flags&4&&(l.flags&=-2),l(),l.flags&4||(l.flags&=-2)}}}function ac(t){if(ns.length){const i=[...new Set(ns)].sort((s,l)=>Gs(s)-Gs(l));if(ns.length=0,Ji){Ji.push(...i);return}for(Ji=i,Qo=0;Qo<Ji.length;Qo++){const s=Ji[Qo];s.flags&4&&(s.flags&=-2),s.flags&8||s(),s.flags&=-2}Ji=null,Qo=0}}const Gs=t=>t.id==null?t.flags&2?-1:1/0:t.id;function rc(t){try{for(si=0;si<fn.length;si++){const i=fn[si];i&&!(i.flags&8)&&(i.flags&4&&(i.flags&=-2),ta(i,i.i,i.i?15:14),i.flags&4||(i.flags&=-2))}}finally{for(;si<fn.length;si++){const i=fn[si];i&&(i.flags&=-2)}si=-1,fn.length=0,ac(),$a=null,(fn.length||ns.length)&&rc()}}let an=null,lc=null;function Ia(t){const i=an;return an=t,lc=t&&t.type.__scopeId||null,i}function ye(t,i=an,s){if(!i||t._n)return t;const l=(...u)=>{l._d&&Fa(-1);const f=Ia(i);let h;try{h=t(...u)}finally{Ia(f),l._d&&Fa(1)}return h};return l._n=!0,l._c=!0,l._d=!0,l}function ie(t,i){if(an===null)return t;const s=nr(an),l=t.dirs||(t.dirs=[]);for(let u=0;u<i.length;u++){let[f,h,_,y=vt]=i[u];f&&(je(f)&&(f={mounted:f,updated:f}),f.deep&&Ai(h),l.push({dir:f,instance:s,value:h,oldValue:void 0,arg:_,modifiers:y}))}return t}function wo(t,i,s,l){const u=t.dirs,f=i&&i.dirs;for(let h=0;h<u.length;h++){const _=u[h];f&&(_.oldValue=f[h].value);let y=_.dir[l];y&&(ci(),Hn(y,s,8,[t.el,_,t,i]),di())}}function uc(t,i){if(hn){let s=hn.provides;const l=hn.parent&&hn.parent.provides;l===s&&(s=hn.provides=Object.create(l)),s[t]=i}}function Vs(t,i,s=!1){const l=Uc();if(l||os){let u=os?os._context.provides:l?l.parent==null||l.ce?l.vnode.appContext&&l.vnode.appContext.provides:l.parent.provides:void 0;if(u&&t in u)return u[t];if(arguments.length>1)return s&&je(i)?i.call(l&&l.proxy):i}}const of=Symbol.for("v-scx"),sf=()=>Vs(of);function Bt(t,i,s){return cc(t,i,s)}function cc(t,i,s=vt){const{immediate:l,deep:u,flush:f,once:h}=s,_=Kt({},s),y=i&&l||!i&&f!=="post";let C;if(Xs){if(f==="sync"){const j=sf();C=j.__watcherHandles||(j.__watcherHandles=[])}else if(!y){const j=()=>{};return j.stop=ui,j.resume=ui,j.pause=ui,j}}const T=hn;_.call=(j,K,F)=>Hn(j,T,K,F);let M=!1;f==="post"?_.scheduler=j=>{dn(j,T&&T.suspense)}:f!=="sync"&&(M=!0,_.scheduler=(j,K)=>{K?j():ul(j)}),_.augmentJob=j=>{i&&(j.flags|=4),M&&(j.flags|=2,T&&(j.id=T.uid,j.i=T))};const H=Qd(t,i,_);return Xs&&(C?C.push(H):y&&H()),H}function af(t,i,s){const l=this.proxy,u=Tt(t)?t.includes(".")?dc(l,t):()=>l[t]:t.bind(l,l);let f;je(i)?f=i:(f=i.handler,s=i);const h=na(this),_=cc(u,f.bind(l),s);return h(),_}function dc(t,i){const s=i.split(".");return()=>{let l=t;for(let u=0;u<s.length&&l;u++)l=l[s[u]];return l}}const Yi=new WeakMap,fc=Symbol("_vte"),hc=t=>t.__isTeleport,Co=t=>t&&(t.disabled||t.disabled===""),rf=t=>t&&(t.defer||t.defer===""),$l=t=>typeof SVGElement<"u"&&t instanceof SVGElement,Il=t=>typeof MathMLElement=="function"&&t instanceof MathMLElement,Br=(t,i)=>{const s=t&&t.to;return Tt(s)?i?i(s):null:s},lf={name:"Teleport",__isTeleport:!0,process(t,i,s,l,u,f,h,_,y,C){const{mc:T,pc:M,pbc:H,o:{insert:j,querySelector:K,createText:F,createComment:te,parentNode:X}}=C,fe=Co(i.props);let{dynamicChildren:Se}=i;const de=(Te,Ze,he)=>{Te.shapeFlag&16&&T(Te.children,Ze,he,u,f,h,_,y)},Fe=(Te=i)=>{const Ze=Co(Te.props),he=Te.target=Br(Te.props,K),Q=Ur(he,Te,F,j);he&&(h!=="svg"&&$l(he)?h="svg":h!=="mathml"&&Il(he)&&(h="mathml"),u&&u.isCE&&(u.ce._teleportTargets||(u.ce._teleportTargets=new Set)).add(he),Ze||(de(Te,he,Q),Is(Te,!1)))},Oe=Te=>{const Ze=()=>{if(Yi.get(Te)===Ze){if(Yi.delete(Te),Co(Te.props)){const he=X(Te.el)||s;de(Te,he,Te.anchor),Is(Te,!0)}Fe(Te)}};Yi.set(Te,Ze),dn(Ze,f)};if(t==null){const Te=i.el=F(""),Ze=i.anchor=F("");if(j(Te,s,l),j(Ze,s,l),rf(i.
Line truncated
|
||||
* @vue/runtime-dom v3.5.39
|
||||
* (c) 2018-present Yuxi (Evan) You and Vue contributors
|
||||
* @license MIT
|
||||
**/let Gr;const ql=typeof window<"u"&&window.trustedTypes;if(ql)try{Gr=ql.createPolicy("vue",{createHTML:t=>t})}catch{}const Hc=Gr?t=>Gr.createHTML(t):t=>t,fh="http://www.w3.org/2000/svg",hh="http://www.w3.org/1998/Math/MathML",Pi=typeof document<"u"?document:null,Yl=Pi&&Pi.createElement("template"),ph={insert:(t,i,s)=>{i.insertBefore(t,s||null)},remove:t=>{const i=t.parentNode;i&&i.removeChild(t)},createElement:(t,i,s,l)=>{const u=i==="svg"?Pi.createElementNS(fh,t):i==="mathml"?Pi.createElementNS(hh,t):s?Pi.createElement(t,{is:s}):Pi.createElement(t);return t==="select"&&l&&l.multiple!=null&&u.setAttribute("multiple",l.multiple),u},createText:t=>Pi.createTextNode(t),createComment:t=>Pi.createComment(t),setText:(t,i)=>{t.nodeValue=i},setElementText:(t,i)=>{t.textContent=i},parentNode:t=>t.parentNode,nextSibling:t=>t.nextSibling,querySelector:t=>Pi.querySelector(t),setScopeId(t,i){t.setAttribute(i,"")},insertStaticContent(t,i,s,l,u,f){const h=s?s.previousSibling:i.lastChild;if(u&&(u===f||u.nextSibling))for(;i.insertBefore(u.cloneNode(!0),s),!(u===f||!(u=u.nextSibling)););else{Yl.innerHTML=Hc(l==="svg"?`<svg>${t}</svg>`:l==="mathml"?`<math>${t}</math>`:t);const _=Yl.content;if(l==="svg"||l==="mathml"){const y=_.firstChild;for(;y.firstChild;)_.appendChild(y.firstChild);_.removeChild(y)}i.insertBefore(_,s)}return[h?h.nextSibling:i.firstChild,s?s.previousSibling:i.lastChild]}},Gi="transition",zs="animation",Qs=Symbol("_vtc"),jc={name:String,type:String,css:{type:Boolean,default:!0},duration:[String,Number,Object],enterFromClass:String,enterActiveClass:String,enterToClass:String,appearFromClass:String,appearActiveClass:String,appearToClass:String,leaveFromClass:String,leaveActiveClass:String,leaveToClass:String},mh=Kt({},pc,jc),gh=t=>(t.displayName="Transition",t.props=mh,t),vh=gh((t,{slots:i})=>ch(hf,_h(t),i)),So=(t,i=[])=>{$e(t)?t.forEach(s=>s(...i)):t&&t(...i)},Jl=t=>t?$e(t)?t.some(i=>i.length>1):t.length>1:!1;function _h(t){const i={};for(const O in t)O in jc||(i[O]=t[O]);if(t.css===!1)return i;const{name:s="v",type:l,duration:u,enterFromClass:f=`${s}-enter-from`,enterActiveClass:h=`${s}-enter-active`,enterToClass:_=`${s}-enter-to`,appearFromClass:y=f,appearActiveClass:C=h,appearToClass:T=_,leaveFromClass:M=`${s}-leave-from`,leaveActiveClass:H=`${s}-leave-active`,leaveToClass:j=`${s}-leave-to`}=t,K=bh(u),F=K&&K[0],te=K&&K[1],{onBeforeEnter:X,onEnter:fe,onEnterCancelled:Se,onLeave:de,onLeaveCancelled:Fe,onBeforeAppear:Oe=X,onAppear:Te=fe,onAppearCancelled:Ze=Se}=i,he=(O,N,$,Ye)=>{O._enterCancelled=Ye,To(O,N?T:_),To(O,N?C:h),$&&$()},Q=(O,N)=>{O._isLeaving=!1,To(O,M),To(O,j),To(O,H),N&&N()},B=O=>(N,$)=>{const Ye=O?Te:fe,we=()=>he(N,O,$);So(Ye,[N,we]),Xl(()=>{To(N,O?y:f),Ti(N,O?T:_),Jl(Ye)||Ql(N,l,F,we)})};return Kt(i,{onBeforeEnter(O){So(X,[O]),Ti(O,f),Ti(O,h)},onBeforeAppear(O){So(Oe,[O]),Ti(O,y),Ti(O,C)},onEnter:B(!1),onAppear:B(!0),onLeave(O,N){O._isLeaving=!0;const $=()=>Q(O,N);Ti(O,M),O._enterCancelled?(Ti(O,H),nu(O)):(nu(O),Ti(O,H)),Xl(()=>{O._isLeaving&&(To(O,M),Ti(O,j),Jl(de)||Ql(O,l,te,$))}),So(de,[O,$])},onEnterCancelled(O){he(O,!1,void 0,!0),So(Se,[O])},onAppearCancelled(O){he(O,!0,void 0,!0),So(Ze,[O])},onLeaveCancelled(O){Q(O),So(Fe,[O])}})}function bh(t){if(t==null)return null;if(dt(t))return[Mr(t.enter),Mr(t.leave)];{const i=Mr(t);return[i,i]}}function Mr(t){return _d(t)}function Ti(t,i){i.split(/\s+/).forEach(s=>s&&t.classList.add(s)),(t[Qs]||(t[Qs]=new Set)).add(i)}function To(t,i){i.split(/\s+/).forEach(l=>l&&t.classList.remove(l));const s=t[Qs];s&&(s.delete(i),s.size||(t[Qs]=void 0))}function Xl(t){requestAnimationFrame(()=>{requestAnimationFrame(t)})}let yh=0;function Ql(t,i,s,l){const u=t._endId=++yh,f=()=>{u===t._endId&&l()};if(s!=null)return setTimeout(f,s);const{type:h,timeout:_,propCount:y}=xh(t,i);if(!h)return l();const C=h+"end";let T=0;const M=()=>{t.removeEventListener(C,H),f()},H=j=>{j.target===t&&++T>=y&&M()};setTimeout(()=>{T<y&&M()},_+1),t.addEventListener(C,H)}function xh(t,i){const s=window.getComputedStyle(t),l=K=>(s[K]||"").split(", "),u=l(`${Gi}Delay`),f=l(`${Gi}Duration`),h=eu(u,f),_=l(`${zs}Delay`),y=l(`${zs}Duration`),C=eu(_,y);let T=null,M=0,H=0;i===Gi?h>0&&(T=Gi,M=h,H=f.length):i===zs?C>0&&(T=zs,M=C,H=y.length):(M=Math.max(h,C),T=M>0?h>C?Gi:zs:null,H=T?T===Gi?f.length:y.length:0);const j=T===Gi&&/\b(?:transform|all)(?:,|$)/.test(l(`${Gi}Property`).toString());return{type:T,timeout:M,propCount:H,hasTransform:j}}function eu(t,i){for(;t.length<i.length;)t=t.concat(t);return Math.max(...i.map((s,l)=>tu(s)+tu(t[l])))}function tu(t){return t==="auto"?0:Number(t.slice(0,-1).replace(",","."))*1e3}function nu(t){return(t?t.ownerDocument:document).body.offsetHeight}function wh(t,i,s){const l=t[Qs];l&&(i=(i?[i,...l]:[...l]).join(" ")),i==null?t.removeAttribute("class"):s?t.setAttribute("class",i):t.className=i}const Ua=Symbol("_vod"),Wc=Symbol("_vsh"),kh={name:"show",beforeMount(t,{value:i},{transition:s}){t[Ua]=t.style.display==="none"?"":t.style.display,s&&i?s.beforeEnter(t):$s
Line truncated
|
||||
* Leaflet 1.9.4, a JS library for interactive maps. https://leafletjs.com
|
||||
* (c) 2010-2023 Vladimir Agafonkin, (c) 2010-2011 CloudMade
|
||||
*/var um=Fs.exports,Pu;function cm(){return Pu||(Pu=1,(function(t,i){(function(s,l){l(i)})(um,(function(s){var l="1.9.4";function u(e){var n,o,r,d;for(o=1,r=arguments.length;o<r;o++){d=arguments[o];for(n in d)e[n]=d[n]}return e}var f=Object.create||(function(){function e(){}return function(n){return e.prototype=n,new e}})();function h(e,n){var o=Array.prototype.slice;if(e.bind)return e.bind.apply(e,o.call(arguments,1));var r=o.call(arguments,2);return function(){return e.apply(n,r.length?r.concat(o.call(arguments)):arguments)}}var _=0;function y(e){return"_leaflet_id"in e||(e._leaflet_id=++_),e._leaflet_id}function C(e,n,o){var r,d,v,P;return P=function(){r=!1,d&&(v.apply(o,d),d=!1)},v=function(){r?d=arguments:(e.apply(o,arguments),setTimeout(P,n),r=!0)},v}function T(e,n,o){var r=n[1],d=n[0],v=r-d;return e===r&&o?e:((e-d)%v+v)%v+d}function M(){return!1}function H(e,n){if(n===!1)return e;var o=Math.pow(10,n===void 0?6:n);return Math.round(e*o)/o}function j(e){return e.trim?e.trim():e.replace(/^\s+|\s+$/g,"")}function K(e){return j(e).split(/\s+/)}function F(e,n){Object.prototype.hasOwnProperty.call(e,"options")||(e.options=e.options?f(e.options):{});for(var o in n)e.options[o]=n[o];return e.options}function te(e,n,o){var r=[];for(var d in e)r.push(encodeURIComponent(o?d.toUpperCase():d)+"="+encodeURIComponent(e[d]));return(!n||n.indexOf("?")===-1?"?":"&")+r.join("&")}var X=/\{ *([\w_ -]+) *\}/g;function fe(e,n){return e.replace(X,function(o,r){var d=n[r];if(d===void 0)throw new Error("No value provided for variable "+o);return typeof d=="function"&&(d=d(n)),d})}var Se=Array.isArray||function(e){return Object.prototype.toString.call(e)==="[object Array]"};function de(e,n){for(var o=0;o<e.length;o++)if(e[o]===n)return o;return-1}var Fe="data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=";function Oe(e){return window["webkit"+e]||window["moz"+e]||window["ms"+e]}var Te=0;function Ze(e){var n=+new Date,o=Math.max(0,16-(n-Te));return Te=n+o,window.setTimeout(e,o)}var he=window.requestAnimationFrame||Oe("RequestAnimationFrame")||Ze,Q=window.cancelAnimationFrame||Oe("CancelAnimationFrame")||Oe("CancelRequestAnimationFrame")||function(e){window.clearTimeout(e)};function B(e,n,o){if(o&&he===Ze)e.call(n);else return he.call(window,h(e,n))}function O(e){e&&Q.call(window,e)}var N={__proto__:null,extend:u,create:f,bind:h,get lastId(){return _},stamp:y,throttle:C,wrapNum:T,falseFn:M,formatNum:H,trim:j,splitWords:K,setOptions:F,getParamString:te,template:fe,isArray:Se,indexOf:de,emptyImageUrl:Fe,requestFn:he,cancelFn:Q,requestAnimFrame:B,cancelAnimFrame:O};function $(){}$.extend=function(e){var n=function(){F(this),this.initialize&&this.initialize.apply(this,arguments),this.callInitHooks()},o=n.__super__=this.prototype,r=f(o);r.constructor=n,n.prototype=r;for(var d in this)Object.prototype.hasOwnProperty.call(this,d)&&d!=="prototype"&&d!=="__super__"&&(n[d]=this[d]);return e.statics&&u(n,e.statics),e.includes&&(Ye(e.includes),u.apply(null,[r].concat(e.includes))),u(r,e),delete r.statics,delete r.includes,r.options&&(r.options=o.options?f(o.options):{},u(r.options,e.options)),r._initHooks=[],r.callInitHooks=function(){if(!this._initHooksCalled){o.callInitHooks&&o.callInitHooks.call(this),this._initHooksCalled=!0;for(var v=0,P=r._initHooks.length;v<P;v++)r._initHooks[v].call(this)}},n},$.include=function(e){var n=this.prototype.options;return u(this.prototype,e),e.options&&(this.prototype.options=n,this.mergeOptions(e.options)),this},$.mergeOptions=function(e){return u(this.prototype.options,e),this},$.addInitHook=function(e){var n=Array.prototype.slice.call(arguments,1),o=typeof e=="function"?e:function(){this[e].apply(this,n)};return this.prototype._initHooks=this.prototype._initHooks||[],this.prototype._initHooks.push(o),this};function Ye(e){if(!(typeof L>"u"||!L||!L.Mixin)){e=Se(e)?e:[e];for(var n=0;n<e.length;n++)e[n]===L.Mixin.Events&&console.warn("Deprecated include of L.Mixin.Events: this property will be removed in future releases, please inherit from L.Evented instead.",new Error().stack)}}var we={on:function(e,n,o){if(typeof e=="object")for(var r in e)this._on(r,e[r],n);else{e=K(e);for(var d=0,v=e.length;d<v;d++)this._on(e[d],n,o)}return this},off:function(e,n,o){if(!arguments.length)delete this._events;else if(typeof e=="object")for(var r in e)this._off(r,e[r],n);else{e=K(e);for(var d=arguments.length===1,v=0,P=e.length;v<P;v++)d?this._off(e[v]):this._off(e[v],n,o)}return this},_on:function(e,n,o,r){if(typeof n!="function"){console.warn("wrong listener type: "+typeof n);return}if(this._listens(e,n,o)===!1){o===this&&(o=void 0);var d={fn:n,ctx:o};r&&(d.once=!0),this._events=this._events||{},this._events[e]=this._events[e]||[],this._events[e].push(d)}},_off:function(e,n,o){var r,d,v;if(this._events&&(r=this._events[e],!!r)){if(arguments.length===1){if(this._firingCount)for(d=0,v=r.length;d<v;d++)r[d].fn=M;delete this._events[e];return}if(typeof n!="function"){console.warn("wrong listener type: "+typeof n);Line truncated
|
||||
Vendored
+2
-2
@@ -35,8 +35,8 @@
|
||||
})()
|
||||
</script>
|
||||
<title>PilotVault — Control Panel</title>
|
||||
<script type="module" crossorigin src="./assets/index-DwyUIzSZ.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-Bw3iqrF-.css">
|
||||
<script type="module" crossorigin src="./assets/index-DwIAtafL.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-DFvwkmh-.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -88,6 +88,9 @@ func main() {
|
||||
mux.HandleFunc("PATCH /bff/flights/{id}", app.requireAuth(app.handleUpdateFlight))
|
||||
mux.HandleFunc("DELETE /bff/flights/{id}", app.requireAuth(app.handleDeleteFlight))
|
||||
mux.HandleFunc("GET /bff/logbook/export", app.requireAuth(app.handleExportLogbook))
|
||||
// Routes — saved waypoint missions from the Fly App (read-only here)
|
||||
mux.HandleFunc("GET /bff/routes", app.requireAuth(app.handleListRoutes))
|
||||
|
||||
// Documents — the compliance + operational document store (scoping upstream)
|
||||
mux.HandleFunc("GET /bff/documents", app.requireAuth(app.handleListDocuments))
|
||||
mux.HandleFunc("POST /bff/documents", app.requireAuth(app.handleCreateDocument))
|
||||
|
||||
@@ -418,6 +418,21 @@ export function exportLogbookUrl() {
|
||||
return '/bff/logbook/export'
|
||||
}
|
||||
|
||||
/* ---------- Routes ---------- */
|
||||
|
||||
// List the saved waypoint routes in scope (own; an admin's whole org). Each
|
||||
// carries the Fly App's route JSON in `spec`.
|
||||
export async function getRoutes() {
|
||||
try {
|
||||
const r = await fetch('/bff/routes')
|
||||
if (!r.ok) return { ok: false, status: r.status, routes: [] }
|
||||
const d = await r.json()
|
||||
return { ok: true, status: 200, routes: d.routes || [] }
|
||||
} catch {
|
||||
return { ok: false, status: 0, routes: [] }
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Documents ---------- */
|
||||
|
||||
// List in-scope documents. Pass `expiring` (days) to narrow to documents that
|
||||
|
||||
@@ -7,6 +7,7 @@ import Settings from './Settings.vue'
|
||||
import Logbook from './Logbook.vue'
|
||||
import Drones from './Drones.vue'
|
||||
import Documents from './Documents.vue'
|
||||
import RoutesView from './Routes.vue'
|
||||
import Toggle from './settings/Toggle.vue'
|
||||
import { getDevices, sendCommand, getOpenSkyStates, getOpenWeatherCurrent, autoAddDrone } from '../api.js'
|
||||
import { formatTime, prefs } from '../prefs.js'
|
||||
@@ -1060,13 +1061,16 @@ onBeforeUnmount(() => {
|
||||
<Drones v-else-if="active === 'Drones'" ref="dronesView" :connected-fc-serials="connectedFcSerials"
|
||||
@deleted="forgetIdentity" />
|
||||
|
||||
<!-- ---------- Routes ---------- -->
|
||||
<RoutesView v-else-if="active === 'Routes'" />
|
||||
|
||||
<!-- ---------- Documents ---------- -->
|
||||
<Documents v-else-if="active === 'Documents'" :email="email" :role="role" :organization="organization" :organization-name="organizationName" />
|
||||
|
||||
<!-- ---------- Settings ---------- -->
|
||||
<Settings v-else-if="active === 'Settings'" :email="email" :role="role" :organization="organization" :organization-name="organizationName" @logout="emit('logout')" />
|
||||
|
||||
<!-- ---------- Placeholder views (Routes / Schedule / Drives) ---------- -->
|
||||
<!-- ---------- Placeholder views (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" />
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import Icon from './Icon.vue'
|
||||
import { getRoutes } from '../api.js'
|
||||
|
||||
// Read-only view of the waypoint routes pilots save in the Fly App's mission
|
||||
// library. The tablet is where routes are drawn and edited; this lists what has
|
||||
// synced. Scoping (own routes; an admin sees the org's) is server-side.
|
||||
|
||||
const routes = ref([])
|
||||
const loading = ref(false)
|
||||
const loadErr = ref('')
|
||||
const openId = ref('')
|
||||
|
||||
async function loadAll() {
|
||||
loading.value = true
|
||||
loadErr.value = ''
|
||||
const res = await getRoutes()
|
||||
if (!res.ok) {
|
||||
loadErr.value =
|
||||
res.status === 503
|
||||
? 'Route storage is not configured on the API Server (service account missing).'
|
||||
: 'Could not load your routes.'
|
||||
}
|
||||
routes.value = res.routes
|
||||
loading.value = false
|
||||
}
|
||||
onMounted(loadAll)
|
||||
|
||||
// Great-circle metres between two {lat, lon} points.
|
||||
function metres(a, b) {
|
||||
const R = 6371000
|
||||
const rad = (d) => (d * Math.PI) / 180
|
||||
const dLat = rad(b.lat - a.lat)
|
||||
const dLon = rad(b.lon - a.lon)
|
||||
const h = Math.sin(dLat / 2) ** 2 + Math.cos(rad(a.lat)) * Math.cos(rad(b.lat)) * Math.sin(dLon / 2) ** 2
|
||||
return 2 * R * Math.asin(Math.sqrt(h))
|
||||
}
|
||||
function points(r) {
|
||||
return Array.isArray(r.spec?.points) ? r.spec.points : []
|
||||
}
|
||||
function length(r) {
|
||||
const p = points(r)
|
||||
let m = 0
|
||||
for (let i = 1; i < p.length; i++) m += metres(p[i - 1], p[i])
|
||||
return m
|
||||
}
|
||||
function fmtLength(m) {
|
||||
return m >= 1000 ? `${(m / 1000).toFixed(2)} km` : `${Math.round(m)} m`
|
||||
}
|
||||
// Flight time at cruise speed (ignores hovers and turns): a rough guide only.
|
||||
function fmtDuration(r) {
|
||||
const v = Number(r.spec?.cruiseSpeed) || 0
|
||||
if (!v) return '—'
|
||||
const s = Math.round(length(r) / v)
|
||||
return s >= 60 ? `${Math.floor(s / 60)} min ${s % 60} s` : `${s} s`
|
||||
}
|
||||
function fmtWhen(iso) {
|
||||
const d = new Date(iso)
|
||||
return Number.isNaN(d.getTime()) ? '—' : d.toLocaleString()
|
||||
}
|
||||
const FINISH = { goHome: 'Return to home', hover: 'Hover', land: 'Land', firstWaypoint: 'Back to first WP' }
|
||||
const ACTION = { none: '—', photo: 'Photo', startRecord: 'Start recording', stopRecord: 'Stop recording' }
|
||||
|
||||
const stats = computed(() => {
|
||||
const all = routes.value
|
||||
return {
|
||||
count: all.length,
|
||||
mine: all.filter((r) => r.mine).length,
|
||||
waypoints: all.reduce((n, r) => n + points(r).length, 0),
|
||||
distance: all.reduce((m, r) => m + length(r), 0),
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mx-auto flex max-w-[1240px] flex-col gap-5 p-7">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<div>
|
||||
<div class="eyebrow">Mission library</div>
|
||||
<div class="mt-0.5 text-base font-semibold text-ink">Waypoint routes</div>
|
||||
</div>
|
||||
<div class="ml-auto flex items-center gap-2">
|
||||
<button class="btn-ghost inline-flex items-center gap-2" :disabled="loading" @click="loadAll">
|
||||
<Icon name="download" :size="14" /> Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-4 gap-4 max-[900px]:grid-cols-2">
|
||||
<div v-for="s in [
|
||||
{ label: 'Routes', value: stats.count },
|
||||
{ label: 'Yours', value: stats.mine },
|
||||
{ label: 'Waypoints', value: stats.waypoints },
|
||||
{ label: 'Total distance', value: fmtLength(stats.distance) },
|
||||
]" :key="s.label" class="panel p-5">
|
||||
<div class="eyebrow">{{ s.label }}</div>
|
||||
<div class="mt-2 text-[30px] font-bold leading-none tracking-tightest text-ink">{{ s.value }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loadErr" class="panel border-danger/40 p-4 text-sm text-danger-fg">{{ loadErr }}</div>
|
||||
|
||||
<div class="panel overflow-hidden p-0">
|
||||
<div v-if="loading && !routes.length" class="px-5 py-12 text-center text-sm text-ink-muted">Loading…</div>
|
||||
<div v-else-if="!routes.length" class="grid place-items-center px-5 py-16 text-center">
|
||||
<Icon name="route" :size="26" class="text-ink-muted" />
|
||||
<div class="mt-3 text-sm font-medium text-ink-secondary">No routes yet</div>
|
||||
<div class="mt-1 text-xs text-ink-muted">
|
||||
Save a route in the Fly App's Waypoints mission library while signed in and it shows up here.
|
||||
</div>
|
||||
</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 ['Route', 'Waypoints', 'Length', 'Est. time', 'Speed', 'On finish', 'Updated']" :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>
|
||||
<template v-for="r in routes" :key="r.id">
|
||||
<tr class="cursor-pointer border-b border-line last:border-0 hover:bg-surface-2"
|
||||
:class="openId === r.id ? 'bg-accent-soft' : ''" @click="openId = openId === r.id ? '' : r.id">
|
||||
<td class="px-5 py-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon name="chevronRight" :size="13" class="text-ink-muted transition"
|
||||
:class="openId === r.id ? 'rotate-90' : ''" />
|
||||
<span class="font-semibold text-ink">{{ r.name || 'Route' }}</span>
|
||||
<span v-if="!r.mine" class="inline-flex rounded-full bg-surface-2 px-2 py-0.5 text-[11px] font-semibold text-ink-secondary">
|
||||
another pilot
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-5 py-3 font-mono text-xs text-ink-secondary">{{ points(r).length }}</td>
|
||||
<td class="px-5 py-3 font-mono text-xs text-ink-secondary">{{ fmtLength(length(r)) }}</td>
|
||||
<td class="px-5 py-3 font-mono text-xs text-ink-secondary">{{ fmtDuration(r) }}</td>
|
||||
<td class="px-5 py-3 font-mono text-xs text-ink-secondary">{{ r.spec?.cruiseSpeed ?? '—' }} m/s</td>
|
||||
<td class="px-5 py-3 text-xs text-ink-secondary">{{ FINISH[r.spec?.finishAction] || r.spec?.finishAction || '—' }}</td>
|
||||
<td class="whitespace-nowrap px-5 py-3 text-xs text-ink-muted">{{ fmtWhen(r.updatedAt) }}</td>
|
||||
</tr>
|
||||
<tr v-if="openId === r.id" class="border-b border-line bg-surface-2">
|
||||
<td colspan="7" class="px-5 py-3">
|
||||
<table class="w-full text-xs">
|
||||
<thead>
|
||||
<tr class="text-left text-ink-muted">
|
||||
<th v-for="h in ['#', 'Latitude', 'Longitude', 'Altitude', 'Speed', 'Gimbal', 'Action']" :key="h"
|
||||
class="py-1 pr-4 font-mono text-[10.5px] font-normal uppercase tracking-caps">{{ h }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="font-mono text-ink-secondary">
|
||||
<tr v-for="(p, i) in points(r)" :key="i">
|
||||
<td class="py-1 pr-4">{{ i + 1 }}</td>
|
||||
<td class="py-1 pr-4">{{ Number(p.lat).toFixed(6) }}</td>
|
||||
<td class="py-1 pr-4">{{ Number(p.lon).toFixed(6) }}</td>
|
||||
<td class="py-1 pr-4">{{ p.altitude }} m</td>
|
||||
<td class="py-1 pr-4">{{ p.speed != null ? `${p.speed} m/s` : 'cruise' }}</td>
|
||||
<td class="py-1 pr-4">{{ p.gimbalPitch != null ? `${p.gimbalPitch}°` : '—' }}</td>
|
||||
<td class="py-1 pr-4">
|
||||
{{ ACTION[p.action] || p.action || '—' }}{{ p.hoverSeconds ? ` · hover ${p.hoverSeconds} s` : '' }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="mt-2 text-xs text-ink-muted">Edit this route in the Fly App; changes sync here.</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
Reference in new issue
Block a user