Alpha stage commit
This commit is contained in:
31
apps/mobile/README.md
Normal file
31
apps/mobile/README.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# Mobile App
|
||||
|
||||
Purpose: riding/offline companion.
|
||||
|
||||
MVP mobile scope can be a placeholder until the web planner and backend are stable.
|
||||
|
||||
Primary future screens:
|
||||
|
||||
- Trip list.
|
||||
- Offline pack status.
|
||||
- Riding mode: next water/food/sleep/repair/bailout.
|
||||
- Map view.
|
||||
- Report issue.
|
||||
- Export/share route.
|
||||
|
||||
Offline-critical data:
|
||||
|
||||
- route geometry,
|
||||
- stages,
|
||||
- POIs,
|
||||
- rules,
|
||||
- exports,
|
||||
- report queue.
|
||||
|
||||
## Implemented placeholder
|
||||
|
||||
The current package is not a full React Native shell. It provides compiled TypeScript contracts for the future mobile app:
|
||||
|
||||
- `OfflineReportQueue` for dated, location-bound reports captured without network access;
|
||||
- `OfflinePackIndex` for downloaded route/stage/POI/rule/export manifests;
|
||||
- storage abstraction that can later be backed by SQLite, AsyncStorage, or a native key-value store.
|
||||
15
apps/mobile/package.json
Normal file
15
apps/mobile/package.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "@pikebacker/mobile",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/offlineStore.js",
|
||||
"types": "./dist/offlineStore.d.ts",
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@pikebacker/shared": "0.1.0"
|
||||
}
|
||||
}
|
||||
80
apps/mobile/src/offlineStore.ts
Normal file
80
apps/mobile/src/offlineStore.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import type { LocalReportCreate, OfflinePackManifest } from '@pikebacker/shared';
|
||||
|
||||
export type OfflineReportQueueItem = LocalReportCreate & {
|
||||
offlineClientId: string;
|
||||
queuedAt: string;
|
||||
syncAttempts: number;
|
||||
lastSyncError?: string;
|
||||
};
|
||||
|
||||
export type OfflinePackRecord = {
|
||||
manifest: OfflinePackManifest;
|
||||
downloadedAt: string;
|
||||
routeJsonPath?: string;
|
||||
poiJsonPath?: string;
|
||||
gpxPath?: string;
|
||||
tilePackPath?: string;
|
||||
};
|
||||
|
||||
export interface OfflineKeyValueStore {
|
||||
getItem(key: string): Promise<string | null>;
|
||||
setItem(key: string, value: string): Promise<void>;
|
||||
}
|
||||
|
||||
const REPORT_QUEUE_KEY = 'pikebacker.mobile.offlineReportQueue';
|
||||
const PACK_INDEX_KEY = 'pikebacker.mobile.offlinePackIndex';
|
||||
|
||||
export class OfflineReportQueue {
|
||||
constructor(private readonly store: OfflineKeyValueStore) {}
|
||||
|
||||
async enqueue(report: LocalReportCreate, now = new Date().toISOString()): Promise<OfflineReportQueueItem> {
|
||||
const item: OfflineReportQueueItem = {
|
||||
...report,
|
||||
offlineClientId: report.offlineClientId ?? crypto.randomUUID(),
|
||||
queuedAt: now,
|
||||
syncAttempts: 0
|
||||
};
|
||||
const queue = await this.list();
|
||||
await this.store.setItem(REPORT_QUEUE_KEY, JSON.stringify([...queue, item]));
|
||||
return item;
|
||||
}
|
||||
|
||||
async list(): Promise<OfflineReportQueueItem[]> {
|
||||
const raw = await this.store.getItem(REPORT_QUEUE_KEY);
|
||||
return raw ? (JSON.parse(raw) as OfflineReportQueueItem[]) : [];
|
||||
}
|
||||
|
||||
async recordSyncFailure(offlineClientId: string, error: string): Promise<void> {
|
||||
const queue = await this.list();
|
||||
const next = queue.map((item) =>
|
||||
item.offlineClientId === offlineClientId
|
||||
? { ...item, syncAttempts: item.syncAttempts + 1, lastSyncError: error }
|
||||
: item
|
||||
);
|
||||
await this.store.setItem(REPORT_QUEUE_KEY, JSON.stringify(next));
|
||||
}
|
||||
|
||||
async removeSynced(offlineClientId: string): Promise<void> {
|
||||
const queue = await this.list();
|
||||
await this.store.setItem(REPORT_QUEUE_KEY, JSON.stringify(queue.filter((item) => item.offlineClientId !== offlineClientId)));
|
||||
}
|
||||
}
|
||||
|
||||
export class OfflinePackIndex {
|
||||
constructor(private readonly store: OfflineKeyValueStore) {}
|
||||
|
||||
async upsert(record: OfflinePackRecord): Promise<void> {
|
||||
const records = await this.list();
|
||||
const next = [...records.filter((item) => item.manifest.packId !== record.manifest.packId), record];
|
||||
await this.store.setItem(PACK_INDEX_KEY, JSON.stringify(next));
|
||||
}
|
||||
|
||||
async list(): Promise<OfflinePackRecord[]> {
|
||||
const raw = await this.store.getItem(PACK_INDEX_KEY);
|
||||
return raw ? (JSON.parse(raw) as OfflinePackRecord[]) : [];
|
||||
}
|
||||
|
||||
async get(packId: string): Promise<OfflinePackRecord | undefined> {
|
||||
return (await this.list()).find((record) => record.manifest.packId === packId);
|
||||
}
|
||||
}
|
||||
11
apps/mobile/tsconfig.json
Normal file
11
apps/mobile/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
1
apps/web/.env.example
Normal file
1
apps/web/.env.example
Normal file
@@ -0,0 +1 @@
|
||||
VITE_API_BASE_URL=http://localhost:8000
|
||||
53
apps/web/README.md
Normal file
53
apps/web/README.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# Web App
|
||||
|
||||
Purpose: planning interface for BikepackPilot.
|
||||
|
||||
MVP screens:
|
||||
|
||||
- Trip setup form.
|
||||
- Map view.
|
||||
- Stage timeline.
|
||||
- POI corridor list.
|
||||
- Warnings panel.
|
||||
- Export/offline panel.
|
||||
|
||||
Implementation guidance:
|
||||
|
||||
- Use shared domain types from `packages/shared`.
|
||||
- Use API client generated from or aligned to `schemas/openapi.yaml`.
|
||||
- Use MapLibre-compatible rendering when map implementation begins.
|
||||
- Start with fixture route rendering if map provider is not configured.
|
||||
|
||||
## Implemented MVP
|
||||
|
||||
The current web planner is a Vite/React/MapLibre app. It calls the API provider flow and renders:
|
||||
|
||||
- trip request controls;
|
||||
- full-screen OSM raster tile map;
|
||||
- route, stage endpoint, and POI map layers;
|
||||
- collapsible left planning/export/stats dock;
|
||||
- ordered routing point editor with reorder/delete controls;
|
||||
- right-click map actions for `from here`, `via here`, and `to here`;
|
||||
- draggable waypoint pins and route-drag insertion of via points;
|
||||
- compact floating layer controls;
|
||||
- bottom drawer for stages, warnings, gaps, rule cards, POIs, and linked route/elevation profile hover;
|
||||
- GPX download and offline manifest actions;
|
||||
- structured local report submission with report type, observed time, route-km attachment, confidence payload, localStorage queue fallback, and queued-report sync.
|
||||
|
||||
Run locally after starting the API:
|
||||
|
||||
```bash
|
||||
npm -w @pikebacker/web run dev
|
||||
```
|
||||
|
||||
Configure the API base with `VITE_API_BASE_URL`, defaulting to `http://localhost:8000`.
|
||||
|
||||
The route provider selector includes:
|
||||
|
||||
- `Bikepacking gravel`: calls the API with `provider: "brouter"` and lets the API compare BRouter bike profiles against Pikebacker scoring.
|
||||
- `OSRM road demo`: calls the API with `provider: "osrm"` and uses the configured OSRM endpoint.
|
||||
- `Mock fixture`: deterministic route and POI fixtures for repeatable tests.
|
||||
|
||||
The profile tab renders per-coordinate `elevationM` samples when the selected routing provider supplies them. BRouter and mock routes currently do; OSRM demo routes expose only aggregate ascent/descent warnings.
|
||||
|
||||
The OSM tile layer uses the public OSM raster URL for MVP development and keeps visible OpenStreetMap attribution in the map control.
|
||||
12
apps/web/index.html
Normal file
12
apps/web/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Pikebacker Planner</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
25
apps/web/package.json
Normal file
25
apps/web/package.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "@pikebacker/web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
"build": "tsc -p tsconfig.json --noEmit && vite build",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"preview": "vite preview --host 0.0.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@pikebacker/shared": "0.1.0",
|
||||
"lucide-react": "^0.468.0",
|
||||
"maplibre-gl": "^5.13.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.0.1",
|
||||
"@types/react-dom": "^19.0.2",
|
||||
"@vitejs/plugin-react": "^6.0.3",
|
||||
"vite": "^8.1.3"
|
||||
}
|
||||
}
|
||||
2155
apps/web/src/App.tsx
Normal file
2155
apps/web/src/App.tsx
Normal file
File diff suppressed because it is too large
Load Diff
92
apps/web/src/api.ts
Normal file
92
apps/web/src/api.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import type {
|
||||
LocalReport,
|
||||
LocalReportCreate,
|
||||
OfflinePackManifest,
|
||||
Poi,
|
||||
RoutePlan,
|
||||
RuleCard,
|
||||
ServiceGap,
|
||||
StagePlan,
|
||||
Trip,
|
||||
TripRequest
|
||||
} from '@pikebacker/shared';
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:8000';
|
||||
|
||||
export type PlannerBundle = {
|
||||
trip: Trip;
|
||||
route: RoutePlan;
|
||||
stages: StagePlan[];
|
||||
pois: Poi[];
|
||||
gaps: ServiceGap[];
|
||||
rules: RuleCard[];
|
||||
};
|
||||
|
||||
export async function createPlannerBundle(tripRequest: TripRequest, provider = 'mock'): Promise<PlannerBundle> {
|
||||
const trip = await postJson<Trip>('/v1/trips', tripRequest);
|
||||
const route = await postJson<RoutePlan>('/v1/routes/plan', { tripRequest, provider });
|
||||
const stageResponse = await postJson<{ stages: StagePlan[] }>('/v1/stages/plan', {
|
||||
routeId: route.id,
|
||||
tripRequest
|
||||
});
|
||||
const poiBufferKm = route.provider === 'mock' ? 5 : 20;
|
||||
const poiResponse = await getJson<{ pois: Poi[] }>(`/v1/routes/${route.id}/pois?buffer_km=${poiBufferKm}`);
|
||||
const gapResponse = await getJson<{ gaps: ServiceGap[] }>(`/v1/routes/${route.id}/gaps`);
|
||||
const ruleResponse = await getJson<{ rules: RuleCard[] }>(`/v1/rules?route_id=${encodeURIComponent(route.id)}`);
|
||||
|
||||
return {
|
||||
trip,
|
||||
route,
|
||||
stages: stageResponse.stages,
|
||||
pois: poiResponse.pois,
|
||||
gaps: gapResponse.gaps,
|
||||
rules: ruleResponse.rules
|
||||
};
|
||||
}
|
||||
|
||||
export async function createOfflinePack(tripId: string, routeId: string): Promise<OfflinePackManifest> {
|
||||
return postJson<OfflinePackManifest>('/v1/offline-packs', { tripId, routeId, routeBufferKm: 5 });
|
||||
}
|
||||
|
||||
export async function downloadGpx(routeId: string): Promise<Blob> {
|
||||
const response = await fetch(`${API_BASE_URL}/v1/routes/${routeId}/export?format=gpx`);
|
||||
if (!response.ok) {
|
||||
throw new Error(await errorMessage(response));
|
||||
}
|
||||
return response.blob();
|
||||
}
|
||||
|
||||
export async function submitReport(report: LocalReportCreate): Promise<LocalReport> {
|
||||
return postJson<LocalReport>('/v1/reports', report);
|
||||
}
|
||||
|
||||
async function getJson<T>(path: string): Promise<T> {
|
||||
const response = await fetch(`${API_BASE_URL}${path}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(await errorMessage(response));
|
||||
}
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
async function postJson<T>(path: string, body: unknown): Promise<T> {
|
||||
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(await errorMessage(response));
|
||||
}
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
async function errorMessage(response: Response): Promise<string> {
|
||||
try {
|
||||
const json = (await response.json()) as { message?: string; error?: string };
|
||||
return json.message ?? json.error ?? response.statusText;
|
||||
} catch {
|
||||
return response.statusText;
|
||||
}
|
||||
}
|
||||
6
apps/web/src/main.tsx
Normal file
6
apps/web/src/main.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
import App from './App.js';
|
||||
import './styles.css';
|
||||
|
||||
createRoot(document.getElementById('root') as HTMLElement).render(<App />);
|
||||
992
apps/web/src/styles.css
Normal file
992
apps/web/src/styles.css
Normal file
@@ -0,0 +1,992 @@
|
||||
:root {
|
||||
background: #111814;
|
||||
color: #19231f;
|
||||
font-family:
|
||||
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
align-items: center;
|
||||
background: #f6f7f3;
|
||||
border: 1px solid #c9d1c8;
|
||||
border-radius: 6px;
|
||||
color: #17231e;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
font-weight: 750;
|
||||
gap: 0.45rem;
|
||||
min-height: 2.25rem;
|
||||
padding: 0.5rem 0.72rem;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
label {
|
||||
color: #52625a;
|
||||
display: grid;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 850;
|
||||
gap: 0.35rem;
|
||||
letter-spacing: 0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
background: #ffffff;
|
||||
border: 1px solid #c7d1c8;
|
||||
border-radius: 6px;
|
||||
color: #18251f;
|
||||
min-height: 2.25rem;
|
||||
padding: 0.48rem 0.58rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
textarea {
|
||||
min-height: 4.5rem;
|
||||
resize: vertical;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.plannerShell {
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
width: 100vw;
|
||||
}
|
||||
|
||||
.mapSurface {
|
||||
background: #e9eee7;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.maplibregl-ctrl-attrib {
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.emptyMapOverlay {
|
||||
align-items: center;
|
||||
background: rgba(245, 248, 243, 0.9);
|
||||
border: 1px solid rgba(42, 62, 53, 0.22);
|
||||
border-radius: 8px;
|
||||
color: #495950;
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
justify-items: center;
|
||||
left: 50%;
|
||||
max-width: 24rem;
|
||||
padding: 1rem 1.2rem;
|
||||
position: absolute;
|
||||
top: 42%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.brandPlate,
|
||||
.statusPlate,
|
||||
.leftDock,
|
||||
.layerPanel,
|
||||
.bottomDrawer {
|
||||
backdrop-filter: blur(16px);
|
||||
background: rgba(250, 251, 247, 0.94);
|
||||
border: 1px solid rgba(73, 91, 79, 0.22);
|
||||
box-shadow: 0 18px 60px rgba(15, 24, 20, 0.2);
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.brandPlate {
|
||||
border-radius: 8px;
|
||||
display: grid;
|
||||
left: var(--left-panel-space, 386px);
|
||||
min-width: 13rem;
|
||||
padding: 0.7rem 0.9rem;
|
||||
top: 1rem;
|
||||
transition: left 180ms ease;
|
||||
}
|
||||
|
||||
.leftCollapsed .brandPlate {
|
||||
left: 5.5rem;
|
||||
}
|
||||
|
||||
.brandPlate strong {
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
color: #617369;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 850;
|
||||
letter-spacing: 0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.statusPlate {
|
||||
border-radius: 999px;
|
||||
color: #23342d;
|
||||
font-size: 0.84rem;
|
||||
font-weight: 750;
|
||||
left: 50%;
|
||||
max-width: min(46rem, calc(100vw - 43rem));
|
||||
overflow: hidden;
|
||||
padding: 0.55rem 0.85rem;
|
||||
text-overflow: ellipsis;
|
||||
top: 1rem;
|
||||
transform: translateX(-50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.leftDock {
|
||||
border-radius: 0 12px 12px 0;
|
||||
display: flex;
|
||||
gap: 0;
|
||||
left: 0;
|
||||
max-height: calc(100vh - 2rem);
|
||||
top: 1rem;
|
||||
transition: width 180ms ease;
|
||||
width: 23rem;
|
||||
}
|
||||
|
||||
.leftCollapsed .leftDock {
|
||||
width: 3.75rem;
|
||||
}
|
||||
|
||||
.dockToggle {
|
||||
align-self: stretch;
|
||||
background: #244a3b;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
color: #ffffff;
|
||||
min-height: 100%;
|
||||
width: 2.4rem;
|
||||
}
|
||||
|
||||
.dockContent {
|
||||
display: grid;
|
||||
gap: 0.9rem;
|
||||
max-height: calc(100vh - 2rem);
|
||||
overflow: auto;
|
||||
padding: 1rem;
|
||||
width: calc(23rem - 2.4rem);
|
||||
}
|
||||
|
||||
.planningForm,
|
||||
.dockSection,
|
||||
.reportForm {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.dockSection {
|
||||
border-top: 1px solid #d8dfd8;
|
||||
padding-top: 0.85rem;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.sectionTitle h2 {
|
||||
font-size: 0.98rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.fieldGrid {
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.fieldGrid.two {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.fieldGrid.three {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.reportRange {
|
||||
accent-color: #245741;
|
||||
min-height: 1.5rem;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.reportLocation {
|
||||
align-items: center;
|
||||
background: #f5f7f1;
|
||||
border: 1px solid #d8dfd8;
|
||||
border-radius: 8px;
|
||||
color: #4f5f57;
|
||||
display: flex;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 750;
|
||||
gap: 0.4rem;
|
||||
padding: 0.45rem 0.55rem;
|
||||
}
|
||||
|
||||
.reportActions {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.reportActions button {
|
||||
justify-content: center;
|
||||
padding-inline: 0.45rem;
|
||||
}
|
||||
|
||||
.reportQueue {
|
||||
background: #ffffff;
|
||||
border: 1px solid #d8dfd8;
|
||||
border-radius: 8px;
|
||||
color: #5d6b63;
|
||||
display: grid;
|
||||
font-size: 0.78rem;
|
||||
gap: 0.22rem;
|
||||
padding: 0.5rem 0.6rem;
|
||||
}
|
||||
|
||||
.reportQueue span,
|
||||
.reportQueue small {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.primaryButton {
|
||||
background: #245741;
|
||||
border-color: #245741;
|
||||
color: #ffffff;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.metricGrid {
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.metric {
|
||||
background: #eef3eb;
|
||||
border: 1px solid #d6ded5;
|
||||
border-radius: 8px;
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
padding: 0.5rem 0.58rem;
|
||||
}
|
||||
|
||||
.metric span,
|
||||
.muted,
|
||||
.emptyText {
|
||||
color: #66746c;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.metric strong {
|
||||
font-size: 0.98rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.actionRow,
|
||||
.filterRow,
|
||||
.tabRow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.manifestBox,
|
||||
.rule {
|
||||
background: #f1f5ef;
|
||||
border: 1px solid #d9e0d8;
|
||||
border-radius: 8px;
|
||||
display: grid;
|
||||
gap: 0.3rem;
|
||||
padding: 0.65rem;
|
||||
}
|
||||
|
||||
.routingPoints {
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.routePointHeader {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.routePointHeader span {
|
||||
color: #52625a;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 850;
|
||||
letter-spacing: 0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.routePointHeader button {
|
||||
min-height: 1.9rem;
|
||||
padding: 0.3rem 0.5rem;
|
||||
}
|
||||
|
||||
.routePointList {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.routePointRow {
|
||||
align-items: center;
|
||||
background: #ffffff;
|
||||
border: 1px solid #d8e0d8;
|
||||
border-radius: 8px;
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
grid-template-columns: auto auto minmax(0, 1fr) auto;
|
||||
padding: 0.45rem;
|
||||
}
|
||||
|
||||
.routePointRow:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.routePointHandle {
|
||||
align-items: center;
|
||||
color: #829187;
|
||||
cursor: grab;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.routePointBadge {
|
||||
align-items: center;
|
||||
border: 2px solid #ffffff;
|
||||
border-radius: 999px;
|
||||
color: #ffffff;
|
||||
display: inline-flex;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 850;
|
||||
height: 1.6rem;
|
||||
justify-content: center;
|
||||
width: 1.6rem;
|
||||
}
|
||||
|
||||
.routePointBadge.start {
|
||||
background: #245741;
|
||||
}
|
||||
|
||||
.routePointBadge.via {
|
||||
background: #6b5aa7;
|
||||
}
|
||||
|
||||
.routePointBadge.end {
|
||||
background: #b75f31;
|
||||
}
|
||||
|
||||
.routePointFields {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.routePointRole {
|
||||
color: #24352d;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.coordinateGrid {
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.coordinateGrid label {
|
||||
font-size: 0.64rem;
|
||||
}
|
||||
|
||||
.coordinateGrid input {
|
||||
min-height: 2rem;
|
||||
padding: 0.35rem 0.45rem;
|
||||
}
|
||||
|
||||
.deletePointButton {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: #8d3d34;
|
||||
justify-content: center;
|
||||
min-height: 1.9rem;
|
||||
padding: 0.25rem;
|
||||
width: 1.9rem;
|
||||
}
|
||||
|
||||
.deletePointButton:disabled {
|
||||
color: #bdc6bf;
|
||||
}
|
||||
|
||||
.mapContextMenu {
|
||||
background: rgba(250, 251, 247, 0.97);
|
||||
border: 1px solid rgba(73, 91, 79, 0.25);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 16px 50px rgba(15, 24, 20, 0.24);
|
||||
display: grid;
|
||||
gap: 0.3rem;
|
||||
padding: 0.4rem;
|
||||
position: absolute;
|
||||
width: 10rem;
|
||||
z-index: 8;
|
||||
}
|
||||
|
||||
.mapContextMenu button {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
justify-content: flex-start;
|
||||
min-height: 2rem;
|
||||
padding: 0.4rem 0.5rem;
|
||||
}
|
||||
|
||||
.manifestBox span,
|
||||
.rule span,
|
||||
.rule small {
|
||||
color: #5e6c64;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.layerPanel {
|
||||
border-radius: 8px;
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
padding: 0.75rem;
|
||||
right: 1rem;
|
||||
top: 1rem;
|
||||
width: 10.5rem;
|
||||
}
|
||||
|
||||
.layerTitle,
|
||||
.switchRow,
|
||||
.poiStrip span,
|
||||
.poiRow {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 0.42rem;
|
||||
}
|
||||
|
||||
.layerTitle strong {
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.switchRow {
|
||||
color: #34463d;
|
||||
display: grid;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 750;
|
||||
grid-template-columns: auto 1fr;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.switchRow input {
|
||||
accent-color: #245741;
|
||||
min-height: auto;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.bottomDrawer {
|
||||
border-radius: 12px 12px 0 0;
|
||||
bottom: 0;
|
||||
display: grid;
|
||||
gap: 0;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
height: 18rem;
|
||||
left: 24.2rem;
|
||||
right: 1rem;
|
||||
transition:
|
||||
height 180ms ease,
|
||||
left 180ms ease;
|
||||
}
|
||||
|
||||
.leftCollapsed .bottomDrawer {
|
||||
left: 4.8rem;
|
||||
}
|
||||
|
||||
.bottomDrawer.collapsed {
|
||||
height: 3.25rem;
|
||||
}
|
||||
|
||||
.bottomDrawer.profileMode {
|
||||
height: 22rem;
|
||||
}
|
||||
|
||||
.bottomHeader {
|
||||
align-items: center;
|
||||
border-bottom: 1px solid #d8dfd8;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.55rem 0.7rem;
|
||||
}
|
||||
|
||||
.bottomDrawer.collapsed .bottomHeader {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.bottomContent {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.profileMode .bottomContent {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tabButton.active,
|
||||
.filter.active {
|
||||
background: #234638;
|
||||
border-color: #234638;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.iconButton {
|
||||
justify-content: center;
|
||||
min-width: 2.35rem;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.stageStrip {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
grid-auto-columns: minmax(18rem, 22rem);
|
||||
grid-auto-flow: column;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.stageCard {
|
||||
background: #ffffff;
|
||||
border: 1px solid #d8e0d8;
|
||||
border-radius: 8px;
|
||||
display: grid;
|
||||
gap: 0.6rem;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.stageMain {
|
||||
align-items: center;
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
grid-template-columns: 1fr auto;
|
||||
}
|
||||
|
||||
.stageMain span {
|
||||
color: #55645c;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.poiStrip {
|
||||
color: #52625a;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
font-size: 0.82rem;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.profilePanel {
|
||||
display: grid;
|
||||
gap: 0.7rem;
|
||||
grid-template-rows: minmax(8rem, 1fr) auto auto;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
padding-block: 0.15rem 0.25rem;
|
||||
}
|
||||
|
||||
.profileChart {
|
||||
background: linear-gradient(180deg, #f7f8f2 0%, #eef3ec 100%);
|
||||
border: 1px solid #d8e0d8;
|
||||
border-radius: 8px;
|
||||
cursor: crosshair;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.profileChart svg {
|
||||
display: block;
|
||||
height: 100%;
|
||||
min-height: 8rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.elevationArea {
|
||||
fill: rgba(106, 112, 65, 0.22);
|
||||
}
|
||||
|
||||
.elevationLine {
|
||||
fill: none;
|
||||
stroke: #245741;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
stroke-width: 5;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
.stageTick line {
|
||||
stroke: rgba(74, 88, 78, 0.38);
|
||||
stroke-dasharray: 5 5;
|
||||
stroke-width: 2;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
.stageLabelLayer {
|
||||
inset: 0.45rem 0.5rem auto 0.5rem;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.stageLabel {
|
||||
background: rgba(255, 255, 255, 0.82);
|
||||
border: 1px solid rgba(93, 107, 99, 0.24);
|
||||
border-radius: 999px;
|
||||
color: #24352d;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 850;
|
||||
padding: 0.12rem 0.38rem;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
transform: translateX(0);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.profileHoverOverlay {
|
||||
bottom: 0;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
transform: translateX(-50%);
|
||||
width: 0;
|
||||
}
|
||||
|
||||
.profileHoverLine {
|
||||
border-left: 2px dashed #9a5f14;
|
||||
bottom: 0;
|
||||
display: block;
|
||||
left: 0;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.profileHoverDot {
|
||||
background: #c9851a;
|
||||
border: 3px solid #ffffff;
|
||||
border-radius: 999px;
|
||||
box-shadow: 0 4px 14px rgba(25, 35, 31, 0.25);
|
||||
height: 0.85rem;
|
||||
left: 0;
|
||||
position: absolute;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 0.85rem;
|
||||
}
|
||||
|
||||
.profileAxis {
|
||||
bottom: 0.5rem;
|
||||
color: #5d6b63;
|
||||
display: flex;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 750;
|
||||
justify-content: space-between;
|
||||
left: 0.75rem;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
right: 0.75rem;
|
||||
}
|
||||
|
||||
.profileHoverReadout {
|
||||
align-items: center;
|
||||
background: #ffffff;
|
||||
border: 1px solid #d8e0d8;
|
||||
border-radius: 8px;
|
||||
color: #52625a;
|
||||
display: grid;
|
||||
font-size: 0.84rem;
|
||||
gap: 0.55rem;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
min-height: 2.35rem;
|
||||
padding: 0.45rem 0.6rem;
|
||||
}
|
||||
|
||||
.profileHoverReadout span,
|
||||
.profileHoverReadout strong {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.profileHoverReadout strong {
|
||||
color: #24352d;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.mutedReadout {
|
||||
color: #7b8980;
|
||||
}
|
||||
|
||||
.profileStats {
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.profileStat {
|
||||
background: #ffffff;
|
||||
border: 1px solid #d8e0d8;
|
||||
border-radius: 8px;
|
||||
display: grid;
|
||||
gap: 0.15rem;
|
||||
min-width: 0;
|
||||
padding: 0.42rem 0.55rem;
|
||||
}
|
||||
|
||||
.profileStat span {
|
||||
color: #607169;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 750;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.profileStat strong {
|
||||
color: #24352d;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.emptyProfile .profileStats {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.infoGrid {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
grid-template-columns: 1.2fr 1fr 1.1fr;
|
||||
}
|
||||
|
||||
.infoColumn {
|
||||
display: grid;
|
||||
gap: 0.6rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.warningList,
|
||||
.gapList,
|
||||
.ruleList,
|
||||
.poiList {
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.warning {
|
||||
align-items: start;
|
||||
background: #f7f8f4;
|
||||
border-left: 4px solid #aab7ad;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.55rem;
|
||||
}
|
||||
|
||||
.warning strong,
|
||||
.warning span {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.warning span {
|
||||
color: #52625a;
|
||||
font-size: 0.82rem;
|
||||
margin-top: 0.12rem;
|
||||
}
|
||||
|
||||
.warning.critical {
|
||||
background: #fff0ee;
|
||||
border-color: #b93d37;
|
||||
}
|
||||
|
||||
.warning.warning {
|
||||
background: #fff7e4;
|
||||
border-color: #c9851a;
|
||||
}
|
||||
|
||||
.warning.notice {
|
||||
background: #eef6fc;
|
||||
border-color: #5f86ae;
|
||||
}
|
||||
|
||||
.warningList.compact {
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.warningList.compact .warning {
|
||||
padding: 0.35rem 0.45rem;
|
||||
}
|
||||
|
||||
.gap {
|
||||
background: #ffffff;
|
||||
border: 1px solid #d8e0d8;
|
||||
border-radius: 8px;
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
padding: 0.55rem;
|
||||
}
|
||||
|
||||
.gap span {
|
||||
color: #5d6b63;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.gap.critical {
|
||||
border-color: #d59a92;
|
||||
}
|
||||
|
||||
.gap.warning {
|
||||
border-color: #e1bd72;
|
||||
}
|
||||
|
||||
.poiPanel {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.poiList {
|
||||
grid-template-columns: repeat(auto-fit, minmax(17rem, 1fr));
|
||||
}
|
||||
|
||||
.poiRow {
|
||||
background: #ffffff;
|
||||
border: 1px solid #d8e0d8;
|
||||
border-radius: 8px;
|
||||
padding: 0.55rem 0.65rem;
|
||||
}
|
||||
|
||||
.poiRow div {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.poiRow span {
|
||||
color: #65756d;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.poiDot {
|
||||
border-radius: 999px;
|
||||
flex: 0 0 auto;
|
||||
height: 0.72rem;
|
||||
width: 0.72rem;
|
||||
}
|
||||
|
||||
.poiDot.water {
|
||||
background: #2379b7;
|
||||
}
|
||||
|
||||
.poiDot.food {
|
||||
background: #b75f31;
|
||||
}
|
||||
|
||||
.poiDot.sleep {
|
||||
background: #6750a4;
|
||||
}
|
||||
|
||||
.poiDot.repair {
|
||||
background: #6a7041;
|
||||
}
|
||||
|
||||
.poiDot.bailout {
|
||||
background: #1f7774;
|
||||
}
|
||||
|
||||
@media (max-width: 1050px) {
|
||||
.statusPlate {
|
||||
left: auto;
|
||||
max-width: calc(100vw - 13rem);
|
||||
right: 12.3rem;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.bottomDrawer,
|
||||
.leftCollapsed .bottomDrawer {
|
||||
left: 1rem;
|
||||
}
|
||||
|
||||
.infoGrid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.profileStats {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.profileHoverReadout {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.brandPlate,
|
||||
.statusPlate,
|
||||
.layerPanel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.leftDock,
|
||||
.leftCollapsed .leftDock {
|
||||
border-radius: 0 0 12px 0;
|
||||
left: 0;
|
||||
max-height: 68vh;
|
||||
top: 0;
|
||||
width: min(23rem, calc(100vw - 2rem));
|
||||
}
|
||||
|
||||
.leftCollapsed .leftDock {
|
||||
width: 3.75rem;
|
||||
}
|
||||
|
||||
.bottomDrawer,
|
||||
.leftCollapsed .bottomDrawer {
|
||||
height: 17rem;
|
||||
left: 0.5rem;
|
||||
right: 0.5rem;
|
||||
}
|
||||
|
||||
.bottomDrawer.profileMode,
|
||||
.leftCollapsed .bottomDrawer.profileMode {
|
||||
height: 20rem;
|
||||
}
|
||||
|
||||
.fieldGrid.two,
|
||||
.fieldGrid.three,
|
||||
.metricGrid,
|
||||
.reportActions,
|
||||
.profileHoverReadout,
|
||||
.profileStats,
|
||||
.emptyProfile .profileStats {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
9
apps/web/tsconfig.json
Normal file
9
apps/web/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"noEmit": true,
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
18
apps/web/vite.config.ts
Normal file
18
apps/web/vite.config.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@pikebacker/shared': resolve(here, '../../packages/shared/src/index.ts')
|
||||
}
|
||||
},
|
||||
server: {
|
||||
port: 5173
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user