Alpha stage commit

This commit is contained in:
2026-07-02 21:04:05 +02:00
parent c03b183dfb
commit abed21be21
136 changed files with 15531 additions and 15 deletions

1
apps/web/.env.example Normal file
View File

@@ -0,0 +1 @@
VITE_API_BASE_URL=http://localhost:8000

53
apps/web/README.md Normal file
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

92
apps/web/src/api.ts Normal file
View 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
View 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
View 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
View 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
View 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
}
});