first commit

This commit is contained in:
2026-03-14 20:49:11 -04:00
commit 08768723bc
31 changed files with 8984 additions and 0 deletions

29
.gitignore vendored Normal file
View File

@@ -0,0 +1,29 @@
# Dependencies
node_modules/
# Build output
dist/
build/
# Environment files
.env
.env.local
.env.*.local
# Logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Editor / OS
.DS_Store
.vscode/
.idea/
*.swp
*.swo
Thumbs.db
# TypeScript
*.tsbuildinfo

15
Dockerfile Normal file
View File

@@ -0,0 +1,15 @@
FROM node:20-alpine as builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN cp node_modules/maplibre-gl/dist/maplibre-gl-csp.js public/maplibre-gl.js \
&& cp node_modules/maplibre-gl/dist/maplibre-gl-csp-worker.js public/maplibre-gl-csp-worker.js \
&& cp node_modules/maplibre-gl/dist/maplibre-gl.css public/maplibre-gl.css
RUN npm run build
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 5173
CMD ["nginx", "-g", "daemon off;"]

6
Dockerfile.dev Normal file
View File

@@ -0,0 +1,6 @@
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
# source code is bind-mounted at runtime — node_modules come from this image layer
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "5173"]

24
README.md Normal file
View File

@@ -0,0 +1,24 @@
# TruckNet Frontend
React + TypeScript + Vite frontend for TruckNet.
## Stack
- React 19
- TypeScript
- Vite
- Tailwind CSS
- MapLibre GL + PMTiles (Protomaps)
## Development
```bash
npm install
npm run dev
```
## Build
```bash
npm run build
```

20
index.html Normal file
View File

@@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#1a1a2e" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<title>TruckNet</title>
<link rel="icon" type="image/svg+xml" href="/truck.svg" />
<link rel="manifest" href="/manifest.json" />
<link rel="stylesheet" href="/maplibre-gl.css" />
<script src="/maplibre-gl.js"></script>
<script type="importmap">{"imports":{"maplibre-gl":"/maplibre-gl-wrapper.js"}}</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

31
nginx.conf Normal file
View File

@@ -0,0 +1,31 @@
server {
listen 5173;
root /usr/share/nginx/html;
index index.html;
location /auth/ {
proxy_pass http://auth-api:3001/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location /api/ {
proxy_pass http://main-api:3000/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location /tiles/ {
alias /var/www/tiles/;
add_header Accept-Ranges bytes;
add_header Access-Control-Allow-Origin *;
add_header Access-Control-Allow-Headers Range;
add_header Access-Control-Expose-Headers Content-Range,Content-Length;
}
location / {
try_files $uri $uri/ /index.html;
}
}

7614
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

34
package.json Normal file
View File

@@ -0,0 +1,34 @@
{
"name": "trucknet-frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"@protomaps/basemaps": "^5.7.2",
"@radix-ui/react-dialog": "^1.1.0",
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-toast": "^1.2.0",
"clsx": "^2.1.0",
"idb": "^8.0.0",
"maplibre-gl": "^5.0.0",
"pmtiles": "^3.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tailwind-merge": "^2.5.0"
},
"devDependencies": {
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.0",
"autoprefixer": "^10.4.0",
"postcss": "^8.4.0",
"tailwindcss": "^3.4.0",
"typescript": "^5.6.0",
"vite": "^6.0.0",
"vite-plugin-pwa": "^0.21.0"
}
}

6
postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

22
public/manifest.json Normal file
View File

@@ -0,0 +1,22 @@
{
"name": "TruckNet",
"short_name": "TruckNet",
"description": "The Modern CB Radio for Truck Drivers",
"theme_color": "#1a1a2e",
"background_color": "#1a1a2e",
"display": "fullscreen",
"orientation": "any",
"start_url": "/",
"icons": [
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}

View File

@@ -0,0 +1 @@
export default window.maplibregl;

4
public/sw.js Normal file
View File

@@ -0,0 +1,4 @@
// This file is a placeholder. The real service worker is generated by vite-plugin-pwa at build time.
// During development, this file is served as-is and the PWA plugin registers its own sw.
self.addEventListener('install', () => self.skipWaiting());
self.addEventListener('activate', () => self.clients.claim());

181
src/App.tsx Normal file
View File

@@ -0,0 +1,181 @@
import { useState, useCallback, useRef } from 'react'
import { MapComponent } from './components/MapComponent'
import { BottomSheet } from './components/BottomSheet'
import { FeedCard } from './components/FeedCard'
import { ReportForm } from './components/ReportForm'
import { ServiceDashboard } from './components/ServiceDashboard'
import { useAuth } from './hooks/useAuth'
import { useReports } from './hooks/useReports'
import { BoundingBox, ReportType, UserRole } from './types'
import { clsx } from 'clsx'
type Tab = 'feed' | 'service'
function LoginScreen({ onLogin, onRegister }: {
onLogin: (h: string, p: string) => Promise<boolean>
onRegister: (h: string, p: string, r: UserRole) => Promise<boolean>
}) {
const [mode, setMode] = useState<'login' | 'register'>('login')
const [handle, setHandle] = useState('')
const [password, setPassword] = useState('')
const [role, setRole] = useState<UserRole>('driver')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError('')
setLoading(true)
if (mode === 'login') {
const ok = await onLogin(handle, password)
if (!ok) setError('Invalid credentials')
} else {
const ok = await onRegister(handle, password, role)
if (!ok) setError('Registration failed — handle may be taken')
else setMode('login')
}
setLoading(false)
}
return (
<div className="min-h-screen bg-trucker-bg flex items-center justify-center p-6">
<div className="w-full max-w-sm">
<div className="text-center mb-8">
<div className="text-5xl mb-3">🚛</div>
<h1 className="text-3xl font-black text-white">TruckNet</h1>
<p className="text-gray-400 mt-1">The Modern CB Radio</p>
</div>
<div className="bg-trucker-card rounded-2xl p-6">
<div className="flex gap-2 mb-6">
{(['login', 'register'] as const).map(m => (
<button key={m} onClick={() => setMode(m)}
className={clsx('flex-1 py-2 rounded-lg text-sm font-semibold capitalize transition-colors',
mode === m ? 'bg-trucker-accent text-black' : 'text-gray-400 hover:text-white')}>
{m}
</button>
))}
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<input value={handle} onChange={e => setHandle(e.target.value)}
placeholder="Handle (e.g. BigRig42)" required minLength={3} maxLength={30}
className="w-full bg-trucker-bg border border-gray-600 rounded-xl px-4 py-3 text-white text-sm focus:outline-none focus:border-trucker-accent" />
<input value={password} onChange={e => setPassword(e.target.value)}
type="password" placeholder="Password" required minLength={8}
className="w-full bg-trucker-bg border border-gray-600 rounded-xl px-4 py-3 text-white text-sm focus:outline-none focus:border-trucker-accent" />
{mode === 'register' && (
<select value={role} onChange={e => setRole(e.target.value as UserRole)}
className="w-full bg-trucker-bg border border-gray-600 rounded-xl px-4 py-3 text-white text-sm focus:outline-none focus:border-trucker-accent">
<option value="driver">Driver</option>
<option value="mechanic_shop">Mechanic Shop</option>
<option value="mobile_mechanic">Mobile Mechanic</option>
</select>
)}
{error && <p className="text-red-400 text-sm">{error}</p>}
<button type="submit" disabled={loading}
className="w-full py-3 bg-trucker-accent text-black font-bold rounded-xl disabled:opacity-50">
{loading ? '...' : mode === 'login' ? 'Sign In' : 'Create Account'}
</button>
</form>
</div>
</div>
</div>
)
}
export default function App() {
const { user, login, register, logout } = useAuth()
const { reports, fetchNear, createReport } = useReports()
const [tab, setTab] = useState<Tab>('feed')
const [reportOpen, setReportOpen] = useState(false)
const mapCenterRef = useRef<{ lat: number; lng: number }>({ lat: 39, lng: -98 })
const handleBoundsChange = useCallback((bbox: BoundingBox) => {
mapCenterRef.current = {
lat: (bbox.min_lat + bbox.max_lat) / 2,
lng: (bbox.min_lng + bbox.max_lng) / 2,
}
fetchNear(bbox)
}, [fetchNear])
const handleSubmitReport = useCallback(async (type: ReportType, text: string) => {
const { lat, lng } = mapCenterRef.current
await createReport(lat, lng, type, text)
setReportOpen(false)
}, [createReport])
if (!user) {
return <LoginScreen onLogin={login} onRegister={register} />
}
const isDriver = user.role === 'driver'
const mechanicRole = user.role === 'mechanic_shop' || user.role === 'mobile_mechanic'
return (
<div className="h-screen w-screen bg-trucker-bg relative overflow-hidden">
{/* Full-screen map */}
<div className="absolute inset-0">
<MapComponent
reports={reports}
onBoundsChange={handleBoundsChange}
/>
</div>
{/* Top bar */}
<div className="absolute top-0 left-0 right-0 z-10 flex items-center justify-between px-4 pt-safe pb-2 bg-gradient-to-b from-black/60 to-transparent">
<div className="flex items-center gap-2">
<span className="text-2xl">🚛</span>
<span className="text-white font-black text-lg">TruckNet</span>
</div>
<div className="flex items-center gap-3">
<span className="text-gray-300 text-sm">@{user.handle}</span>
<button onClick={logout} className="text-gray-400 hover:text-white text-sm">Sign out</button>
</div>
</div>
{/* Driver: report button bottom-right */}
{isDriver && (
<button
onClick={() => setReportOpen(true)}
className="absolute bottom-32 right-4 z-20 w-14 h-14 bg-trucker-accent text-black text-3xl font-bold rounded-full shadow-lg flex items-center justify-center"
aria-label="New report"
>
+
</button>
)}
{/* Report form */}
{reportOpen && (
<ReportForm
lat={mapCenterRef.current.lat}
lng={mapCenterRef.current.lng}
onSubmit={handleSubmitReport}
onCancel={() => setReportOpen(false)}
/>
)}
{/* Bottom sheet */}
<BottomSheet title={mechanicRole ? 'Service Dashboard' : 'Recent Reports'}>
{mechanicRole ? (
<>
<div className="flex gap-2 mb-4">
{(['feed', 'service'] as Tab[]).map(t => (
<button key={t} onClick={() => setTab(t)}
className={clsx('flex-1 py-2 rounded-lg text-sm font-semibold capitalize transition-colors',
tab === t ? 'bg-trucker-accent text-black' : 'text-gray-400')}>
{t === 'feed' ? 'Feed' : 'Service Requests'}
</button>
))}
</div>
{tab === 'feed' ? (
reports.map(r => <FeedCard key={r.id} report={r} />)
) : (
<ServiceDashboard />
)}
</>
) : (
reports.map(r => <FeedCard key={r.id} report={r} />)
)}
</BottomSheet>
</div>
)
}

View File

@@ -0,0 +1,56 @@
import { useState, useRef } from 'react'
import { clsx } from 'clsx'
interface BottomSheetProps {
children: React.ReactNode
title?: string
}
type SheetState = 'peek' | 'half' | 'full'
const HEIGHT: Record<SheetState, string> = {
peek: '80px',
half: '50vh',
full: '90vh',
}
export function BottomSheet({ children, title }: BottomSheetProps) {
const [state, setState] = useState<SheetState>('peek')
const startY = useRef<number>(0)
const handleTouchStart = (e: React.TouchEvent) => {
startY.current = e.touches[0].clientY
}
const handleTouchEnd = (e: React.TouchEvent) => {
const delta = startY.current - e.changedTouches[0].clientY
if (delta > 50) {
setState(s => s === 'peek' ? 'half' : 'full')
} else if (delta < -50) {
setState(s => s === 'full' ? 'half' : 'peek')
}
}
return (
<div
className="fixed bottom-0 left-0 right-0 bg-trucker-card rounded-t-2xl shadow-2xl z-10 transition-all duration-300 overflow-hidden"
style={{ height: HEIGHT[state] }}
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
>
<div
className="flex flex-col items-center pt-3 pb-2 cursor-pointer"
onClick={() => setState(s => s === 'peek' ? 'half' : s === 'half' ? 'full' : 'peek')}
>
<div className="w-10 h-1 rounded-full bg-gray-500 mb-2" />
{title && <span className="text-white font-semibold text-sm">{title}</span>}
</div>
<div
className={clsx('overflow-y-auto px-4 pb-8', state === 'peek' ? 'hidden' : 'block')}
style={{ height: 'calc(100% - 48px)' }}
>
{children}
</div>
</div>
)
}

View File

@@ -0,0 +1,74 @@
import { Report } from '../types'
import apiFetch from '../lib/api'
import { useState } from 'react'
import { clsx } from 'clsx'
const TYPE_COLORS: Record<string, string> = {
police: 'bg-blue-600',
dot: 'bg-purple-600',
hazard: 'bg-yellow-500',
road_condition: 'bg-cyan-600',
breakdown_request: 'bg-red-600',
other: 'bg-gray-600',
}
const TYPE_LABELS: Record<string, string> = {
police: '🚔 Police',
dot: '🔍 DOT',
hazard: '⚠️ Hazard',
road_condition: '🌧️ Road',
breakdown_request: '🔧 Breakdown',
other: '📍 Other',
}
interface FeedCardProps {
report: Report
onVote?: (id: string, votes: number) => void
}
export function FeedCard({ report, onVote }: FeedCardProps) {
const [upvotes, setUpvotes] = useState(report.upvotes)
const [voted, setVoted] = useState(false)
async function handleUpvote() {
if (voted) return
await apiFetch(`/reports/${report.id}/upvote`, { method: 'POST' })
setUpvotes(v => v + 1)
setVoted(true)
onVote?.(report.id, upvotes + 1)
}
const timeAgo = (() => {
const diff = Date.now() - new Date(report.created_at).getTime()
const mins = Math.floor(diff / 60000)
if (mins < 1) return 'just now'
if (mins < 60) return `${mins}m ago`
return `${Math.floor(mins / 60)}h ago`
})()
return (
<div className="bg-trucker-bg border border-gray-700 rounded-xl p-4 mb-3">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span className={clsx('text-xs font-bold px-2 py-1 rounded-full text-white', TYPE_COLORS[report.report_type])}>
{TYPE_LABELS[report.report_type]}
</span>
<span className="text-gray-400 text-xs">@{report.handle}</span>
</div>
<span className="text-gray-500 text-xs">{timeAgo}</span>
</div>
<p className="text-white text-sm mb-3">{report.text}</p>
<div className="flex items-center gap-4">
<button
onClick={handleUpvote}
disabled={voted}
className={clsx('flex items-center gap-1 text-xs transition-colors',
voted ? 'text-trucker-accent' : 'text-gray-400 hover:text-trucker-accent')}
>
👍 {upvotes}
</button>
<span className="text-gray-600 text-xs">🚩 {report.flags}</span>
</div>
</div>
)
}

View File

@@ -0,0 +1,228 @@
import { useEffect, useRef } from 'react'
import maplibregl from 'maplibre-gl'
import { Protocol } from 'pmtiles'
import { layers, namedFlavor } from '@protomaps/basemaps'
import 'maplibre-gl/dist/maplibre-gl.css'
import { Report, BoundingBox } from '../types'
// Both Rollup (prod) and esbuild (dev) mangle the inline worker blob — always use the CSP worker.
// Dev: served from node_modules by the Vite plugin in vite.config.ts
// Prod: copied to public/ by the production Dockerfile
maplibregl.setWorkerUrl('/maplibre-gl-csp-worker.js')
const TILES_URL = import.meta.env.VITE_TILES_URL ?? '/tiles/north-america.pmtiles'
// Register once at module level — not inside a component/effect
const _protocol = new Protocol()
maplibregl.addProtocol('pmtiles', _protocol.tile)
const REPORT_ICONS: Record<string, string> = {
police: '🚔',
dot: '🔍',
hazard: '⚠️',
road_condition: '🌧️',
breakdown_request: '🔧',
other: '📍',
}
interface MapComponentProps {
reports: Report[]
onBoundsChange: (bbox: BoundingBox) => void
}
export function MapComponent({ reports, onBoundsChange }: MapComponentProps) {
const containerRef = useRef<HTMLDivElement>(null)
const mapRef = useRef<maplibregl.Map | null>(null)
// Initialize map
useEffect(() => {
if (!containerRef.current || mapRef.current) return
const map = new maplibregl.Map({
container: containerRef.current,
style: {
version: 8,
glyphs: 'https://protomaps.github.io/basemaps-assets/fonts/{fontstack}/{range}.pbf',
sprite: 'https://protomaps.github.io/basemaps-assets/sprites/v4/light',
sources: {
protomaps: {
type: 'vector',
url: `pmtiles://${TILES_URL}`,
attribution: '© <a href="https://openstreetmap.org">OpenStreetMap</a>',
},
},
layers: layers('protomaps', namedFlavor('light'), { lang: 'en' }),
},
center: [-98, 39],
zoom: 4,
})
// Zoom to user's location (~250 mile radius = zoom 7) on first load
navigator.geolocation?.getCurrentPosition(
(pos) => {
map.flyTo({ center: [pos.coords.longitude, pos.coords.latitude], zoom: 7, duration: 1500 })
},
() => { /* permission denied — stay on default view */ },
{ timeout: 5000 },
)
map.addControl(new maplibregl.NavigationControl(), 'top-right')
map.addControl(new maplibregl.GeolocateControl({
positionOptions: { enableHighAccuracy: true },
trackUserLocation: true,
}), 'top-right')
// Emit bbox on move
const handleMoveEnd = () => {
const bounds = map.getBounds()
onBoundsChange({
min_lat: bounds.getSouth(),
min_lng: bounds.getWest(),
max_lat: bounds.getNorth(),
max_lng: bounds.getEast(),
})
}
map.on('moveend', handleMoveEnd)
map.on('load', handleMoveEnd)
mapRef.current = map
return () => {
map.remove()
mapRef.current = null
}
}, []) // eslint-disable-line react-hooks/exhaustive-deps
// Add/update report markers
useEffect(() => {
const map = mapRef.current
if (!map || !map.isStyleLoaded()) return
const markerLayer = 'reports-layer'
const markerSource = 'reports-source'
const geojson: GeoJSON.FeatureCollection = {
type: 'FeatureCollection',
features: reports.map(r => ({
type: 'Feature',
geometry: { type: 'Point', coordinates: [r.lng, r.lat] },
properties: {
id: r.id,
handle: r.handle,
report_type: r.report_type,
text: r.text,
upvotes: r.upvotes,
flags: r.flags,
created_at: r.created_at,
icon: REPORT_ICONS[r.report_type] ?? '📍',
},
})),
}
if (map.getSource(markerSource)) {
(map.getSource(markerSource) as maplibregl.GeoJSONSource).setData(geojson)
} else {
map.addSource(markerSource, {
type: 'geojson',
data: geojson,
cluster: true,
clusterMaxZoom: 14,
clusterRadius: 50,
})
// Cluster circles
map.addLayer({
id: 'clusters',
type: 'circle',
source: markerSource,
filter: ['has', 'point_count'],
paint: {
'circle-color': ['step', ['get', 'point_count'], '#f59e0b', 10, '#ef4444', 30, '#7c3aed'],
'circle-radius': ['step', ['get', 'point_count'], 20, 10, 30, 30, 40],
'circle-opacity': 0.85,
},
})
map.addLayer({
id: 'cluster-count',
type: 'symbol',
source: markerSource,
filter: ['has', 'point_count'],
layout: {
'text-field': '{point_count_abbreviated}',
'text-size': 14,
'text-font': ['Noto Sans Regular'],
},
paint: { 'text-color': '#ffffff' },
})
// Individual markers
map.addLayer({
id: markerLayer,
type: 'circle',
source: markerSource,
filter: ['!', ['has', 'point_count']],
paint: {
'circle-color': [
'match', ['get', 'report_type'],
'police', '#3b82f6',
'dot', '#8b5cf6',
'hazard', '#f59e0b',
'road_condition', '#06b6d4',
'breakdown_request', '#ef4444',
'#6b7280',
],
'circle-radius': 10,
'circle-stroke-width': 2,
'circle-stroke-color': '#ffffff',
},
})
// Popups on click
map.on('click', markerLayer, (e) => {
const feature = e.features?.[0]
if (!feature || feature.geometry.type !== 'Point') return
const props = feature.properties
const coords = feature.geometry.coordinates as [number, number]
const timeAgo = getTimeAgo(props.created_at)
new maplibregl.Popup({ closeButton: true, maxWidth: '300px' })
.setLngLat(coords)
.setHTML(`
<div style="font-family: sans-serif; padding: 8px;">
<div style="font-weight: bold; margin-bottom: 4px;">@${props.handle}</div>
<div style="font-size: 0.85em; color: #888; margin-bottom: 8px;">${props.report_type.replace('_', ' ').toUpperCase()} · ${timeAgo}</div>
<div style="margin-bottom: 8px;">${props.text}</div>
<div style="font-size: 0.8em; color: #666;">👍 ${props.upvotes} · 🚩 ${props.flags}</div>
</div>
`)
.addTo(map)
})
map.on('mouseenter', markerLayer, () => { map.getCanvas().style.cursor = 'pointer' })
map.on('mouseleave', markerLayer, () => { map.getCanvas().style.cursor = '' })
// Zoom into cluster on click
map.on('click', 'clusters', async (e) => {
const features = map.queryRenderedFeatures(e.point, { layers: ['clusters'] })
if (!features.length) return
const clusterId = features[0].properties.cluster_id
const source = map.getSource(markerSource) as maplibregl.GeoJSONSource
const zoom = await source.getClusterExpansionZoom(clusterId)
const coords = (features[0].geometry as GeoJSON.Point).coordinates as [number, number]
map.easeTo({ center: coords, zoom })
})
}
}, [reports])
return <div ref={containerRef} className="w-full h-full" />
}
function getTimeAgo(isoDate: string): string {
const diff = Date.now() - new Date(isoDate).getTime()
const mins = Math.floor(diff / 60000)
if (mins < 1) return 'just now'
if (mins < 60) return `${mins}m ago`
const hrs = Math.floor(mins / 60)
if (hrs < 24) return `${hrs}h ago`
return `${Math.floor(hrs / 24)}d ago`
}

View File

@@ -0,0 +1,88 @@
import { useState } from 'react'
import { ReportType } from '../types'
import { clsx } from 'clsx'
const REPORT_TYPES: { value: ReportType; label: string; icon: string }[] = [
{ value: 'police', label: 'Police', icon: '🚔' },
{ value: 'dot', label: 'DOT Checkpoint', icon: '🔍' },
{ value: 'hazard', label: 'Hazard', icon: '⚠️' },
{ value: 'road_condition', label: 'Road Condition', icon: '🌧️' },
{ value: 'breakdown_request', label: 'Need Help', icon: '🔧' },
{ value: 'other', label: 'Other', icon: '📍' },
]
interface ReportFormProps {
lat: number
lng: number
onSubmit: (type: ReportType, text: string) => Promise<void>
onCancel: () => void
}
export function ReportForm({ lat, lng, onSubmit, onCancel }: ReportFormProps) {
const [type, setType] = useState<ReportType>('hazard')
const [text, setText] = useState('')
const [loading, setLoading] = useState(false)
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!text.trim()) return
setLoading(true)
await onSubmit(type, text)
setLoading(false)
}
return (
<div className="fixed inset-0 bg-black/70 flex items-end z-50">
<div className="bg-trucker-card w-full rounded-t-2xl p-6 pb-10">
<h2 className="text-white font-bold text-lg mb-1">New Report</h2>
<p className="text-gray-400 text-xs mb-4">
{lat.toFixed(5)}, {lng.toFixed(5)}
</p>
<form onSubmit={handleSubmit}>
<div className="grid grid-cols-3 gap-2 mb-4">
{REPORT_TYPES.map(rt => (
<button
key={rt.value}
type="button"
onClick={() => setType(rt.value)}
className={clsx(
'flex flex-col items-center justify-center py-3 rounded-xl text-xs font-medium transition-colors',
type === rt.value
? 'bg-trucker-accent text-black'
: 'bg-trucker-bg text-gray-300 hover:bg-gray-700'
)}
>
<span className="text-xl mb-1">{rt.icon}</span>
{rt.label}
</button>
))}
</div>
<textarea
value={text}
onChange={e => setText(e.target.value)}
placeholder="Describe what you're seeing..."
maxLength={500}
rows={3}
className="w-full bg-trucker-bg border border-gray-600 rounded-xl p-3 text-white text-sm resize-none focus:outline-none focus:border-trucker-accent"
/>
<div className="flex gap-3 mt-4">
<button
type="button"
onClick={onCancel}
className="flex-1 py-3 rounded-xl border border-gray-600 text-gray-300 text-sm"
>
Cancel
</button>
<button
type="submit"
disabled={loading || !text.trim()}
className="flex-1 py-3 rounded-xl bg-trucker-accent text-black font-bold text-sm disabled:opacity-50"
>
{loading ? 'Posting...' : 'Post Report'}
</button>
</div>
</form>
</div>
</div>
)
}

View File

@@ -0,0 +1,58 @@
import { useState, useEffect } from 'react'
import apiFetch from '../lib/api'
import { ServiceRequest } from '../types'
import { clsx } from 'clsx'
export function ServiceDashboard() {
const [requests, setRequests] = useState<ServiceRequest[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
apiFetch('/service-requests/open')
.then(r => r.json())
.then(data => setRequests(data))
.finally(() => setLoading(false))
}, [])
async function handleClaim(id: string) {
const res = await apiFetch(`/service-requests/${id}/claim`, { method: 'PATCH' })
if (res.ok) {
setRequests(prev => prev.filter(r => r.id !== id))
}
}
if (loading) return <div className="text-gray-400 text-center py-8">Loading...</div>
if (!requests.length) return <div className="text-gray-400 text-center py-8">No open service requests</div>
return (
<div className="space-y-3">
{requests.map(req => (
<div key={req.id} className="bg-trucker-bg border border-gray-700 rounded-xl p-4">
<div className="flex items-start justify-between mb-2">
<span className="text-red-400 font-bold text-sm">🔧 Service Request</span>
<span className={clsx(
'text-xs px-2 py-1 rounded-full',
req.status === 'open' ? 'bg-green-800 text-green-300' : 'bg-gray-700 text-gray-400'
)}>
{req.status}
</span>
</div>
<p className="text-white text-sm mb-2">{req.description}</p>
{req.truck_details && Object.keys(req.truck_details).length > 0 && (
<pre className="text-gray-400 text-xs bg-black/30 rounded p-2 mb-3 overflow-auto">
{JSON.stringify(req.truck_details, null, 2)}
</pre>
)}
{req.status === 'open' && (
<button
onClick={() => handleClaim(req.id)}
className="w-full py-2 bg-trucker-accent text-black font-bold text-sm rounded-lg"
>
Claim Job
</button>
)}
</div>
))}
</div>
)
}

71
src/hooks/useAuth.ts Normal file
View File

@@ -0,0 +1,71 @@
import { useState, useCallback } from 'react'
import { authFetch } from '../lib/api'
import { setAccessToken, setUser, clearAuth, getUser, getAccessToken } from '../lib/auth'
import { User, UserRole } from '../types'
interface AuthState {
user: User | null
token: string | null
loading: boolean
error: string | null
}
export function useAuth() {
const [state, setState] = useState<AuthState>({
user: getUser(),
token: getAccessToken(),
loading: false,
error: null,
})
const login = useCallback(async (handle: string, password: string) => {
setState(s => ({ ...s, loading: true, error: null }))
try {
const res = await authFetch('/login', {
method: 'POST',
body: JSON.stringify({ handle, password }),
})
if (!res.ok) {
const err = await res.json()
setState(s => ({ ...s, loading: false, error: err.error ?? 'Login failed' }))
return false
}
const data = await res.json()
const user: User = { user_id: data.user_id, handle: data.handle, role: data.role }
setAccessToken(data.access_token)
setUser(user)
setState({ user, token: data.access_token, loading: false, error: null })
return true
} catch {
setState(s => ({ ...s, loading: false, error: 'Network error' }))
return false
}
}, [])
const register = useCallback(async (handle: string, password: string, role: UserRole) => {
setState(s => ({ ...s, loading: true, error: null }))
try {
const res = await authFetch('/register', {
method: 'POST',
body: JSON.stringify({ handle, password, role }),
})
if (!res.ok) {
const err = await res.json()
setState(s => ({ ...s, loading: false, error: err.error ?? 'Registration failed' }))
return false
}
return true
} catch {
setState(s => ({ ...s, loading: false, error: 'Network error' }))
return false
}
}, [])
const logout = useCallback(async () => {
await authFetch('/logout', { method: 'POST' })
clearAuth()
setState({ user: null, token: null, loading: false, error: null })
}, [])
return { ...state, login, register, logout }
}

88
src/hooks/useReports.ts Normal file
View File

@@ -0,0 +1,88 @@
import { useState, useCallback } from 'react'
import apiFetch from '../lib/api'
import { queueReport, getPendingReports, removePendingReport } from '../lib/db'
import { Report, BoundingBox, ReportType } from '../types'
export function useReports() {
const [reports, setReports] = useState<Report[]>([])
const [loading, setLoading] = useState(false)
const fetchNear = useCallback(async (bbox: BoundingBox) => {
setLoading(true)
try {
const params = new URLSearchParams({
min_lat: bbox.min_lat.toString(),
min_lng: bbox.min_lng.toString(),
max_lat: bbox.max_lat.toString(),
max_lng: bbox.max_lng.toString(),
})
const res = await apiFetch(`/reports/near?${params}`)
if (res.ok) {
const data: Report[] = await res.json()
setReports(data)
}
} finally {
setLoading(false)
}
}, [])
const createReport = useCallback(async (
lat: number,
lng: number,
report_type: ReportType,
text: string,
) => {
try {
const res = await apiFetch('/reports', {
method: 'POST',
body: JSON.stringify({ lat, lng, report_type, text }),
})
if (res.ok) {
const report: Report = await res.json()
setReports(prev => [report, ...prev])
return report
}
} catch {
// Offline: queue locally
const pending: Report = {
id: crypto.randomUUID(),
user_id: '',
handle: 'you',
report_type,
lat,
lng,
text,
upvotes: 0,
flags: 0,
created_at: new Date().toISOString(),
}
await queueReport(pending)
setReports(prev => [pending, ...prev])
}
return null
}, [])
const syncPending = useCallback(async () => {
const pending = await getPendingReports()
for (const report of pending) {
try {
const res = await apiFetch('/reports', {
method: 'POST',
body: JSON.stringify({
lat: report.lat,
lng: report.lng,
report_type: report.report_type,
text: report.text,
}),
})
if (res.ok) {
await removePendingReport(report.id)
}
} catch {
break // still offline
}
}
}, [])
return { reports, loading, fetchNear, createReport, syncPending }
}

31
src/index.css Normal file
View File

@@ -0,0 +1,31 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
color-scheme: dark;
}
* {
box-sizing: border-box;
}
html, body, #root {
height: 100%;
width: 100%;
margin: 0;
padding: 0;
overflow: hidden;
background-color: #0f0f23;
color: white;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
}
}
@layer utilities {
.pt-safe {
padding-top: env(safe-area-inset-top, 12px);
}
}

37
src/lib/api.ts Normal file
View File

@@ -0,0 +1,37 @@
import { getAccessToken, setAccessToken, clearAuth, isTokenExpired } from './auth'
const AUTH_BASE = import.meta.env.VITE_AUTH_BASE ?? '/auth'
const API_BASE = import.meta.env.VITE_API_BASE ?? '/api'
async function refreshAccessToken(): Promise<string | null> {
const res = await fetch(`${AUTH_BASE}/refresh`, { method: 'POST', credentials: 'include' })
if (!res.ok) { clearAuth(); return null }
const data = await res.json()
setAccessToken(data.access_token)
return data.access_token
}
async function apiFetch(path: string, options: RequestInit = {}): Promise<Response> {
let token = getAccessToken()
if (token && isTokenExpired(token)) {
token = await refreshAccessToken()
}
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(options.headers as Record<string, string> ?? {}),
}
if (token) headers['Authorization'] = `Bearer ${token}`
return fetch(`${API_BASE}${path}`, { ...options, headers, credentials: 'include' })
}
export async function authFetch(path: string, options: RequestInit = {}): Promise<Response> {
return fetch(`${AUTH_BASE}${path}`, {
...options,
headers: { 'Content-Type': 'application/json', ...(options.headers ?? {}) },
credentials: 'include',
})
}
export default apiFetch

37
src/lib/auth.ts Normal file
View File

@@ -0,0 +1,37 @@
import { User } from '../types'
const ACCESS_TOKEN_KEY = 'trucknet_access_token'
const USER_KEY = 'trucknet_user'
export function getAccessToken(): string | null {
return sessionStorage.getItem(ACCESS_TOKEN_KEY)
}
export function setAccessToken(token: string): void {
sessionStorage.setItem(ACCESS_TOKEN_KEY, token)
}
export function getUser(): User | null {
const raw = sessionStorage.getItem(USER_KEY)
if (!raw) return null
try { return JSON.parse(raw) } catch { return null }
}
export function setUser(user: User): void {
sessionStorage.setItem(USER_KEY, JSON.stringify(user))
}
export function clearAuth(): void {
sessionStorage.removeItem(ACCESS_TOKEN_KEY)
sessionStorage.removeItem(USER_KEY)
}
export function isTokenExpired(token: string): boolean {
try {
const [, payload] = token.split('.')
const decoded = JSON.parse(atob(payload))
return decoded.exp * 1000 < Date.now()
} catch {
return true
}
}

37
src/lib/db.ts Normal file
View File

@@ -0,0 +1,37 @@
import { openDB, DBSchema, IDBPDatabase } from 'idb'
import { Report } from '../types'
interface TruckNetDB extends DBSchema {
pending_reports: {
key: string
value: Report & { pending: true }
}
}
let db: IDBPDatabase<TruckNetDB> | null = null
async function getDB() {
if (!db) {
db = await openDB<TruckNetDB>('trucknet', 1, {
upgrade(database) {
database.createObjectStore('pending_reports', { keyPath: 'id' })
},
})
}
return db
}
export async function queueReport(report: Report): Promise<void> {
const database = await getDB()
await database.put('pending_reports', { ...report, pending: true })
}
export async function getPendingReports(): Promise<(Report & { pending: true })[]> {
const database = await getDB()
return database.getAll('pending_reports')
}
export async function removePendingReport(id: string): Promise<void> {
const database = await getDB()
await database.delete('pending_reports', id)
}

10
src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
)

52
src/types/index.ts Normal file
View File

@@ -0,0 +1,52 @@
export type UserRole = 'driver' | 'mechanic_shop' | 'mobile_mechanic'
export type ReportType =
| 'police'
| 'dot'
| 'hazard'
| 'road_condition'
| 'breakdown_request'
| 'other'
export type ServiceStatus = 'open' | 'claimed' | 'resolved'
export interface User {
user_id: string
handle: string
role: UserRole
}
export interface AuthTokens {
access_token: string
user: User
}
export interface Report {
id: string
user_id: string
handle: string
report_type: ReportType
lat: number
lng: number
text: string
upvotes: number
flags: number
created_at: string
}
export interface ServiceRequest {
id: string
user_id: string
description: string
truck_details: Record<string, unknown>
status: ServiceStatus
claimed_by: string | null
created_at: string
}
export interface BoundingBox {
min_lat: number
min_lng: number
max_lat: number
max_lng: number
}

11
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,11 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_BASE: string
readonly VITE_AUTH_BASE: string
readonly VITE_TILES_URL: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}

19
tailwind.config.ts Normal file
View File

@@ -0,0 +1,19 @@
import type { Config } from 'tailwindcss'
export default {
content: ['./index.html', './src/**/*.{ts,tsx}'],
theme: {
extend: {
colors: {
trucker: {
bg: '#0f0f23',
card: '#1a1a35',
accent: '#f59e0b',
danger: '#ef4444',
safe: '#22c55e',
},
},
},
},
plugins: [],
} satisfies Config

21
tsconfig.json Normal file
View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

10
tsconfig.node.json Normal file
View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

69
vite.config.ts Normal file
View File

@@ -0,0 +1,69 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { VitePWA } from 'vite-plugin-pwa'
import { createReadStream } from 'fs'
import { resolve } from 'path'
// Serve maplibre CSP worker from node_modules in dev (production Dockerfile copies it to public/)
const serveMaplibreWorker = {
name: 'serve-maplibre-csp-worker',
configureServer(server: { middlewares: { use: (path: string, handler: (req: unknown, res: { setHeader: (k: string, v: string) => void }, next: () => void) => void) => void } }) {
server.middlewares.use('/maplibre-gl-csp-worker.js', (_req, res) => {
res.setHeader('Content-Type', 'application/javascript')
createReadStream(resolve('node_modules/maplibre-gl/dist/maplibre-gl-csp-worker.js')).pipe(res as unknown as NodeJS.WritableStream)
})
},
}
export default defineConfig({
plugins: [
react(),
serveMaplibreWorker,
VitePWA({
registerType: 'autoUpdate',
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg}'],
runtimeCaching: [
{
urlPattern: /^\/tiles\//,
handler: 'CacheFirst',
options: {
cacheName: 'pmtiles-cache',
expiration: { maxEntries: 10, maxAgeSeconds: 7 * 24 * 60 * 60 },
},
},
{
urlPattern: /^\/api\//,
handler: 'NetworkFirst',
options: { cacheName: 'api-cache' },
},
],
},
manifest: {
name: 'TruckNet',
short_name: 'TruckNet',
description: 'The Modern CB Radio for Truck Drivers',
theme_color: '#1a1a2e',
background_color: '#1a1a2e',
display: 'fullscreen',
orientation: 'any',
icons: [
{ src: '/icon-192.png', sizes: '192x192', type: 'image/png' },
{ src: '/icon-512.png', sizes: '512x512', type: 'image/png' },
],
},
}),
],
build: {
rollupOptions: {
external: ['maplibre-gl'],
},
},
server: {
proxy: {
'/api': { target: `http://${process.env.API_HOST ?? 'localhost'}:3000`, rewrite: (p) => p.replace(/^\/api/, '') },
'/auth': { target: `http://${process.env.AUTH_HOST ?? 'localhost'}:3001`, rewrite: (p) => p.replace(/^\/auth/, '') },
'/tiles': { target: `http://${process.env.TILES_HOST ?? 'localhost'}:80` },
},
},
})