first commit
This commit is contained in:
29
.gitignore
vendored
Normal file
29
.gitignore
vendored
Normal 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
15
Dockerfile
Normal 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
6
Dockerfile.dev
Normal 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
24
README.md
Normal 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
20
index.html
Normal 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
31
nginx.conf
Normal 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
7614
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
34
package.json
Normal file
34
package.json
Normal 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
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
22
public/manifest.json
Normal file
22
public/manifest.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
1
public/maplibre-gl-wrapper.js
Normal file
1
public/maplibre-gl-wrapper.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export default window.maplibregl;
|
||||||
4
public/sw.js
Normal file
4
public/sw.js
Normal 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
181
src/App.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
56
src/components/BottomSheet.tsx
Normal file
56
src/components/BottomSheet.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
74
src/components/FeedCard.tsx
Normal file
74
src/components/FeedCard.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
228
src/components/MapComponent.tsx
Normal file
228
src/components/MapComponent.tsx
Normal 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`
|
||||||
|
}
|
||||||
88
src/components/ReportForm.tsx
Normal file
88
src/components/ReportForm.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
58
src/components/ServiceDashboard.tsx
Normal file
58
src/components/ServiceDashboard.tsx
Normal 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
71
src/hooks/useAuth.ts
Normal 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
88
src/hooks/useReports.ts
Normal 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
31
src/index.css
Normal 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
37
src/lib/api.ts
Normal 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
37
src/lib/auth.ts
Normal 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
37
src/lib/db.ts
Normal 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
10
src/main.tsx
Normal 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
52
src/types/index.ts
Normal 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
11
src/vite-env.d.ts
vendored
Normal 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
19
tailwind.config.ts
Normal 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
21
tsconfig.json
Normal 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
10
tsconfig.node.json
Normal 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
69
vite.config.ts
Normal 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` },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user