chore: initial checkpoint - Tesla Roadtrip planner

- Proactive Grok integration (xAI API + local CLI fallback)
- Real road routing via OSRM (no more bird's-eye lines)
- Heavy structured logging for fast iteration
- Strong sanitization + geocoding + ErrorBoundary (no black screens)
- Playwright E2E tests (API diagnostic + full UI flow)
- scripts/dev.sh for one-command startup
- Clean .env.example + documentation

This is a stable checkpoint before further prompt/UI refinement.
This commit is contained in:
2026-05-15 19:24:35 +01:00
commit d516e93323
29 changed files with 11927 additions and 0 deletions
+16
View File
@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/tesla-icon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Tesla Trip Planner • Grok</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
crossorigin="" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+2918
View File
File diff suppressed because it is too large Load Diff
+39
View File
@@ -0,0 +1,39 @@
{
"name": "tesla-roadtrip-client",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@tonycodes/auth-react": "^1.4.0",
"framer-motion": "^11.0.0",
"leaflet": "^1.9.4",
"lucide-react": "^0.303.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-leaflet": "^4.2.1",
"react-resizable-panels": "^2.1.7",
"react-router-dom": "^6.21.1",
"sonner": "^1.4.0",
"tailwind-merge": "^2.2.0",
"dompurify": "^3.2.4"
},
"devDependencies": {
"@types/leaflet": "^1.9.14",
"@types/node": "^20.10.0",
"@types/react": "^18.2.47",
"@types/react-dom": "^18.2.18",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.16",
"postcss": "^8.4.32",
"tailwindcss": "^3.4.0",
"typescript": "^5.3.2",
"vite": "^5.0.8",
"@types/dompurify": "^3.0.5"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+14
View File
@@ -0,0 +1,14 @@
import { Routes, Route } from 'react-router-dom';
import { AuthCallback } from '@tonycodes/auth-react';
import TeslaTripPlanner from './pages/TeslaTripPlanner';
function App() {
return (
<Routes>
<Route path="/auth/callback" element={<AuthCallback />} />
<Route path="/*" element={<TeslaTripPlanner />} />
</Routes>
);
}
export default App;
+15
View File
@@ -0,0 +1,15 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { Toaster } from 'sonner';
import App from './App';
import './styles/globals.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<BrowserRouter>
<App />
<Toaster position="top-center" richColors closeButton />
</BrowserRouter>
</React.StrictMode>
);
+424
View File
@@ -0,0 +1,424 @@
import React, { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Send, MapPin, BatteryCharging, Clock, Share2, Download, Car, Zap, AlertTriangle } from 'lucide-react';
import { toast } from 'sonner';
import { MapContainer, TileLayer, Marker, Polyline, Popup } from 'react-leaflet';
import L from 'leaflet';
// Fix Leaflet default icons
delete (L.Icon.Default.prototype as any)._getIconUrl;
L.Icon.Default.mergeOptions({
iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon-2x.png',
iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon.png',
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-shadow.png',
});
const VEHICLES = [
{ name: 'Model Y Long Range', rangeKm: 514, efficiency: 165 },
{ name: 'Model 3 Highland LR', rangeKm: 549, efficiency: 155 },
{ name: 'Model S Long Range', rangeKm: 634, efficiency: 175 },
{ name: 'Model Y RWD (EU)', rangeKm: 455, efficiency: 158 },
];
interface Stop {
id: string;
name: string;
type: 'supercharger' | 'hotel' | 'attraction' | 'restaurant' | 'custom';
lat: number;
lng: number;
day: number;
order: number;
estArrivalBattery?: number;
chargeMinutes?: number;
notes?: string;
}
interface Itinerary {
days: { day: number; stops: Stop[] }[];
summary: { totalDistanceKm: number; estDriveHours: number; estChargeHours: number; superchargers: number; hotels: number };
}
const EMPTY_ITINERARY: Itinerary = {
days: [],
summary: { totalDistanceKm: 0, estDriveHours: 0, estChargeHours: 0, superchargers: 0, hotels: 0 },
};
const QUICK_PROMPTS = [
"Plan a 2-day trip from London to Edinburgh in my Model Y",
"I want to drive from Amsterdam to Munich",
"Help me plan a scenic route from Paris to the Alps",
"Best way from Glasgow to London avoiding motorways",
];
// Simple in-memory geocache
const geocodeCache = new Map<string, { lat: number; lng: number }>();
async function geocodeLocation(query: string): Promise<{ lat: number; lng: number } | null> {
const cacheKey = query.toLowerCase().trim();
if (geocodeCache.has(cacheKey)) return geocodeCache.get(cacheKey)!;
try {
const url = `https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(query)}&limit=1`;
const res = await fetch(url, { headers: { 'User-Agent': 'TeslaRoadtripPlanner/1.0 (local)' } });
const data = await res.json();
if (data && data.length > 0) {
const result = { lat: parseFloat(data[0].lat), lng: parseFloat(data[0].lon) };
geocodeCache.set(cacheKey, result);
await new Promise(r => setTimeout(r, 1100)); // be nice to Nominatim
return result;
}
} catch (e) {
console.warn('[TeslaTrip] Geocoding failed for', query);
}
return null;
}
// Very forgiving sanitization + geocoding
async function normalizeAndSanitizeItinerary(raw: any): Promise<Itinerary> {
if (!raw || !Array.isArray(raw.days)) return EMPTY_ITINERARY;
const normalizedDays: any[] = [];
for (const day of raw.days) {
if (!day) continue;
let rawStops: any[] = [];
if (Array.isArray(day.stops)) rawStops = day.stops;
else if (Array.isArray(day.chargeStops)) rawStops = day.chargeStops;
else if (Array.isArray(raw.pointsOfInterest)) rawStops = raw.pointsOfInterest;
const validStops: any[] = [];
for (const s of rawStops) {
if (!s) continue;
let name = s.name || s.location || s;
if (typeof name !== 'string') continue;
let lat = typeof s.lat === 'number' ? s.lat : null;
let lng = typeof s.lng === 'number' ? s.lng : null;
if ((lat === null || lng === null) && name) {
const geo = await geocodeLocation(name);
if (geo) { lat = geo.lat; lng = geo.lng; }
}
if (lat === null || lng === null) {
validStops.push({
id: s.id || `text-${Date.now()}-${Math.random()}`,
name, type: s.type || 'custom', lat: null, lng: null,
day: day.day || 1, order: s.order || validStops.length + 1,
estArrivalBattery: s.estArrivalBattery, chargeMinutes: s.chargeMinutes, notes: s.notes,
});
continue;
}
validStops.push({
id: s.id || `stop-${Date.now()}-${Math.random()}`,
name, type: ['supercharger','hotel','attraction','restaurant','custom'].includes(s.type) ? s.type : 'custom',
lat, lng, day: day.day || 1, order: s.order || validStops.length + 1,
estArrivalBattery: s.estArrivalBattery, chargeMinutes: s.chargeMinutes, notes: s.notes,
});
}
if (validStops.length > 0) {
normalizedDays.push({ day: day.day || normalizedDays.length + 1, stops: validStops.sort((a,b) => a.order - b.order) });
}
}
const sortedDays = normalizedDays.sort((a,b) => a.day - b.day);
const allStops = sortedDays.flatMap(d => d.stops);
return {
days: sortedDays,
summary: {
totalDistanceKm: raw.summary?.totalDistanceKm ?? 0,
estDriveHours: raw.summary?.estDriveHours ?? 0,
estChargeHours: raw.summary?.estChargeHours ?? 0,
superchargers: allStops.filter(s => s.type === 'supercharger').length,
hotels: allStops.filter(s => s.type === 'hotel').length,
},
};
}
// Simple Error Boundary
class ErrorBoundary extends React.Component<{ children: React.ReactNode }, { hasError: boolean }> {
constructor(props: any) { super(props); this.state = { hasError: false }; }
static getDerivedStateFromError() { return { hasError: true }; }
componentDidCatch(error: Error) { console.error('[TeslaTrip] ErrorBoundary caught:', error); }
render() {
if (this.state.hasError) {
return (
<div className="h-full flex items-center justify-center bg-red-950/30 text-red-400 p-6 rounded-2xl">
<div className="text-center">
<AlertTriangle className="w-8 h-8 mx-auto mb-3" />
<div>Something went wrong rendering the map/itinerary.</div>
<div className="text-xs mt-1 text-red-400/70">Check console for details.</div>
</div>
</div>
);
}
return this.props.children;
}
}
// Fetch actual road route using OSRM (free, no key)
async function getRoadRoute(from: Stop, to: Stop): Promise<[number, number][]> {
try {
const url = `https://router.project-osrm.org/route/v1/driving/${from.lng},${from.lat};${to.lng},${to.lat}?overview=full&geometries=geojson`;
const res = await fetch(url);
const data = await res.json();
if (data.routes && data.routes[0]) {
return data.routes[0].geometry.coordinates.map((c: number[]) => [c[1], c[0]]); // OSRM returns [lng, lat]
}
} catch (e) {
console.warn('[TeslaTrip] OSRM routing failed, falling back to straight line');
}
return [[from.lat, from.lng], [to.lat, to.lng]];
}
export default function TeslaTripPlanner() {
const [messages, setMessages] = useState<any[]>([
{ id: 1, role: 'assistant', content: "Hello! I'm Grok Drive. I'm here to help you plan amazing Tesla road trips across the UK and Europe. Where would you like to go?" },
]);
const [input, setInput] = useState('');
const [thinking, setThinking] = useState(false);
const [itinerary, setItinerary] = useState<Itinerary>(EMPTY_ITINERARY);
const [vehicle, setVehicle] = useState(VEHICLES[0]);
const [roadRoutes, setRoadRoutes] = useState<[number, number][][]>([]);
// Clean stops for map
const allStops: Stop[] = itinerary.days.flatMap(d => d.stops).filter((s): s is Stop => s != null && typeof s.lat === 'number');
// Use real road routes when available, fallback to straight
const displayPolylines = roadRoutes.length > 0 ? roadRoutes : allStops.slice(1).map((stop, i) => {
const prev = allStops[i];
return [[prev.lat, prev.lng], [stop.lat, stop.lng]] as [number, number][];
});
// When itinerary changes, fetch real road routes
React.useEffect(() => {
const fetchRoutes = async () => {
if (allStops.length < 2) {
setRoadRoutes([]);
return;
}
const routes: [number, number][][] = [];
for (let i = 0; i < allStops.length - 1; i++) {
const route = await getRoadRoute(allStops[i], allStops[i + 1]);
routes.push(route);
}
setRoadRoutes(routes);
};
fetchRoutes();
}, [itinerary]);
const sendMessage = async (text: string) => {
if (!text.trim()) return;
console.log('[TeslaTrip] Sending to Grok:', { message: text.trim(), vehicle: vehicle.name });
const userMessage = { id: Date.now(), role: 'user' as const, content: text.trim() };
setMessages(prev => [...prev, userMessage]);
setInput('');
setThinking(true);
try {
const response = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
message: text.trim(),
vehicle: { name: vehicle.name, rangeKm: vehicle.rangeKm },
itinerary,
history: messages.map(m => ({ role: m.role, content: m.content })),
}),
});
if (!response.ok) throw new Error("Failed to get response from server");
const data = await response.json();
console.log('[TeslaTrip] Grok replied:', { replyLength: data.reply?.length, hasItineraryUpdate: !!data.itinerary });
const assistantMessage = {
id: Date.now() + 1,
role: 'assistant' as const,
content: data.reply || "Sorry, I could not generate a response.",
};
setMessages(prev => [...prev, assistantMessage]);
if (data.itinerary) {
const cleanItinerary = await normalizeAndSanitizeItinerary(data.itinerary);
console.log('[TeslaTrip] Sanitized itinerary has', cleanItinerary.days.length, 'day(s)');
setItinerary(cleanItinerary);
const hasMapStops = cleanItinerary.days.flatMap(d => d.stops).some(s => typeof s.lat === 'number');
toast.success("Grok updated your route", {
description: hasMapStops
? `${cleanItinerary.days.length} day(s) • ${cleanItinerary.summary.superchargers} Superchargers`
: `${cleanItinerary.days.length} day(s) (some locations could not be placed on map)`,
});
}
} catch (error: any) {
console.error("[TeslaTrip] Grok call failed:", error);
const errorMessage = {
id: Date.now() + 1,
role: 'assistant' as const,
content: error?.message?.includes('Grok') ? error.message : "I'm having trouble reaching Grok right now. Check backend logs (XAI_API_KEY loaded?).",
};
setMessages(prev => [...prev, errorMessage]);
} finally {
setThinking(false);
}
};
const removeStop = (stopId: string) => {
const newItin = structuredClone(itinerary);
newItin.days.forEach(day => { day.stops = day.stops.filter(s => s.id !== stopId); });
newItin.days = newItin.days.filter(d => d.stops.length > 0);
setItinerary(newItin);
toast.info('Stop removed from itinerary');
};
return (
<div className="flex h-screen bg-[#0a0a0a] text-white overflow-hidden">
{/* LEFT: CHAT */}
<div className="w-[380px] flex flex-col border-r border-white/10 bg-[#111111]">
<div className="p-5 border-b border-white/10 flex items-center gap-3 bg-black/60">
<div className="w-9 h-9 rounded-full bg-[#E82127] flex items-center justify-center"><Zap className="w-5 h-5" /></div>
<div>
<div className="font-semibold text-lg tracking-tight">Grok Drive</div>
<div className="text-xs text-white/50">UK &amp; Europe Headless Grok</div>
</div>
</div>
<div className="p-4 border-b border-white/10 bg-black/30">
<div className="text-[10px] uppercase tracking-[1.5px] text-white/40 mb-1.5 px-1">YOUR VEHICLE</div>
<select value={vehicle.name} onChange={e => setVehicle(VEHICLES.find(v => v.name === e.target.value)!)} className="w-full bg-[#1a1a1a] border border-white/10 rounded-xl px-4 py-2.5 text-sm focus:border-[#E82127]">
{VEHICLES.map(v => <option key={v.name} value={v.name}>{v.name} {v.rangeKm} km</option>)}
</select>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-6 text-sm">
<AnimatePresence>
{messages.map((msg, index) => (
<motion.div key={index} initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} className={`flex ${msg.role === 'user' ? 'justify-end' : ''}`}>
<div className={`max-w-[85%] rounded-2xl px-4 py-3 leading-snug ${msg.role === 'user' ? 'bg-[#E82127] text-white rounded-tr-sm' : 'bg-[#1f242e] border border-white/10 rounded-tl-sm'}`}>
{msg.content}
</div>
</motion.div>
))}
</AnimatePresence>
{thinking && (
<div className="flex items-center gap-2 pl-1 text-[#E82127]">
<div className="flex gap-1">
<div className="w-1 h-1 bg-current rounded-full animate-bounce" />
<div className="w-1 h-1 bg-current rounded-full animate-bounce delay-150" />
<div className="w-1 h-1 bg-current rounded-full animate-bounce delay-300" />
</div>
<span className="text-xs tracking-widest">GROK IS PLANNING YOUR ROUTE...</span>
</div>
)}
</div>
<div className="p-3 border-t border-white/10 flex flex-wrap gap-1.5 bg-black/40">
{QUICK_PROMPTS.map((prompt, i) => (
<button key={i} onClick={() => sendMessage(prompt)} disabled={thinking} className="text-[10px] px-3 py-1 bg-white/5 hover:bg-white/10 rounded-full border border-white/10 transition disabled:opacity-50">
{prompt.length > 42 ? prompt.slice(0, 39) + '...' : prompt}
</button>
))}
</div>
<div className="p-3 border-t border-white/10 bg-black/40">
<div className="flex items-center gap-2 bg-[#1a1a1a] border border-white/10 rounded-2xl px-3">
<input value={input} onChange={e => setInput(e.target.value)} onKeyDown={e => e.key === 'Enter' && sendMessage(input)} placeholder="Tell me where you want to drive..." className="flex-1 bg-transparent py-3 text-sm placeholder:text-white/40 outline-none" disabled={thinking} />
<button onClick={() => sendMessage(input)} disabled={!input.trim() || thinking} className="p-2 rounded-xl bg-[#E82127] disabled:opacity-40 hover:bg-[#c01a20] transition">
<Send className="w-4 h-4" />
</button>
</div>
<div className="text-center text-[9px] text-white/30 mt-2 tracking-[1.5px]">POWERED BY HEADLESS GROK CLI</div>
</div>
</div>
{/* RIGHT: MAP + ITINERARY */}
<ErrorBoundary>
<div className="flex-1 flex flex-col min-w-0">
<div className="h-14 border-b border-white/10 bg-black/40 px-6 flex items-center justify-between text-sm">
<div className="flex items-center gap-8">
<div><MapPin className="inline w-4 h-4 mr-1 text-[#E82127]" />{itinerary.summary.totalDistanceKm} km</div>
<div><Clock className="inline w-4 h-4 mr-1 text-[#E82127]" />{itinerary.summary.estDriveHours}h drive</div>
<div><BatteryCharging className="inline w-4 h-4 mr-1 text-[#E82127]" />{itinerary.summary.estChargeHours}h charging</div>
</div>
<div className="flex gap-2">
<button onClick={() => toast.success('GPX exported for your Tesla')} className="flex items-center gap-1.5 px-3 py-1.5 text-xs border border-white/20 rounded-lg hover:bg-white/5">
<Download className="w-3.5 h-3.5" /> GPX
</button>
<button onClick={() => toast('Shareable link copied')} className="flex items-center gap-1.5 px-3 py-1.5 text-xs bg-white/5 hover:bg-white/10 rounded-lg">
<Share2 className="w-3.5 h-3.5" /> Share
</button>
</div>
</div>
<div className="flex-1 bg-[#05070d] p-3">
<div className="w-full h-full rounded-2xl overflow-hidden border border-white/10 relative">
<MapContainer center={[54.5, -2.5]} zoom={5.5} style={{ height: '100%', width: '100%', background: '#0a0f1a' }}>
<TileLayer attribution='&copy; OpenStreetMap contributors' url="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png" />
{allStops.map(stop => (
<Marker key={stop.id} position={[stop.lat, stop.lng]}>
<Popup><strong>{stop.name}</strong><br />{stop.type === 'supercharger' && `${stop.chargeMinutes} min charge`}{stop.type === 'hotel' && '🏨 Destination charging available'}</Popup>
</Marker>
))}
{displayPolylines.map((positions, idx) => (
<Polyline key={idx} positions={positions} color="#E82127" weight={4} opacity={0.8} />
))}
</MapContainer>
{allStops.length === 0 && (
<div className="absolute inset-0 flex items-center justify-center bg-black/40 pointer-events-none">
<div className="text-center px-6">
<div className="text-2xl mb-2 text-white/80">Ready when you are</div>
<div className="text-white/50">Tell Grok where you want to go and Ill build the perfect Tesla route.</div>
</div>
</div>
)}
</div>
</div>
<div className="h-[215px] border-t border-white/10 bg-[#111111] p-4 overflow-x-auto">
{itinerary.days.length > 0 ? (
<div className="flex gap-6 min-w-max">
{itinerary.days.map((day, di) => {
const validStops = (day.stops || []).filter((s): s is Stop => s != null);
return (
<div key={di} className="w-[310px]">
<div className="uppercase text-xs tracking-[2px] text-[#E82127] mb-2">DAY {day.day}</div>
{validStops.length > 0 ? validStops.sort((a,b) => a.order - b.order).map(stop => (
<div key={stop.id} className="group flex justify-between items-center bg-[#1a1f2b] hover:bg-[#22283a] border border-white/5 rounded-2xl px-4 py-2.5 mb-1.5 text-sm">
<div className="flex items-center gap-3">
<div className={`w-2.5 h-2.5 rounded-full flex-shrink-0 ${stop.type === 'supercharger' ? 'bg-[#E82127]' : stop.type === 'hotel' ? 'bg-blue-500' : 'bg-white/60'}`} />
<div>
<div className="font-medium leading-tight">{stop.name}</div>
{typeof stop.lat === 'number' ? <div className="text-xs text-emerald-400">Placed on map</div> : <div className="text-xs text-amber-400">Location not yet on map</div>}
</div>
</div>
<button onClick={() => removeStop(stop.id)} className="opacity-0 group-hover:opacity-100 text-white/30 hover:text-red-400 transition">
<svg xmlns="http://www.w3.org/2000/svg" className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.595 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.595-1.858L5 7m5 4v6m4-6v6m1-10V9a1 1 0 00-1-1h-4a1 1 0 00-1 1v1M9 7h6" /></svg>
</button>
</div>
)) : <div className="text-xs text-white/40 italic">No valid stops for this day</div>}
</div>
);
})}
</div>
) : (
<div className="h-full flex flex-col items-center justify-center text-center">
<div className="text-white/60 mb-2">No trip planned yet</div>
<div className="text-sm text-white/40 max-w-xs">Describe your journey in the chat and Ill create the perfect route with Superchargers and stops.</div>
</div>
)}
</div>
</div>
</ErrorBoundary>
</div>
);
}
+51
View File
@@ -0,0 +1,51 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--tesla-red: #E82127;
--bg: #0a0a0a;
--bg-elevated: #111111;
--bg-card: #1a1f2b;
--border: rgba(255, 255, 255, 0.08);
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background-color: var(--bg);
color: white;
}
/* Tesla-inspired scrollbar */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: #1a1a1a;
}
::-webkit-scrollbar-thumb {
background: #3a3a3a;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #E82127;
}
/* Map container */
.leaflet-container {
background: #0a0f1a !important;
}
/* Chat bubbles */
.chat-bubble-user {
background: #E82127;
color: white;
border-bottom-right-radius: 4px;
}
.chat-bubble-assistant {
background: #1f242e;
border: 1px solid rgba(255,255,255,0.08);
border-bottom-left-radius: 4px;
}
+21
View File
@@ -0,0 +1,21 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
darkMode: 'class',
theme: {
extend: {
colors: {
tesla: {
red: '#E82127',
black: '#0a0a0a',
dark: '#111111',
gray: '#1a1f2b',
}
}
},
},
plugins: [],
}
+30
View File
@@ -0,0 +1,30 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { resolve } from 'path';
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': resolve(__dirname, './src'),
},
},
server: {
port: 5173,
host: true,
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
},
'/auth': {
target: 'http://localhost:3000',
changeOrigin: true,
},
},
},
build: {
outDir: 'dist',
sourcemap: true,
},
});