feat: Initialize frontend and backend structure with essential configurations
- Added TypeScript build info for frontend. - Created Vite configuration for React application. - Implemented pre-commit hook to run checks before commits. - Set up PostgreSQL Dockerfile with PostGIS support and initialization scripts. - Added database creation script for PostgreSQL with necessary extensions. - Established Python project configuration with dependencies and development tools. - Developed pre-commit script to enforce code quality checks for backend and frontend. - Created PowerShell script to set up Git hooks path.
This commit is contained in:
88
frontend/src/App.tsx
Normal file
88
frontend/src/App.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import './styles/global.css';
|
||||
|
||||
import { LoginForm } from './components/auth/LoginForm';
|
||||
import { NetworkMap } from './components/map/NetworkMap';
|
||||
import { useNetworkSnapshot } from './hooks/useNetworkSnapshot';
|
||||
import { useAuth } from './state/AuthContext';
|
||||
|
||||
function App(): JSX.Element {
|
||||
const { token, user, status: authStatus, logout } = useAuth();
|
||||
const isAuthenticated = authStatus === 'authenticated' && token !== null;
|
||||
const { data, status, error } = useNetworkSnapshot(isAuthenticated ? token : null);
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<header className="app-header">
|
||||
<div>
|
||||
<h1>Rail Game</h1>
|
||||
<p>Build and manage a railway network using real-world map data.</p>
|
||||
</div>
|
||||
{isAuthenticated && (
|
||||
<div className="auth-meta">
|
||||
<span>Signed in as {user?.fullName ?? user?.username}</span>
|
||||
<button type="button" onClick={() => logout()} className="ghost-button">
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
<main className="app-main">
|
||||
{!isAuthenticated ? (
|
||||
<section className="card">
|
||||
<LoginForm />
|
||||
</section>
|
||||
) : (
|
||||
<section className="card">
|
||||
<h2>Network Snapshot</h2>
|
||||
{status === 'loading' && <p>Loading network data…</p>}
|
||||
{status === 'error' && <p className="error-text">{error}</p>}
|
||||
{status === 'success' && data && (
|
||||
<div className="snapshot-layout">
|
||||
<div className="map-wrapper">
|
||||
<NetworkMap snapshot={data} />
|
||||
</div>
|
||||
<div className="grid">
|
||||
<div>
|
||||
<h3>Stations</h3>
|
||||
<ul>
|
||||
{data.stations.map((station) => (
|
||||
<li key={station.id}>
|
||||
{station.name} ({station.latitude.toFixed(3)},{' '}
|
||||
{station.longitude.toFixed(3)})
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h3>Trains</h3>
|
||||
<ul>
|
||||
{data.trains.map((train) => (
|
||||
<li key={train.id}>
|
||||
{train.designation} · {train.capacity} capacity ·{' '}
|
||||
{train.maxSpeedKph} km/h
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h3>Tracks</h3>
|
||||
<ul>
|
||||
{data.tracks.map((track) => (
|
||||
<li key={track.id}>
|
||||
{track.startStationId} → {track.endStationId} ·{' '}
|
||||
{(track.lengthMeters / 1000).toFixed(1)} km
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
98
frontend/src/components/auth/LoginForm.tsx
Normal file
98
frontend/src/components/auth/LoginForm.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import { FormEvent, useState } from 'react';
|
||||
|
||||
import { useAuth } from '../../state/AuthContext';
|
||||
|
||||
export function LoginForm(): JSX.Element {
|
||||
const { login, register, logout, status, error } = useAuth();
|
||||
const [mode, setMode] = useState<'login' | 'register'>('login');
|
||||
const [username, setUsername] = useState('demo');
|
||||
const [password, setPassword] = useState('railgame123');
|
||||
const [fullName, setFullName] = useState('');
|
||||
|
||||
const isSubmitting = status === 'authenticating';
|
||||
const isRegisterMode = mode === 'register';
|
||||
|
||||
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (isRegisterMode) {
|
||||
await register(username, password, fullName || undefined);
|
||||
} else {
|
||||
await login(username, password);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleMode() {
|
||||
setMode((current) => (current === 'login' ? 'register' : 'login'));
|
||||
setUsername('');
|
||||
setPassword('');
|
||||
setFullName('');
|
||||
logout();
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="auth-card" onSubmit={handleSubmit}>
|
||||
<div className="auth-card__heading">
|
||||
<h2>{isRegisterMode ? 'Create an account' : 'Sign in'}</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="link-button"
|
||||
onClick={toggleMode}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isRegisterMode ? 'Have an account? Sign in' : 'Need an account? Register'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="auth-helper">
|
||||
{isRegisterMode
|
||||
? 'Choose a username and password to create a new profile.'
|
||||
: 'Use the demo credentials to explore the prototype.'}
|
||||
</p>
|
||||
|
||||
{isRegisterMode && (
|
||||
<>
|
||||
<label htmlFor="fullName">Full name</label>
|
||||
<input
|
||||
id="fullName"
|
||||
autoComplete="name"
|
||||
value={fullName}
|
||||
onChange={(event) => setFullName(event.target.value)}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<label htmlFor="username">Username</label>
|
||||
<input
|
||||
id="username"
|
||||
autoComplete="username"
|
||||
value={username}
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
disabled={isSubmitting}
|
||||
required
|
||||
/>
|
||||
|
||||
<label htmlFor="password">Password</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete={isRegisterMode ? 'new-password' : 'current-password'}
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
disabled={isSubmitting}
|
||||
required
|
||||
/>
|
||||
|
||||
{error && <p className="error-text">{error}</p>}
|
||||
<button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting
|
||||
? isRegisterMode
|
||||
? 'Creating account…'
|
||||
: 'Signing in…'
|
||||
: isRegisterMode
|
||||
? 'Register'
|
||||
: 'Sign in'}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
102
frontend/src/components/map/NetworkMap.tsx
Normal file
102
frontend/src/components/map/NetworkMap.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import type { LatLngBoundsExpression, LatLngExpression } from 'leaflet';
|
||||
import { useMemo } from 'react';
|
||||
import { CircleMarker, MapContainer, Polyline, TileLayer, Tooltip } from 'react-leaflet';
|
||||
|
||||
import type { NetworkSnapshot } from '../../services/api';
|
||||
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
|
||||
interface NetworkMapProps {
|
||||
readonly snapshot: NetworkSnapshot;
|
||||
}
|
||||
|
||||
interface StationPosition {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly position: LatLngExpression;
|
||||
}
|
||||
|
||||
const DEFAULT_CENTER: LatLngExpression = [51.505, -0.09];
|
||||
|
||||
export function NetworkMap({ snapshot }: NetworkMapProps): JSX.Element {
|
||||
const stationPositions = useMemo<StationPosition[]>(() => {
|
||||
return snapshot.stations.map((station) => ({
|
||||
id: station.id,
|
||||
name: station.name,
|
||||
position: [station.latitude, station.longitude] as LatLngExpression,
|
||||
}));
|
||||
}, [snapshot.stations]);
|
||||
|
||||
const stationLookup = useMemo(() => {
|
||||
const lookup = new Map<string, LatLngExpression>();
|
||||
for (const station of stationPositions) {
|
||||
lookup.set(station.id, station.position);
|
||||
}
|
||||
return lookup;
|
||||
}, [stationPositions]);
|
||||
|
||||
const trackSegments = useMemo(() => {
|
||||
return snapshot.tracks
|
||||
.map((track) => {
|
||||
const start = stationLookup.get(track.startStationId);
|
||||
const end = stationLookup.get(track.endStationId);
|
||||
if (!start || !end) {
|
||||
return null;
|
||||
}
|
||||
return [start, end] as LatLngExpression[];
|
||||
})
|
||||
.filter((segment): segment is LatLngExpression[] => segment !== null);
|
||||
}, [snapshot.tracks, stationLookup]);
|
||||
|
||||
const bounds = useMemo(() => {
|
||||
if (stationPositions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let minLat = Infinity;
|
||||
let maxLat = -Infinity;
|
||||
let minLng = Infinity;
|
||||
let maxLng = -Infinity;
|
||||
|
||||
for (const { position } of stationPositions) {
|
||||
const [lat, lng] = position as [number, number];
|
||||
minLat = Math.min(minLat, lat);
|
||||
maxLat = Math.max(maxLat, lat);
|
||||
minLng = Math.min(minLng, lng);
|
||||
maxLng = Math.max(maxLng, lng);
|
||||
}
|
||||
|
||||
const padding = 0.02;
|
||||
return [
|
||||
[minLat - padding, minLng - padding],
|
||||
[maxLat + padding, maxLng + padding],
|
||||
] as LatLngBoundsExpression;
|
||||
}, [stationPositions]);
|
||||
|
||||
return (
|
||||
<MapContainer
|
||||
className="network-map"
|
||||
center={bounds ? undefined : DEFAULT_CENTER}
|
||||
bounds={bounds ?? undefined}
|
||||
scrollWheelZoom
|
||||
>
|
||||
<TileLayer
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
{trackSegments.map((segment, index) => (
|
||||
<Polyline key={`track-${index}`} positions={segment} pathOptions={{ color: '#38bdf8', weight: 4 }} />
|
||||
))}
|
||||
{stationPositions.map((station) => (
|
||||
<CircleMarker
|
||||
key={station.id}
|
||||
center={station.position}
|
||||
radius={6}
|
||||
pathOptions={{ color: '#f97316', fillColor: '#fed7aa', fillOpacity: 0.9 }}
|
||||
>
|
||||
<Tooltip direction="top" offset={[0, -8]}>{station.name}</Tooltip>
|
||||
</CircleMarker>
|
||||
))}
|
||||
</MapContainer>
|
||||
);
|
||||
}
|
||||
56
frontend/src/hooks/useNetworkSnapshot.ts
Normal file
56
frontend/src/hooks/useNetworkSnapshot.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import type { NetworkSnapshot } from '../services/api';
|
||||
import { ApiError, fetchNetworkSnapshot } from '../services/api';
|
||||
import { useAuth } from '../state/AuthContext';
|
||||
|
||||
interface NetworkSnapshotState {
|
||||
readonly data: NetworkSnapshot | null;
|
||||
readonly status: 'idle' | 'loading' | 'success' | 'error';
|
||||
readonly error: string | null;
|
||||
}
|
||||
|
||||
const INITIAL_STATE: NetworkSnapshotState = {
|
||||
data: null,
|
||||
status: 'idle',
|
||||
error: null,
|
||||
};
|
||||
|
||||
export function useNetworkSnapshot(token: string | null): NetworkSnapshotState {
|
||||
const [state, setState] = useState<NetworkSnapshotState>(INITIAL_STATE);
|
||||
const { logout } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
const abortController = new AbortController();
|
||||
|
||||
if (!token) {
|
||||
setState(INITIAL_STATE);
|
||||
return () => abortController.abort();
|
||||
}
|
||||
|
||||
const authToken = token;
|
||||
|
||||
async function load() {
|
||||
setState({ data: null, status: 'loading', error: null });
|
||||
try {
|
||||
const snapshot = await fetchNetworkSnapshot(authToken, abortController.signal);
|
||||
setState({ data: snapshot, status: 'success', error: null });
|
||||
} catch (error) {
|
||||
if (abortController.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
if (error instanceof ApiError && error.status === 401) {
|
||||
logout(error.message);
|
||||
setState({ data: null, status: 'error', error: error.message });
|
||||
return;
|
||||
}
|
||||
setState({ data: null, status: 'error', error: (error as Error).message });
|
||||
}
|
||||
}
|
||||
|
||||
load();
|
||||
return () => abortController.abort();
|
||||
}, [token, logout]);
|
||||
|
||||
return state;
|
||||
}
|
||||
20
frontend/src/main.tsx
Normal file
20
frontend/src/main.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
|
||||
import App from './App';
|
||||
import './styles/global.css';
|
||||
import { AuthProvider } from './state/AuthContext';
|
||||
|
||||
const rootElement = document.getElementById('root');
|
||||
|
||||
if (!rootElement) {
|
||||
throw new Error('Failed to find the root element');
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(rootElement).render(
|
||||
<React.StrictMode>
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
84
frontend/src/services/api.ts
Normal file
84
frontend/src/services/api.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import type { AuthResponse, LoginPayload, RegisterPayload } from '../types/auth';
|
||||
import type { Station, Track, Train } from '../types/domain';
|
||||
|
||||
export class ApiError extends Error {
|
||||
readonly status: number;
|
||||
|
||||
constructor(message: string, status: number) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
export interface NetworkSnapshot {
|
||||
readonly stations: Station[];
|
||||
readonly tracks: Track[];
|
||||
readonly trains: Train[];
|
||||
}
|
||||
|
||||
const JSON_HEADERS = {
|
||||
accept: 'application/json',
|
||||
'content-type': 'application/json',
|
||||
};
|
||||
|
||||
export async function login(credentials: LoginPayload): Promise<AuthResponse> {
|
||||
const response = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: JSON_HEADERS,
|
||||
body: JSON.stringify(credentials),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ApiError('Invalid username or password', response.status);
|
||||
}
|
||||
|
||||
return (await response.json()) as AuthResponse;
|
||||
}
|
||||
|
||||
export async function register(credentials: RegisterPayload): Promise<AuthResponse> {
|
||||
const response = await fetch('/api/auth/register', {
|
||||
method: 'POST',
|
||||
headers: JSON_HEADERS,
|
||||
body: JSON.stringify(credentials),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const message =
|
||||
response.status === 409
|
||||
? 'Username already exists'
|
||||
: 'Registration failed. Please try again.';
|
||||
throw new ApiError(message, response.status);
|
||||
}
|
||||
|
||||
return (await response.json()) as AuthResponse;
|
||||
}
|
||||
|
||||
export async function fetchNetworkSnapshot(
|
||||
token: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<NetworkSnapshot> {
|
||||
const response = await fetch('/api/network', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
accept: 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const message =
|
||||
response.status === 401
|
||||
? 'Authorization required. Please sign in again.'
|
||||
: `Failed to fetch network snapshot (status ${response.status})`;
|
||||
throw new ApiError(message, response.status);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as NetworkSnapshot;
|
||||
return {
|
||||
stations: data.stations ?? [],
|
||||
tracks: data.tracks ?? [],
|
||||
trains: data.trains ?? [],
|
||||
};
|
||||
}
|
||||
149
frontend/src/state/AuthContext.tsx
Normal file
149
frontend/src/state/AuthContext.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
import {
|
||||
createContext,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { login as loginRequest, register as registerRequest } from '../services/api';
|
||||
import type { AuthenticatedUser } from '../types/auth';
|
||||
|
||||
const STORAGE_TOKEN_KEY = 'rail-game/auth/token';
|
||||
const STORAGE_USER_KEY = 'rail-game/auth/user';
|
||||
|
||||
type AuthStatus = 'unauthenticated' | 'authenticating' | 'authenticated';
|
||||
|
||||
interface AuthContextValue {
|
||||
readonly token: string | null;
|
||||
readonly user: AuthenticatedUser | null;
|
||||
readonly status: AuthStatus;
|
||||
readonly error: string | null;
|
||||
login: (username: string, password: string) => Promise<boolean>;
|
||||
register: (
|
||||
username: string,
|
||||
password: string,
|
||||
fullName?: string | null
|
||||
) => Promise<boolean>;
|
||||
logout: (reason?: string) => void;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
|
||||
|
||||
function readFromStorage<T>(key: string): T | null {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
const raw = window.localStorage.getItem(key);
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(raw) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeToStorage(key: string, value: unknown): void {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
window.localStorage.setItem(key, JSON.stringify(value));
|
||||
}
|
||||
|
||||
function removeFromStorage(key: string): void {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
window.localStorage.removeItem(key);
|
||||
}
|
||||
|
||||
interface AuthProviderProps {
|
||||
readonly children: ReactNode;
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }: AuthProviderProps): JSX.Element {
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [user, setUser] = useState<AuthenticatedUser | null>(null);
|
||||
const [status, setStatus] = useState<AuthStatus>('unauthenticated');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const storedToken = readFromStorage<string>(STORAGE_TOKEN_KEY);
|
||||
const storedUser = readFromStorage<AuthenticatedUser>(STORAGE_USER_KEY);
|
||||
|
||||
if (storedToken && storedUser) {
|
||||
setToken(storedToken);
|
||||
setUser(storedUser);
|
||||
setStatus('authenticated');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const logout = useCallback((reason?: string) => {
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
setStatus('unauthenticated');
|
||||
setError(reason ?? null);
|
||||
removeFromStorage(STORAGE_TOKEN_KEY);
|
||||
removeFromStorage(STORAGE_USER_KEY);
|
||||
}, []);
|
||||
|
||||
const login = useCallback(async (username: string, password: string) => {
|
||||
setStatus('authenticating');
|
||||
setError(null);
|
||||
try {
|
||||
const response = await loginRequest({ username, password });
|
||||
setToken(response.accessToken);
|
||||
setUser(response.user);
|
||||
setStatus('authenticated');
|
||||
writeToStorage(STORAGE_TOKEN_KEY, response.accessToken);
|
||||
writeToStorage(STORAGE_USER_KEY, response.user);
|
||||
return true;
|
||||
} catch (err) {
|
||||
setStatus('unauthenticated');
|
||||
const message = err instanceof Error ? err.message : 'Authentication failed';
|
||||
setError(message);
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const register = useCallback(
|
||||
async (username: string, password: string, fullName?: string | null) => {
|
||||
setStatus('authenticating');
|
||||
setError(null);
|
||||
try {
|
||||
const response = await registerRequest({ username, password, fullName });
|
||||
setToken(response.accessToken);
|
||||
setUser(response.user);
|
||||
setStatus('authenticated');
|
||||
writeToStorage(STORAGE_TOKEN_KEY, response.accessToken);
|
||||
writeToStorage(STORAGE_USER_KEY, response.user);
|
||||
return true;
|
||||
} catch (err) {
|
||||
setStatus('unauthenticated');
|
||||
const message = err instanceof Error ? err.message : 'Registration failed';
|
||||
setError(message);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const value = useMemo<AuthContextValue>(
|
||||
() => ({ token, user, status, error, login, register, logout }),
|
||||
[token, user, status, error, login, register, logout]
|
||||
);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
export function useAuth(): AuthContextValue {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
235
frontend/src/styles/global.css
Normal file
235
frontend/src/styles/global.css
Normal file
@@ -0,0 +1,235 @@
|
||||
:root {
|
||||
font-family:
|
||||
'Inter',
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
'Segoe UI',
|
||||
sans-serif;
|
||||
color-scheme: light dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #242424;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background: radial-gradient(circle at top, #1e293b, #0f172a 55%, #020617 100%);
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
align-items: center;
|
||||
padding: 3rem 1.5rem;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
text-align: center;
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
.app-header h1 {
|
||||
font-size: 3rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.app-header p {
|
||||
font-size: 1.1rem;
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
}
|
||||
|
||||
.app-main {
|
||||
width: min(900px, 100%);
|
||||
}
|
||||
|
||||
.card {
|
||||
background: rgba(15, 23, 42, 0.85);
|
||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||
border-radius: 16px;
|
||||
padding: 2rem;
|
||||
box-shadow: 0 35px 60px -15px rgba(15, 23, 42, 0.65);
|
||||
}
|
||||
|
||||
.card h2 {
|
||||
font-size: 1.6rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.card p {
|
||||
color: rgba(226, 232, 240, 0.85);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.grid ul {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.grid h3 {
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.snapshot-layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.map-wrapper {
|
||||
height: 360px;
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(148, 163, 184, 0.25);
|
||||
}
|
||||
|
||||
.network-map {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.snapshot-layout {
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.map-wrapper {
|
||||
height: 420px;
|
||||
}
|
||||
}
|
||||
|
||||
.error-text {
|
||||
color: #f87171;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.app-header h1 {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.auth-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
font-size: 0.95rem;
|
||||
color: rgba(226, 232, 240, 0.9);
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.app-header {
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.auth-meta {
|
||||
align-items: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.auth-card__heading {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.auth-helper {
|
||||
font-size: 0.95rem;
|
||||
color: rgba(226, 232, 240, 0.75);
|
||||
}
|
||||
|
||||
.auth-card input {
|
||||
padding: 0.75rem 0.85rem;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||
background: rgba(15, 23, 42, 0.7);
|
||||
color: rgba(226, 232, 240, 0.95);
|
||||
}
|
||||
|
||||
.auth-card button,
|
||||
.ghost-button {
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
padding: 0.75rem 1.5rem;
|
||||
font-weight: 600;
|
||||
transition:
|
||||
background-color 0.2s ease,
|
||||
transform 0.2s ease;
|
||||
}
|
||||
|
||||
.auth-card button {
|
||||
background: linear-gradient(135deg, #38bdf8, #818cf8);
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.auth-card button:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.auth-card button:not(:disabled):hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.ghost-button {
|
||||
background: transparent;
|
||||
color: rgba(226, 232, 240, 0.85);
|
||||
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||
padding: 0.35rem 1.1rem;
|
||||
}
|
||||
|
||||
.ghost-button:hover {
|
||||
background: rgba(148, 163, 184, 0.15);
|
||||
}
|
||||
|
||||
.link-button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #93c5fd;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
text-decoration: underline;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.link-button:disabled {
|
||||
color: rgba(148, 163, 184, 0.6);
|
||||
cursor: not-allowed;
|
||||
text-decoration: none;
|
||||
}
|
||||
19
frontend/src/types/auth.ts
Normal file
19
frontend/src/types/auth.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export interface LoginPayload {
|
||||
readonly username: string;
|
||||
readonly password: string;
|
||||
}
|
||||
|
||||
export interface RegisterPayload extends LoginPayload {
|
||||
readonly fullName?: string | null;
|
||||
}
|
||||
|
||||
export interface AuthenticatedUser {
|
||||
readonly username: string;
|
||||
readonly fullName?: string | null;
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
readonly accessToken: string;
|
||||
readonly tokenType: string;
|
||||
readonly user: AuthenticatedUser;
|
||||
}
|
||||
30
frontend/src/types/domain.ts
Normal file
30
frontend/src/types/domain.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
export interface Timestamped {
|
||||
readonly createdAt: string;
|
||||
readonly updatedAt: string;
|
||||
}
|
||||
|
||||
export interface Identified extends Timestamped {
|
||||
readonly id: string;
|
||||
}
|
||||
|
||||
export interface Station extends Identified {
|
||||
readonly name: string;
|
||||
readonly latitude: number;
|
||||
readonly longitude: number;
|
||||
}
|
||||
|
||||
export interface Track extends Identified {
|
||||
readonly startStationId: string;
|
||||
readonly endStationId: string;
|
||||
readonly lengthMeters: number;
|
||||
readonly maxSpeedKph: number;
|
||||
}
|
||||
|
||||
export interface Train extends Identified {
|
||||
readonly designation: string;
|
||||
readonly capacity: number;
|
||||
readonly maxSpeedKph: number;
|
||||
readonly operatingTrackIds: readonly string[];
|
||||
}
|
||||
|
||||
export type NetworkEntity = Station | Track | Train;
|
||||
1
frontend/src/vite-env.d.ts
vendored
Normal file
1
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user