Skip to main content

Web App Overview

The Apollon web app is a Next.js 16 application that provides a real-time GPS tracking dashboard for logistics and cargo operations. It connects to the Rust API via REST proxy routes (GraphQL under the hood) and WebSocket for real-time data.

Tech Stack

TechnologyPurpose
Next.js 16React framework with App Router + Turbopack
React 19UI rendering
TypeScriptType-safe application code
Tailwind CSS 4Utility-first styling
shadcn/uiAccessible UI components (Button, Card, Input, Select, Badge, etc.)
MapLibre GLInteractive maps with multiple tile providers (Carto, OpenFreeMap)
ZustandClient-side state management
MotionSpring-based animations (speed overlay)
@elcto/apiShared GraphQL client, WebSocket client, types
@elcto/uiShared components (Map, MapMarker, MapRoute, Spinner, ThemeToggle, AIS Vessel Markers)

Data Flow

Browser → fetch('/api/*') → Next.js API Route → gql() → Rust API /v1/gql
Browser → WebSocket → Rust API /v1/ws (direct, no proxy)
  • REST Proxy: Next.js API routes call gql() from @elcto/api, transforming GraphQL responses to REST
  • WebSocket: Browser connects directly to Rust API for real-time GPS, speed, trip, and AIS vessel data via channels
  • No GraphQL client in browser — frontend uses only fetch() against /api/* proxy routes

Project Structure

platform/web/
├── src/
│ ├── app/
│ │ ├── globals.css # Tailwind + shadcn theme
│ │ ├── layout.tsx # Root layout (Roboto font)
│ │ ├── (app)/ # Main app with navbar
│ │ │ ├── layout.tsx # Navbar + ThemeToggle
│ │ │ ├── page.tsx # Dashboard
│ │ │ ├── gps/page.tsx # GPS tracker
│ │ │ ├── trips/
│ │ │ │ ├── page.tsx # Trip list (filterable)
│ │ │ │ ├── new/page.tsx # Create trip
│ │ │ │ ├── [id]/page.tsx # Trip detail
│ │ │ │ ├── [id]/edit/page.tsx # Edit trip
│ │ │ │ └── categories/page.tsx
│ │ │ └── clients/
│ │ │ ├── page.tsx # Client list
│ │ │ ├── new/page.tsx # Create client
│ │ │ └── [id]/page.tsx # Client detail
│ │ ├── (overlay)/ # Overlay layout (no nav)
│ │ │ └── overlay/
│ │ │ ├── speed/page.tsx # Speed overlay (OBS)
│ │ │ ├── map/page.tsx # Map overlay (OBS, URL params)
│ │ │ └── compass/page.tsx # Compass overlay (OBS)
│ │ └── api/ # REST proxy routes
│ │ ├── gps/ # GPS endpoints
│ │ ├── trips/ # Trip CRUD
│ │ ├── clients/ # Client CRUD
│ │ └── categories/ # Category CRUD
│ ├── components/ui/ # shadcn/ui components
│ ├── hooks/
│ │ ├── use-spring.ts # Spring animation hook
│ │ └── use-interpolated-position.ts # Dead reckoning GPS interpolation
│ └── lib/
│ ├── api.ts # Client-side fetch wrapper
│ └── utils/ # Formatting helpers
├── components.json # shadcn configuration
├── next.config.ts
├── postcss.config.mjs
└── tsconfig.json

Route Groups

The app uses Next.js route groups to provide different layouts:

  • (app) — Main application with navigation bar and standard layout. All CRUD pages live here.
  • (overlay) — Minimal layout without navigation. Used for embeddable overlays: speed display, live map, and compass (OBS browser source).

Development

just dev-web # Start dev server (port 5001)
just watch-web # Same as dev-web
just build-web # Production build
just typecheck-web # TypeScript check
just lint-web # ESLint

Configuration

VariableDefaultDescription
NEXT_PUBLIC_API_URLhttp://localhost:3000Rust API base URL
NEXT_PUBLIC_WS_URLws://localhost:3000WebSocket URL (optional, derived from API URL)
NEXT_PUBLIC_WS_TOKEN(empty)WebSocket auth token (?token= param)
API_KEY(empty)Server-only API key for GraphQL (X-API-Key header)
SENTRY_DSN(empty)Sentry error tracking DSN
NODE_ENVdevelopmentNode.js environment
PORT5001Dev server port

WebSocket Client

The createWebSocket() function from @elcto/api provides:

  • Auto-reconnect with exponential backoff (3s base, max 30s)
  • Token authentication via ?token= query parameter
  • JSON message parsing with type routing
  • Clean disconnect with intentional close detection
import { createWebSocket } from '@elcto/api/clients';

const ws = createWebSocket('/v1/ws', {
token: process.env.NEXT_PUBLIC_WS_TOKEN,
channels: ['speed', 'gps'],
autoReconnect: true,
maxReconnectAttempts: 10,
});

ws.onMessage((data) => {
const msg = data as { type: string; data: unknown };
if (msg.type === 'speed') {
console.log('Speed:', msg.data);
}
if (msg.type === 'gps') {
console.log('GPS:', msg.data);
}
if (msg.type === 'vessels') {
console.log('AIS Vessels:', msg.data);
}
});