From 6a1d42e26d806dbfe4cbe65d205971e16be4e791 Mon Sep 17 00:00:00 2001 From: Pavel Sergienko <79020505+p-sergienko@users.noreply.github.com> Date: Fri, 29 May 2026 22:21:51 +0300 Subject: [PATCH 1/9] updated schema to handle dashboards --- prisma/schema.prisma | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d0afd0155..63eb14eae 100755 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -26,6 +26,7 @@ model User { tokens UserToken[] settings Json? @default("{}") presets UserPreset[] + dashboards Dashboard[] requests UserRequest[] lists UserTrackingList[] messages UserAcknowledgedMessages[] @@ -111,6 +112,16 @@ model UserTrackingList { presets UserPresetList[] } +model Dashboard { + id Int @id @default(autoincrement()) + name String + public Boolean @default(false) + json Json + userId Int + user User @relation(onDelete: Cascade, fields: [userId], references: [id]) + createdAt DateTime @default(now()) +} + enum AuthType { NAVIGRAPH VATSIM From 4e9de16ba9e8f0b5e35b3fa0f7fde194ec57c74f Mon Sep 17 00:00:00 2001 From: Pavel Sergienko <79020505+p-sergienko@users.noreply.github.com> Date: Fri, 29 May 2026 23:01:27 +0300 Subject: [PATCH 2/9] Add dashboard schema and settings with validation --- app/utils/shared/dashboard.ts | 64 +++++++++++++++++++ app/utils/shared/index.ts | 7 +- package.json | 1 + .../20260529194142_dashboards/migration.sql | 14 ++++ yarn.lock | 13 ++++ 5 files changed, 93 insertions(+), 6 deletions(-) create mode 100644 app/utils/shared/dashboard.ts create mode 100644 prisma/migrations/20260529194142_dashboards/migration.sql diff --git a/app/utils/shared/dashboard.ts b/app/utils/shared/dashboard.ts new file mode 100644 index 000000000..8d0ca67d4 --- /dev/null +++ b/app/utils/shared/dashboard.ts @@ -0,0 +1,64 @@ +import * as v from 'valibot'; +import { hexColorRegex } from '~/utils/shared'; + +export const MAX_DASHBOARD_AIRPORTS = 20; + +export const dashboardColumns = ['prefiles', 'departing', 'enroute', 'departed', 'arriving', 'landed'] as const; +export type DashboardColumn = typeof dashboardColumns[number]; + +export const dashboardMapLocations = ['right', 'left', 'above', 'below'] as const; +export type DashboardMapLocation = typeof dashboardMapLocations[number]; + +export const dashboardMapSizes = [25, 50, 75, 100] as const; +export type DashboardMapSize = typeof dashboardMapSizes[number]; + +export const dashboardDisplayModes = ['both', 'map', 'aircraft'] as const; +export type DashboardDisplayMode = typeof dashboardDisplayModes[number]; + +const icaoSchema = v.pipe(v.string(), v.trim(), v.toUpperCase(), v.regex(/^[A-Z0-9]{2,4}$/)); + +const enrouteCallsignSchema = v.pipe(v.string(), v.trim(), v.toUpperCase(), v.regex(/^(?=.{2,12}$)[A-Z0-9-]+(?:_[A-Z0-9-]+){0,2}$/)); + +const colorSchema = v.pipe(v.string(), v.regex(hexColorRegex)); + +const flightLevelSchema = v.pipe(v.number(), v.integer(), v.minValue(0), v.maxValue(999)); + +export const DashboardAirportSchema = v.object({ + icao: icaoSchema, + showInTrafficPrediction: v.optional(v.boolean(), false), + aircraftColor: v.optional(v.nullable(colorSchema)), +}); +export type DashboardAirport = v.InferOutput; + +export const DashboardSettingsSchema = v.object({ + airports: v.pipe( + v.array(DashboardAirportSchema), + v.minLength(1), + v.maxLength(MAX_DASHBOARD_AIRPORTS), + v.check(airports => new Set(airports.map(airport => airport.icao)).size === airports.length, 'Airport ICAOs must be unique'), + ), + mapLocation: v.optional(v.picklist(dashboardMapLocations), 'right'), + enrouteCallsign: v.optional(v.nullable(enrouteCallsignSchema)), + enrouteFlightLevel: v.optional(v.nullable(v.pipe( + v.object({ + from: flightLevelSchema, + to: flightLevelSchema, + }), + v.check(({ from, to }) => from <= to, 'Enroute FL "from" must be lower than or equal to "to"'), + ))), + mapSize: v.optional(v.picklist(dashboardMapSizes), 100), + displayMode: v.optional(v.picklist(dashboardDisplayModes), 'both'), + showMetar: v.optional(v.boolean(), true), + showArrivalTracks: v.optional(v.boolean(), true), + openColumns: v.optional(v.array(v.picklist(dashboardColumns)), () => [...dashboardColumns]), +}); +export type DashboardSettings = v.InferOutput; + +const dashboardNameSchema = v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(50)); + +export const DashboardSchema = v.object({ + name: dashboardNameSchema, + public: v.optional(v.boolean(), false), + json: DashboardSettingsSchema, +}); +export type DashboardPayload = v.InferOutput; diff --git a/app/utils/shared/index.ts b/app/utils/shared/index.ts index 59df0b1d9..538faf7cd 100644 --- a/app/utils/shared/index.ts +++ b/app/utils/shared/index.ts @@ -17,6 +17,7 @@ export const MAX_USER_LISTS = 5; export const MAX_LISTS_USERS = 200; export const MAX_FILTERS = 5; export const MAX_BOOKMARKS = 20; +export const MAX_DASHBOARDS = 10; export const MAX_FILTER_ARRAY_VALUE = 30; export const MAX_MAP_ZOOM = 20; @@ -28,12 +29,6 @@ export function isProductionMode() { return typeof process !== 'undefined' ? process.env.DOMAIN === 'https://vatsim-radar.com' : useRuntimeConfig().public.DOMAIN === 'https://vatsim-radar.com'; } -export function isRunwayEast(runway: string | number) { - if (typeof runway === 'string') runway = parseInt(runway, 10); - - return runway > 16; -} - export interface FilterAltitudeConfig { strategy: 'below' | 'above'; altitude: number; diff --git a/package.json b/package.json index f493ffe3b..275e264e6 100644 --- a/package.json +++ b/package.json @@ -79,6 +79,7 @@ "srvx": "^0.11.16", "svgo": "^4.0.1", "ua-parser-js": "2.0.10", + "valibot": "^1.4.1", "vite-plugin-pwa": "^1.3.0", "vite-svg-loader": "^5.1.1", "vitepress": "^1.6.4", diff --git a/prisma/migrations/20260529194142_dashboards/migration.sql b/prisma/migrations/20260529194142_dashboards/migration.sql new file mode 100644 index 000000000..ac1e9ff57 --- /dev/null +++ b/prisma/migrations/20260529194142_dashboards/migration.sql @@ -0,0 +1,14 @@ +-- CreateTable +CREATE TABLE `Dashboard` ( + `id` INTEGER NOT NULL AUTO_INCREMENT, + `name` VARCHAR(191) NOT NULL, + `public` BOOLEAN NOT NULL DEFAULT false, + `json` JSON NOT NULL, + `userId` INTEGER NOT NULL, + `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- AddForeignKey +ALTER TABLE `Dashboard` ADD CONSTRAINT `Dashboard_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/yarn.lock b/yarn.lock index 9e644ce44..46f0d9c96 100644 --- a/yarn.lock +++ b/yarn.lock @@ -16151,6 +16151,18 @@ __metadata: languageName: node linkType: hard +"valibot@npm:^1.4.1": + version: 1.4.1 + resolution: "valibot@npm:1.4.1" + peerDependencies: + typescript: ">=5" + peerDependenciesMeta: + typescript: + optional: true + checksum: 10c0/8a492d9c6d21032abea4fa29eafa355e5c31f1df6a3ba4b207abf20433c00b66b30884f60b225c3348113a327f20bb19e01e9b7771663e462cf94398ff39d233 + languageName: node + linkType: hard + "varint@npm:^6.0.0": version: 6.0.0 resolution: "varint@npm:6.0.0" @@ -16242,6 +16254,7 @@ __metadata: tsx: "npm:4.22.3" typescript: "npm:6.0.3" ua-parser-js: "npm:2.0.10" + valibot: "npm:^1.4.1" vite-plugin-eslint2: "npm:^5.1.0" vite-plugin-pwa: "npm:^1.3.0" vite-svg-loader: "npm:^5.1.1" From 463ec736f297b5738acaa3ad80d33a05aff57e08 Mon Sep 17 00:00:00 2001 From: Pavel Sergienko <79020505+p-sergienko@users.noreply.github.com> Date: Fri, 29 May 2026 23:19:59 +0300 Subject: [PATCH 3/9] dashboard api --- app/composables/fetchers/dashboards.ts | 50 ++++ app/store/index.ts | 5 + app/utils/server/handlers/dashboards.ts | 320 ++++++++++++++++++++++++ server/api/data/dashboard/[id].ts | 3 + server/api/user/dashboards/[...id].ts | 3 + server/api/user/dashboards/index.ts | 3 + server/api/user/dashboards/validate.ts | 3 + 7 files changed, 387 insertions(+) create mode 100644 app/composables/fetchers/dashboards.ts create mode 100644 app/utils/server/handlers/dashboards.ts create mode 100644 server/api/data/dashboard/[id].ts create mode 100644 server/api/user/dashboards/[...id].ts create mode 100644 server/api/user/dashboards/index.ts create mode 100644 server/api/user/dashboards/validate.ts diff --git a/app/composables/fetchers/dashboards.ts b/app/composables/fetchers/dashboards.ts new file mode 100644 index 000000000..85cc7055d --- /dev/null +++ b/app/composables/fetchers/dashboards.ts @@ -0,0 +1,50 @@ +import { toRaw } from 'vue'; +import { useStore } from '~/store'; +import type { UserDashboard } from '~/utils/server/handlers/dashboards'; +import type { DashboardPayload } from '~/utils/shared/dashboard'; + +export async function createDashboard(payload: DashboardPayload, force = false) { + const store = useStore(); + + const result = await $fetch<{ id: number }>(`/api/user/dashboards${ force ? '?force=1' : '' }`, { + method: 'POST', + body: toRaw(payload), + }); + await store.fetchDashboards(); + + return result; +} + +export async function updateDashboard(id: number, payload: Partial, force = false) { + const store = useStore(); + + const result = await $fetch<{ id: number }>(`/api/user/dashboards/${ id }${ force ? '?force=1' : '' }`, { + method: 'PUT', + body: toRaw(payload), + }); + await store.fetchDashboards(); + + return result; +} + +export async function deleteDashboard(id: number) { + const store = useStore(); + + const result = await $fetch(`/api/user/dashboards/${ id }`, { + method: 'DELETE', + }); + await store.fetchDashboards(); + + return result; +} + +export async function validateDashboard(payload: DashboardPayload) { + return $fetch<{ status: 'ok' }>('/api/user/dashboards/validate', { + method: 'POST', + body: toRaw(payload), + }); +} + +export function getUserDashboards(): UserDashboard[] { + return useStore().dashboards; +} diff --git a/app/store/index.ts b/app/store/index.ts index ac7e2c62a..f3fca245d 100644 --- a/app/store/index.ts +++ b/app/store/index.ts @@ -23,6 +23,7 @@ import type { IEngine } from 'ua-parser-js'; import { isFetchError } from '~/utils/shared'; import type { UserMessageType } from '~/utils/shared'; import type { UserBookmarkPreset } from '~/utils/server/handlers/bookmarks'; +import type { UserDashboard } from '~/utils/server/handlers/dashboards'; import { useIsDebug } from '~/composables'; import { clientDB } from '~/composables/render/idb'; import type { PartialRecord } from '~/types'; @@ -68,6 +69,7 @@ export const useStore = defineStore('index', { activeFilter: {} as UserFilter, filterPresets: [] as UserFilterPreset[], bookmarks: [] as UserBookmarkPreset[], + dashboards: [] as UserDashboard[], config: {} as SiteConfig, events: [] as VatsimActiveEvent[], @@ -410,5 +412,8 @@ export const useStore = defineStore('index', { async fetchBookmarks() { this.bookmarks = await $fetch('/api/user/bookmarks'); }, + async fetchDashboards() { + this.dashboards = await $fetch('/api/user/dashboards'); + }, }, }); diff --git a/app/utils/server/handlers/dashboards.ts b/app/utils/server/handlers/dashboards.ts new file mode 100644 index 000000000..b495efdf0 --- /dev/null +++ b/app/utils/server/handlers/dashboards.ts @@ -0,0 +1,320 @@ +import type { H3Event } from 'h3'; +import type { Dashboard } from '#prisma'; +import { safeParse } from 'valibot'; +import { findUserByCookie } from '~/utils/server/user'; +import { freezeH3Request, handleH3Error, handleH3Exception, unfreezeH3Request } from '~/utils/server/h3'; +import { prisma } from '~/utils/server/prisma'; +import { MAX_DASHBOARDS } from '~/utils/shared'; +import { radarStorage } from '~/utils/server/storage'; +import { DashboardSettingsSchema } from '~/utils/shared/dashboard'; +import type { DashboardSettings } from '~/utils/shared/dashboard'; + +export type UserDashboard = Omit & { + json: DashboardSettings; +}; + +export type PublicDashboard = Omit & { + owner: boolean; +}; + +type ValidateResult = + | { error: string } + | { settings: DashboardSettings }; + +function validateDashboardSettings(json: unknown): ValidateResult { + const parsed = safeParse(DashboardSettingsSchema, json); + + if (!parsed.success) { + return { error: parsed.issues[0]?.message ?? 'Invalid dashboard settings' }; + } + + const realIcao = radarStorage.vatspy?.data?.keyAirports.realIcao; + + for (const airport of parsed.output.airports) { + if (!realIcao?.[airport.icao]) { + return { error: `Unknown airport ICAO: ${ airport.icao }` }; + } + } + + return { settings: parsed.output }; +} + +export async function handleDashboardsEvent(event: H3Event) { + let userId: number | undefined; + + const isValidate = event.path.endsWith('validate'); + + try { + const user = await findUserByCookie(event); + + if (!user && !isValidate) { + return handleH3Error({ + event, + statusCode: 401, + }); + } + + userId = user?.id; + if (user && await freezeH3Request(event, user.id) !== true) return; + + const id = getRouterParam(event, 'id'); + + if (id && event.method !== 'GET' && event.method !== 'PUT' && event.method !== 'DELETE') { + return handleH3Error({ + event, + statusCode: 400, + data: 'Only PUT, DELETE and GET are allowed when using id', + }); + } + else if (!id && event.method !== 'GET' && event.method !== 'POST') { + return handleH3Error({ + event, + statusCode: 400, + data: 'Only POST is allowed when not using id', + }); + } + + const dashboards = (!user && isValidate) + ? [] + : await prisma.dashboard.findMany({ + where: { + userId: user!.id, + }, + orderBy: [ + { + createdAt: 'asc', + }, + { + id: 'asc', + }, + ], + }); + + let dashboard: Dashboard | null = null; + + if (id) { + dashboard = dashboards.find(x => x.id === +id) ?? null; + + if (!dashboard) { + return handleH3Error({ + event, + statusCode: 400, + data: 'This dashboard was not found for your user ID', + }); + } + } + + if (event.method === 'POST' || event.method === 'PUT') { + const body = await readBody>(event); + if (!body) { + return handleH3Error({ + event, + statusCode: 400, + data: 'You must pass body to this route', + }); + } + + if (!body.name && !dashboard && !isValidate) { + return handleH3Error({ + event, + statusCode: 400, + data: 'Name is required when creating a dashboard', + }); + } + + if (body.name && body.name.trim().length > 50) { + return handleH3Error({ + event, + statusCode: 400, + data: 'Max name length is 50', + }); + } + + if (!body.json && !dashboard) { + return handleH3Error({ + event, + statusCode: 400, + data: 'Json is required when creating a dashboard', + }); + } + + let jsonToStore: Record | undefined; + + if (body.json) { + const result = validateDashboardSettings(body.json); + + if ('error' in result) { + return handleH3Error({ + event, + statusCode: 400, + data: result.error, + }); + } + + jsonToStore = result.settings as Record; + } + + if (body.name) { + const duplicatedDashboard = dashboards.find(x => x.id !== dashboard?.id && x.name.toLowerCase().trim() === body.name?.toLowerCase().trim()); + + if (duplicatedDashboard) { + if (getQuery(event).force === '1') { + await prisma.dashboard.delete({ + where: { + id: duplicatedDashboard.id, + }, + }); + } + else { + return handleH3Error({ + event, + statusCode: 409, + data: 'A dashboard with this name already exists', + }); + } + } + } + + if (isValidate) { + return { + status: 'ok', + }; + } + + if (dashboard) { + await prisma.dashboard.update({ + where: { + id: dashboard.id, + }, + data: { + name: body.name?.trim() ?? dashboard.name, + public: body.public ?? dashboard.public, + json: jsonToStore ?? (dashboard.json as Record), + }, + }); + + return { + id: dashboard.id, + }; + } + else { + const userDashboards = await prisma.dashboard.count({ + where: { + userId: user!.id, + }, + }); + + if (userDashboards >= MAX_DASHBOARDS) { + return handleH3Error({ + event, + statusCode: 400, + data: `Only ${ MAX_DASHBOARDS } dashboards are allowed`, + }); + } + + const created = await prisma.dashboard.create({ + data: { + userId: user!.id, + name: (body.name as string).trim(), + public: body.public ?? false, + json: jsonToStore!, + }, + select: { + id: true, + }, + }); + + return { + id: created.id, + }; + } + } + else if (event.method === 'DELETE' && dashboard) { + await prisma.dashboard.delete({ + where: { + id: dashboard.id, + }, + }); + + return { + status: 'ok', + }; + } + else if (event.method === 'GET') { + if (id) { + return dashboard; + } + else { + return dashboards; + } + } + else { + return handleH3Error({ + event, + statusCode: 400, + data: 'Incorrect method received', + }); + } + } + catch (e) { + return handleH3Exception(event, e); + } + finally { + if (userId) { + unfreezeH3Request(userId); + } + } +} + +// Public endpoint for a single dashboard: returns it if it is public or the requester is its +// author, otherwise 403. Auth-aware via cookie so authors can preview their own private boards. +export async function handlePublicDashboardEvent(event: H3Event) { + const id = getRouterParam(event, 'id'); + + if (!id || isNaN(+id)) { + return handleH3Error({ + event, + statusCode: 400, + data: 'Invalid dashboard id', + }); + } + + try { + const dashboard = await prisma.dashboard.findUnique({ + where: { + id: +id, + }, + }); + + if (!dashboard) { + return handleH3Error({ + event, + statusCode: 404, + data: 'Dashboard not found', + }); + } + + const user = await findUserByCookie(event); + const owner = !!user && dashboard.userId === user.id; + + if (!dashboard.public && !owner) { + return handleH3Error({ + event, + statusCode: 403, + data: 'This dashboard is private', + }); + } + + return { + id: dashboard.id, + name: dashboard.name, + public: dashboard.public, + createdAt: dashboard.createdAt, + json: dashboard.json as DashboardSettings, + owner, + } satisfies PublicDashboard; + } + catch (e) { + return handleH3Exception(event, e); + } +} diff --git a/server/api/data/dashboard/[id].ts b/server/api/data/dashboard/[id].ts new file mode 100644 index 000000000..3b401af1f --- /dev/null +++ b/server/api/data/dashboard/[id].ts @@ -0,0 +1,3 @@ +import { handlePublicDashboardEvent } from '~/utils/server/handlers/dashboards'; + +export default defineEventHandler(handlePublicDashboardEvent); diff --git a/server/api/user/dashboards/[...id].ts b/server/api/user/dashboards/[...id].ts new file mode 100644 index 000000000..83c96c67c --- /dev/null +++ b/server/api/user/dashboards/[...id].ts @@ -0,0 +1,3 @@ +import { handleDashboardsEvent } from '~/utils/server/handlers/dashboards'; + +export default defineEventHandler(handleDashboardsEvent); diff --git a/server/api/user/dashboards/index.ts b/server/api/user/dashboards/index.ts new file mode 100644 index 000000000..83c96c67c --- /dev/null +++ b/server/api/user/dashboards/index.ts @@ -0,0 +1,3 @@ +import { handleDashboardsEvent } from '~/utils/server/handlers/dashboards'; + +export default defineEventHandler(handleDashboardsEvent); diff --git a/server/api/user/dashboards/validate.ts b/server/api/user/dashboards/validate.ts new file mode 100644 index 000000000..83c96c67c --- /dev/null +++ b/server/api/user/dashboards/validate.ts @@ -0,0 +1,3 @@ +import { handleDashboardsEvent } from '~/utils/server/handlers/dashboards'; + +export default defineEventHandler(handleDashboardsEvent); From 00aa8bad9fb5c675141d2af279a2263be9f164c8 Mon Sep 17 00:00:00 2001 From: Pavel Sergienko <79020505+p-sergienko@users.noreply.github.com> Date: Fri, 29 May 2026 23:29:54 +0300 Subject: [PATCH 4/9] dashboard composables --- app/composables/dashboard.ts | 64 +++++++++++++++++++++++++++++++++++ app/store/index.ts | 2 ++ app/utils/shared/dashboard.ts | 9 +++++ 3 files changed, 75 insertions(+) create mode 100644 app/composables/dashboard.ts diff --git a/app/composables/dashboard.ts b/app/composables/dashboard.ts new file mode 100644 index 000000000..45d863cad --- /dev/null +++ b/app/composables/dashboard.ts @@ -0,0 +1,64 @@ +import { computed } from 'vue'; +import { useStore } from '~/store'; +import type { MapAircraftKeys } from '~/types/map'; +import type { DashboardAirport, DashboardColumn } from '~/utils/shared/dashboard'; + +export type DashboardAircraftKey = MapAircraftKeys | 'enroute'; + +const columnToAircraftKey: Record = { + prefiles: 'prefiles', + departing: 'groundDep', + enroute: 'enroute', + departed: 'departures', + arriving: 'arrivals', + landed: 'groundArr', +}; + +export function mapDashboardColumnToAircraftKey(column: DashboardColumn): DashboardAircraftKey { + return columnToAircraftKey[column]; +} + +export function mapDashboardColumnsToAircraftKeys(columns: DashboardColumn[]): DashboardAircraftKey[] { + return columns.map(mapDashboardColumnToAircraftKey); +} + +export function useDashboard() { + const store = useStore(); + + const airportsMap = computed(() => { + const map = new Map(); + for (const airport of store.activeDashboard?.airports ?? []) { + map.set(airport.icao, airport); + } + return map; + }); + + function getAirportAircraftColor(icao: string): string | null { + return airportsMap.value.get(icao)?.aircraftColor ?? null; + } + + function isPredictionAirport(icao: string): boolean { + return airportsMap.value.get(icao)?.showInTrafficPrediction === true; + } + + function isColoredAirport(icao: string): boolean { + const airport = airportsMap.value.get(icao); + if (!airport) return false; + return !!airport.aircraftColor || airport.showInTrafficPrediction === true; + } + + function sortByPriorityAirports(items: T[], getIcao: (item: T) => string = item => item as unknown as string): T[] { + return items.slice().sort((a, b) => Number(isColoredAirport(getIcao(b))) - Number(isColoredAirport(getIcao(a)))); + } + + return { + activeDashboard: computed(() => store.activeDashboard), + airportsMap, + getAirportAircraftColor, + isPredictionAirport, + isColoredAirport, + sortByPriorityAirports, + mapDashboardColumnToAircraftKey, + mapDashboardColumnsToAircraftKeys, + }; +} diff --git a/app/store/index.ts b/app/store/index.ts index f3fca245d..aa0bd9303 100644 --- a/app/store/index.ts +++ b/app/store/index.ts @@ -24,6 +24,7 @@ import { isFetchError } from '~/utils/shared'; import type { UserMessageType } from '~/utils/shared'; import type { UserBookmarkPreset } from '~/utils/server/handlers/bookmarks'; import type { UserDashboard } from '~/utils/server/handlers/dashboards'; +import type { DashboardSettings } from '~/utils/shared/dashboard'; import { useIsDebug } from '~/composables'; import { clientDB } from '~/composables/render/idb'; import type { PartialRecord } from '~/types'; @@ -70,6 +71,7 @@ export const useStore = defineStore('index', { filterPresets: [] as UserFilterPreset[], bookmarks: [] as UserBookmarkPreset[], dashboards: [] as UserDashboard[], + activeDashboard: null as DashboardSettings | null, config: {} as SiteConfig, events: [] as VatsimActiveEvent[], diff --git a/app/utils/shared/dashboard.ts b/app/utils/shared/dashboard.ts index 8d0ca67d4..807834469 100644 --- a/app/utils/shared/dashboard.ts +++ b/app/utils/shared/dashboard.ts @@ -6,6 +6,15 @@ export const MAX_DASHBOARD_AIRPORTS = 20; export const dashboardColumns = ['prefiles', 'departing', 'enroute', 'departed', 'arriving', 'landed'] as const; export type DashboardColumn = typeof dashboardColumns[number]; +export const dashboardColumnLabels: Record = { + prefiles: 'Prefiles', + departing: 'Departing', + enroute: 'Enroute', + departed: 'Departed', + arriving: 'Arriving', + landed: 'Landed', +}; + export const dashboardMapLocations = ['right', 'left', 'above', 'below'] as const; export type DashboardMapLocation = typeof dashboardMapLocations[number]; From e39122b9aef3e8dc6f5ac7fa381afab5e9ad4f8d Mon Sep 17 00:00:00 2001 From: Pavel Sergienko <79020505+p-sergienko@users.noreply.github.com> Date: Sat, 30 May 2026 11:15:00 +0300 Subject: [PATCH 5/9] dashboard page --- .../features/dashboard/DashboardEditPopup.vue | 90 ++++ .../map/overlays/MapOverlayAirport.vue | 16 +- app/pages/airport/[icao].vue | 491 +----------------- app/pages/dashboard/index.vue | 174 +++++++ 4 files changed, 278 insertions(+), 493 deletions(-) create mode 100644 app/components/features/dashboard/DashboardEditPopup.vue create mode 100644 app/pages/dashboard/index.vue diff --git a/app/components/features/dashboard/DashboardEditPopup.vue b/app/components/features/dashboard/DashboardEditPopup.vue new file mode 100644 index 000000000..3e6e218f9 --- /dev/null +++ b/app/components/features/dashboard/DashboardEditPopup.vue @@ -0,0 +1,90 @@ + + + + + diff --git a/app/components/map/overlays/MapOverlayAirport.vue b/app/components/map/overlays/MapOverlayAirport.vue index 189bfb879..3de997197 100644 --- a/app/components/map/overlays/MapOverlayAirport.vue +++ b/app/components/map/overlays/MapOverlayAirport.vue @@ -248,9 +248,15 @@ - Dashboard + Airport + + + + Create Dashboard { } &_question { - align-self: center; align-self: flex-start; margin-top: 3px; } diff --git a/app/pages/airport/[icao].vue b/app/pages/airport/[icao].vue index 6519a5e41..bdf8b95a9 100644 --- a/app/pages/airport/[icao].vue +++ b/app/pages/airport/[icao].vue @@ -47,114 +47,13 @@ width="200px" /> -
- - Controller Mode - -
Arrival Tracks
-
- -
-
- - Show pilot stats - -
- -
-
-
-
- Time -
- -
- {{ formatDateDime.format(dataStore.time.value) }}z -
-
-
-
-
-
- {{previousQnh?.value}} - {{currentQnh.value}} {{currentQnh.unit}} -
-
-
- {{currentAtisLetter.departure}} -
-
- {{currentAtisLetter.arrival}} -
-
-
-
- -
- {{showPreviousMetar ? previousMetar : currentMetar}} -
-
-
+
@@ -231,51 +130,6 @@
-
-
-
-
-
-
- {{ column.title }} -
-
- -
- - {{ aircraft?.[column.key]?.length ?? 0 }} - -
-
- -
-
-
{ } }); -const formatDateDime = new Intl.DateTimeFormat('en-GB', { - timeZone: 'UTC', - hour: '2-digit', - minute: '2-digit', -}); - const aircraftMode = ref(null); const aircraftModes: SelectItem[] = [ { @@ -453,13 +296,6 @@ const aircraftModes: SelectItem[] = [ }, ]; -const displayedColumns = ref(['prefiles', 'groundDep', 'departures', 'arrivals', 'groundArr']); -// const displayedColumns = useCookie('dashboard-displayed-columns', { -// sameSite: 'strict', -// secure: true, -// default: () => ['prefiles', 'groundDep', 'departures', 'arrivals', 'groundArr'], -// }); - watch(() => dataStore.navigraphProcedures.value[airportData.value?.icao ?? ''], async () => { if (airportMapFrame.value) { await sleep(1000); @@ -472,29 +308,6 @@ watch(() => dataStore.navigraphProcedures.value[airportData.value?.icao ?? ''], deep: 3, }); -const displayableColumns: SelectItem[] = [ - { - value: 'prefiles', - text: 'Prefiles', - }, - { - value: 'groundDep', - text: 'Departing', - }, - { - value: 'departures', - text: 'Departed', - }, - { - value: 'arrivals', - text: 'Arriving', - }, - { - value: 'groundArr', - text: 'Landed', - }, -]; - type MapMode = 'default' | 'dashBigMapBig' | 'dashSmallMapBig' | 'dashBigMapSmall' | 'dashOnly' | 'mapOnly'; const mapMode = useCookie('dashboard-map-mode', { sameSite: 'none', @@ -504,14 +317,6 @@ const mapMode = useCookie('dashboard-map-mode', { path: '/', }); -const controllerMode = useCookie('controller-mode', { - sameSite: 'none', - secure: true, - watch: false, - default: () => false, - path: '/', -}); - const isMobileOrTablet = useIsMobileOrTablet(); function calculateMapLayout(height: number, type: 'dash' | 'map' | 'default' | 'alone') { @@ -520,7 +325,7 @@ function calculateMapLayout(height: number, type: 'dash' | 'map' | 'default' | ' if (isMobileOrTablet.value) return `${ height }vh`; let calculatedHeight = `calc(${ height }vh`; - if (type === 'dash') calculatedHeight += ` - (32px + 56px) - 40px - ${ controllerMode.value ? '32px - 16px' : '0px' } - 16px)`; + if (type === 'dash') calculatedHeight += ` - (32px + 56px) - 40px - 16px)`; else if (type === 'map') calculatedHeight += ` - 16px)`; else if (type === 'alone') calculatedHeight += ` - (32px + 56px) - 16px)`; else calculatedHeight += ')'; @@ -582,42 +387,6 @@ const mapModes: SelectItem[] = [ }, ]; -const controllerColumns = computed(() => { - return displayableColumns.filter(x => !displayedColumns.value.length || displayedColumns.value.includes(x.value)).map(x => { - // getPilotStatus - - let color: string; - let darkColor = false; - - switch (x.value) { - case 'prefiles': - color = radarColors.lightGray600; - darkColor = store.theme === 'default'; - break; - case 'groundDep': - color = radarColors.green500; - break; - case 'departures': - color = radarColors.blue700; - break; - case 'arrivals': - color = radarColors.orange500; - darkColor = true; - break; - case 'groundArr': - color = radarColors.error300; - break; - } - - return { - title: x.text, - key: x.value, - color, - darkColor, - }; - }); -}); - const arrivalTracks = useCookie('controller-arrival-tracks', { sameSite: 'none', secure: true, @@ -626,17 +395,12 @@ const arrivalTracks = useCookie('controller-arrival-tracks', { path: '/', }); -const showPilotStats = useShowPilotStats(); - const settings = computed(() => ({ zoom: mapQuery.value?.zoom, aircraft: aircraftMode.value, info: airportTab.value, weather: weatherTab.value, - columns: displayedColumns.value.join(','), mode: mapMode.value, - controller: Number(controllerMode.value).toString(), - stats: Number(showPilotStats.value).toString(), tracks: Number(arrivalTracks.value).toString(), }) satisfies Record); @@ -648,8 +412,6 @@ onMounted(() => { const query = route.query[setting]; if (typeof query !== 'string' || !query.trim()) continue; - const arr = query.split(','); - switch (setting) { case 'aircraft': if (aircraftModes.some(x => x.value === query)) aircraftMode.value = query as any; @@ -660,15 +422,6 @@ onMounted(() => { case 'weather': if (query === 'metar' || query === 'taf') weatherTab.value = query as any; break; - case 'columns': - if (arr.every(x => displayedColumns.value.includes(x as any))) displayedColumns.value = arr as any; - break; - case 'controller': - controllerMode.value = query === '1'; - break; - case 'stats': - showPilotStats.value = query === '1'; - break; case 'tracks': arrivalTracks.value = query === '1'; break; @@ -712,50 +465,6 @@ useLazyAsyncData(async () => { server: false, }); -type ATISChange = { departure: string | null; arrival: string | null } | null; - -const changesAck = ref(true); - -const currentAtisLetter = computed(() => { - if (!airportData.value) return null; - const atis = atc.value?.filter(x => x.isATIS || x.facility === -1); - - const departure = atis?.length === 1 ? atis[0].atis_code ?? null : (atis?.find(x => x.callsign.endsWith('D_ATIS')) ?? atis[0])?.atis_code ?? null; - - return { - departure, - arrival: atis?.length === 1 ? null : (atis?.find(x => departure && x.callsign.endsWith('A_ATIS')) ?? atis[1])?.atis_code ?? null, - }; -}); - -const previousQnh = shallowRef(null); -const currentQnh = computed(() => { - if (!airportData.value?.airport?.metar) return null; - const parsedMetar = parseMetar(airportData.value?.airport?.metar); - return parsedMetar.altimeter; -}); - -const previousMetar = ref(null); -const showPreviousMetar = ref(false); -const currentMetar = computed(() => { - return airportData.value?.airport?.metar ?? null; -}); - -watch(currentMetar, (_, oldVal) => { - if (!oldVal) return; - previousMetar.value = oldVal; -}); - -watch(() => currentQnh.value?.value, (_, oldVal) => { - if (oldVal && oldVal !== previousQnh.value?.value) { - previousQnh.value = { - ...currentQnh.value!, - value: oldVal, - }; - changesAck.value = false; - } -}); - const loadingData = ref(false); async function refreshData() { @@ -839,88 +548,6 @@ await setupDataFetch({ &_section { position: relative; - &--themed { - position: relative; - - display: flex; - gap: 12px; - align-items: center; - - padding: 8px 12px !important; - border-radius: 8px; - - font-size: 11px; - - background: $black; - - &::after { - left: calc(100% + 16px) !important; - } - - &:not(:last-child) { - margin-right: 16px; - } - - &_section, &_section_item { - @include pc { - position: relative; - - &:not(:last-child) { - padding-right: 12px; - - &::after { - content: ''; - - position: absolute; - top: calc(50% - 8px); - left: 100%; - - width: 1px; - height: 16px; - - background: varToRgba('lightGray500', 0.2); - } - } - } - } - - &_section { - display: flex; - gap: 6px; - align-items: center; - - s, strong { - font-size: 13px; - } - - s { - cursor: pointer; - color: $lightGray600; - opacity: 0.5; - } - - strong { - font-weight: 600; - color: $blue500; - } - - &_item { - &::after { - top: calc(50% - 2px) !important; - left: calc(100% - 2px) !important; - - width: 4px !important; - height: 4px !important; - border-radius: 100%; - } - - &:not(:last-child) { - padding-right: 6px; - } - } - } - } - @include pc { &:not(:last-child) { padding-right: 16px; @@ -962,69 +589,6 @@ await setupDataFetch({ } } - &__metar { - display: flex; - gap: 4px; - align-items: center; - - &_arrow { - cursor: pointer; - - display: flex; - align-items: center; - justify-content: center; - - width: 32px; - min-width: 32px; - height: 32px; - border-radius: 8px; - - background: $darkGray700; - - @include hover { - transition: 0.3s; - - &:hover { - background: $darkGray600; - } - } - - &--disabled { - opacity: 0.5; - background: $darkGray500; - - &, svg { - pointer-events: none; - cursor: default; - } - } - - svg { - transform: rotate(90deg); - width: 10px; - } - - &--prev svg { - transform: rotate(-90deg); - } - } - - &_text { - user-select: all; - - display: flex; - align-items: center; - - min-height: 32px; - padding: 4px 12px; - border-radius: 8px; - - font-size: 12px; - - background: $darkGray700; - } - } - &_sections { overflow: auto; display: flex; @@ -1035,26 +599,6 @@ await setupDataFetch({ flex-direction: column; height: auto; } - - &--aircraft-cols-4, &--aircraft-cols-5 { - :deep(.aircraft_list) { - flex-direction: column; - flex-wrap: nowrap; - } - - @include mobile { - flex-direction: row; - height: var(--dashboard-height); - - .airport_column { - min-width: 400px; - - @include mobileOnly { - min-width: 80vw; - } - } - } - } } &_column { @@ -1133,35 +677,6 @@ await setupDataFetch({ } } - &__aircraft-title { - display: flex; - gap: 8px; - align-items: center; - justify-content: space-between; - - width: 100%; - - color: var(--color); - - & &_bubble { - background: var(--color); - - &--dark { - color: $darkGray400Orig; - } - } - - &_interval { - display: flex; - gap: 12px; - - font-size: 14px; - font-weight: 700; - line-height: 100%; - } - - } - &_map { display: flex; gap: 16px; diff --git a/app/pages/dashboard/index.vue b/app/pages/dashboard/index.vue new file mode 100644 index 000000000..db83b6fd2 --- /dev/null +++ b/app/pages/dashboard/index.vue @@ -0,0 +1,174 @@ + + + + + From f5ac74811463ad0031e2a8ff712061c3c33183f3 Mon Sep 17 00:00:00 2001 From: Pavel Sergienko <79020505+p-sergienko@users.noreply.github.com> Date: Sat, 30 May 2026 11:51:23 +0300 Subject: [PATCH 6/9] dashboard edit popup --- .../features/dashboard/DashboardEditPopup.vue | 591 +++++++++++++++++- app/pages/dashboard/index.vue | 12 +- app/utils/shared/dashboard.ts | 4 +- 3 files changed, 576 insertions(+), 31 deletions(-) diff --git a/app/components/features/dashboard/DashboardEditPopup.vue b/app/components/features/dashboard/DashboardEditPopup.vue index 3e6e218f9..0bade4444 100644 --- a/app/components/features/dashboard/DashboardEditPopup.vue +++ b/app/components/features/dashboard/DashboardEditPopup.vue @@ -1,33 +1,233 @@