From 309d9eefba55a7880b59feb7da75b8012ceb98b1 Mon Sep 17 00:00:00 2001 From: raven Date: Mon, 22 Dec 2025 22:40:38 -0300 Subject: [PATCH] first commit --- alpr-service/Dockerfile | 24 ++ alpr-service/main.py | 139 ++++++++++ alpr-service/requirements.txt | 5 + backend/Dockerfile | 24 ++ backend/package.json | 22 ++ .../20251223000209_init/migration.sql | 23 ++ backend/prisma/migrations/migration_lock.toml | 3 + backend/prisma/schema.prisma | 24 ++ backend/src/index.js | 98 +++++++ docker-compose.yml | 81 ++++++ frontend/.gitignore | 24 ++ frontend/Dockerfile | 13 + frontend/README.md | 16 ++ frontend/eslint.config.js | 29 +++ frontend/index.html | 13 + frontend/package.json | 32 +++ frontend/postcss.config.js | 6 + frontend/public/vite.svg | 1 + frontend/src/App.css | 42 +++ frontend/src/App.jsx | 246 ++++++++++++++++++ frontend/src/assets/react.svg | 1 + frontend/src/index.css | 8 + frontend/src/main.jsx | 10 + frontend/tailwind.config.js | 11 + frontend/vite.config.js | 7 + 25 files changed, 902 insertions(+) create mode 100644 alpr-service/Dockerfile create mode 100644 alpr-service/main.py create mode 100644 alpr-service/requirements.txt create mode 100644 backend/Dockerfile create mode 100644 backend/package.json create mode 100644 backend/prisma/migrations/20251223000209_init/migration.sql create mode 100644 backend/prisma/migrations/migration_lock.toml create mode 100644 backend/prisma/schema.prisma create mode 100644 backend/src/index.js create mode 100644 docker-compose.yml create mode 100644 frontend/.gitignore create mode 100644 frontend/Dockerfile create mode 100644 frontend/README.md create mode 100644 frontend/eslint.config.js create mode 100644 frontend/index.html create mode 100644 frontend/package.json create mode 100644 frontend/postcss.config.js create mode 100644 frontend/public/vite.svg create mode 100644 frontend/src/App.css create mode 100644 frontend/src/App.jsx create mode 100644 frontend/src/assets/react.svg create mode 100644 frontend/src/index.css create mode 100644 frontend/src/main.jsx create mode 100644 frontend/tailwind.config.js create mode 100644 frontend/vite.config.js diff --git a/alpr-service/Dockerfile b/alpr-service/Dockerfile new file mode 100644 index 0000000..4b45a04 --- /dev/null +++ b/alpr-service/Dockerfile @@ -0,0 +1,24 @@ +FROM python:3.9-slim + +WORKDIR /app + +RUN apt-get update && apt-get install -y \ + libgl1 \ + libglib2.0-0 \ + libsm6 \ + libxext6 \ + libxrender-dev \ + && rm -rf /var/lib/apt/lists/* + +# Copy requirements (we will create this file next) +COPY requirements.txt . + +# Install Python dependencies +# Using --no-cache-dir to keep image small +RUN pip install --no-cache-dir -r requirements.txt + +# Copy source code +COPY . . + +# Run the application +CMD ["python", "main.py"] diff --git a/alpr-service/main.py b/alpr-service/main.py new file mode 100644 index 0000000..b08c22d --- /dev/null +++ b/alpr-service/main.py @@ -0,0 +1,139 @@ +import cv2 +import easyocr +import requests +import os +import time +import threading +import numpy as np +from flask import Flask, Response +from flask_cors import CORS + +# Configuration +BACKEND_URL = os.environ.get('BACKEND_URL', 'http://localhost:3000') +CAMERA_ID = 0 +PROCESS_INTERVAL = 2.0 # OCR every 2 seconds +CONFIDENCE_THRESHOLD = 0.4 + +app = Flask(__name__) +CORS(app) + +# Global variables (using simple globals for PoC) +outputFrame = None +lock = threading.Lock() + +def send_plate(plate_number): + try: + url = f"{BACKEND_URL}/api/detect" + payload = {'plate_number': plate_number} + print(f"Sending plate: {plate_number} to {url}") + requests.post(url, json=payload, timeout=2) + except Exception as e: + print(f"Error sending plate: {e}") + +def alpr_loop(): + global outputFrame, lock + + print("Initializing EasyOCR...") + reader = easyocr.Reader(['en'], gpu=False) + print("EasyOCR initialized.") + + cap = cv2.VideoCapture(CAMERA_ID) + time.sleep(2.0) # Warmup + + if not cap.isOpened(): + print("Error: Could not open video device.") + return + + last_process_time = 0 + + while True: + ret, frame = cap.read() + if not ret: + print("Failed to grab frame") + time.sleep(1) + continue + + # Resize for performance and streaming bandwidth + frame = cv2.resize(frame, (640, 480)) + + current_time = time.time() + + # OCR Processing + if current_time - last_process_time > PROCESS_INTERVAL: + last_process_time = current_time + threading.Thread(target=perform_ocr, args=(reader, frame.copy())).start() + + # Update output frame + with lock: + outputFrame = frame.copy() + + time.sleep(0.01) + +import re + +# ... (imports) + +def perform_ocr(reader, img): + try: + results = reader.readtext(img) + for (bbox, text, prob) in results: + if prob > CONFIDENCE_THRESHOLD: + # Clean text: keep alphanumerics + clean_text = ''.join(e for e in text if e.isalnum()).upper() + + # Chilean Plate Regex Patterns + # 1. New format: 4 letters + 2 numbers (e.g., BB-BB-10) -> ^[A-Z]{4}\d{2}$ + # 2. Old format: 2 letters + 4 numbers (e.g., AA-1000) -> ^[A-Z]{2}\d{4}$ + # 3. Moto format (common): 3 letters + 2 numbers or 3 numbers + + # General robust filter for Chile: + # - Length 6 (standard) + # - Must match specific patterns + + is_valid = False + + # Check Pattern 1: BBBB11 (4 Letters, 2 Digits) + if re.match(r'^[A-Z]{4}\d{2}$', clean_text): + is_valid = True + # Check Pattern 2: BB1111 (2 Letters, 4 Digits) + elif re.match(r'^[A-Z]{2}\d{4}$', clean_text): + is_valid = True + + if is_valid: + print(f"Detected Valid Plate: {clean_text}") + send_plate(clean_text) + else: + # Optional logging for debugging ignored text + # print(f"Ignored: {clean_text} (Format mismatch)") + pass + + except Exception as e: + print(f"OCR Error: {e}") + +def generate(): + global outputFrame, lock + while True: + with lock: + if outputFrame is None: + continue + (flag, encodedImage) = cv2.imencode(".jpg", outputFrame) + if not flag: + continue + + yield(b'--frame\r\n' b'Content-Type: image/jpeg\r\n\r\n' + + bytearray(encodedImage) + b'\r\n') + +@app.route("/video_feed") +def video_feed(): + return Response(generate(), mimetype = "multipart/x-mixed-replace; boundary=frame") + +if __name__ == "__main__": + # Start capturing thread + t = threading.Thread(target=alpr_loop) + t.daemon = True + t.start() + + # Start Flask Server + print("Starting Video Stream on port 5001...") + # Using 0.0.0.0 to allow access if needed, though mostly used locally + app.run(host="0.0.0.0", port=5001, debug=False, threaded=True, use_reloader=False) diff --git a/alpr-service/requirements.txt b/alpr-service/requirements.txt new file mode 100644 index 0000000..0543f2d --- /dev/null +++ b/alpr-service/requirements.txt @@ -0,0 +1,5 @@ +opencv-python-headless +easyocr +requests +numpy +flask diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..9f3ce7b --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,24 @@ +FROM node:18-alpine + +WORKDIR /app + +# Copy package files first for caching +COPY package*.json ./ + +# Install dependencies +RUN apk add --no-cache openssl + +# Install dependencies +RUN npm install + +# Copy source code +COPY . . + +# Generate Prisma Client +RUN npx prisma generate + +# Expose port +EXPOSE 3000 + +# Start command (dev mode for now) +CMD ["npm", "run", "dev"] diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..6520448 --- /dev/null +++ b/backend/package.json @@ -0,0 +1,22 @@ +{ + "name": "controlpatente-backend", + "version": "1.0.0", + "description": "Backend for ALPR System", + "main": "src/index.js", + "scripts": { + "start": "node src/index.js", + "dev": "nodemon src/index.js", + "migrate": "npx prisma migrate dev" + }, + "dependencies": { + "cors": "^2.8.5", + "express": "^4.18.2", + "pg": "^8.11.0", + "socket.io": "^4.6.1", + "@prisma/client": "^5.0.0" + }, + "devDependencies": { + "nodemon": "^2.0.22", + "prisma": "^5.0.0" + } +} diff --git a/backend/prisma/migrations/20251223000209_init/migration.sql b/backend/prisma/migrations/20251223000209_init/migration.sql new file mode 100644 index 0000000..d5bc5e3 --- /dev/null +++ b/backend/prisma/migrations/20251223000209_init/migration.sql @@ -0,0 +1,23 @@ +-- CreateTable +CREATE TABLE "Plate" ( + "id" SERIAL NOT NULL, + "number" TEXT NOT NULL, + "owner" TEXT, + "status" TEXT NOT NULL DEFAULT 'ALLOWED', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Plate_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "AccessLog" ( + "id" SERIAL NOT NULL, + "plateNumber" TEXT NOT NULL, + "accessStatus" TEXT NOT NULL, + "timestamp" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "AccessLog_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Plate_number_key" ON "Plate"("number"); diff --git a/backend/prisma/migrations/migration_lock.toml b/backend/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..fbffa92 --- /dev/null +++ b/backend/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (i.e. Git) +provider = "postgresql" \ No newline at end of file diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma new file mode 100644 index 0000000..6987457 --- /dev/null +++ b/backend/prisma/schema.prisma @@ -0,0 +1,24 @@ +generator client { + provider = "prisma-client-js" + binaryTargets = ["native", "linux-musl-arm64-openssl-3.0.x"] +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model Plate { + id Int @id @default(autoincrement()) + number String @unique + owner String? + status String @default("ALLOWED") // ALLOWED, DENIED + createdAt DateTime @default(now()) +} + +model AccessLog { + id Int @id @default(autoincrement()) + plateNumber String + accessStatus String // GRANTED, DENIED, UNKNOWN + timestamp DateTime @default(now()) +} diff --git a/backend/src/index.js b/backend/src/index.js new file mode 100644 index 0000000..576bf56 --- /dev/null +++ b/backend/src/index.js @@ -0,0 +1,98 @@ +const express = require('express'); +const cors = require('cors'); +const { PrismaClient } = require('@prisma/client'); +const http = require('http'); +const { Server } = require('socket.io'); + +const app = express(); +const prisma = new PrismaClient(); + +app.use(cors()); +app.use(express.json()); + +const server = http.createServer(app); +const io = new Server(server, { + cors: { + origin: "*", + methods: ["GET", "POST"] + } +}); + +// Hello World +app.get('/', (req, res) => { + res.send('ALPR Backend Running'); +}); + +// Plates CRUD +app.get('/api/plates', async (req, res) => { + try { + const plates = await prisma.plate.findMany(); + res.json(plates); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +app.post('/api/plates', async (req, res) => { + const { number, owner, status } = req.body; + try { + const plate = await prisma.plate.create({ + data: { number, owner, status: status || 'ALLOWED' } + }); + res.json(plate); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// Detection Endpoint (from Python) +app.post('/api/detect', async (req, res) => { + const { plate_number } = req.body; + console.log(`Detected: ${plate_number}`); + + try { + // Check if plate exists + let plate = await prisma.plate.findUnique({ + where: { number: plate_number } + }); + + let accessStatus = 'DENIED'; + + if (plate && plate.status === 'ALLOWED') { + accessStatus = 'GRANTED'; + } + + if (!plate) { + // Optional: Auto-create unknown plates? + // For now, treat as UNKNOWN (Denied) + accessStatus = 'UNKNOWN'; + } + + // Log the access attempt + const log = await prisma.accessLog.create({ + data: { + plateNumber: plate_number, + accessStatus, + timestamp: new Date() + } + }); + + // Notify Frontend via WebSocket + io.emit('new_detection', { + plate: plate_number, + status: accessStatus, + timestamp: log.timestamp + }); + + res.json({ message: 'Processed', accessStatus }); + + } catch (err) { + console.error(err); + res.status(500).json({ error: err.message }); + } +}); + +const PORT = process.env.PORT || 3000; +server.listen(PORT, () => { + console.log(`Server running on port ${PORT}`); +}); diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f9cbec7 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,81 @@ +version: '3.8' + +services: + # Database Service + db: + image: postgres:15-alpine + container_name: controlpatente-db + environment: + POSTGRES_USER: ${DB_USER:-postgres} + POSTGRES_PASSWORD: ${DB_PASSWORD:-postgres} + POSTGRES_DB: ${DB_NAME:-controlpatente} + volumes: + - db_data:/var/lib/postgresql/data + ports: + - "5432:5432" + networks: + - backend-net + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 5s + retries: 5 + + # Backend Service (Node.js) + backend: + build: ./backend + container_name: controlpatente-backend + environment: + - DATABASE_URL=postgresql://${DB_USER:-postgres}:${DB_PASSWORD:-postgres}@db:5432/${DB_NAME:-controlpatente} + - PORT=3000 + ports: + - "3000:3000" + depends_on: + db: + condition: service_healthy + networks: + - backend-net + volumes: + - ./backend:/app + - /app/node_modules + + # ALPR Component (Python + OpenCV) + alpr-service: + build: ./alpr-service + container_name: controlpatente-alpr + environment: + - BACKEND_URL=http://backend:3000 + # On Mac, you usually cannot pass /dev/video0 directly. + # We might need to use a stream or just test with a file for now if direct access fails. + # For Linux/Raspberry Pi, the device mapping below is correct. + devices: + - "/dev/video0:/dev/video0" + networks: + - backend-net + depends_on: + - backend + restart: unless-stopped + # Add privilege for hardware access + privileged: true + + # Frontend Service (React) + frontend: + build: ./frontend + container_name: controlpatente-frontend + ports: + - "5173:5173" + networks: + - backend-net + volumes: + - ./frontend:/app + - /app/node_modules + environment: + - VITE_API_URL=http://localhost:3000 + +networks: + backend-net: + driver: bridge + +volumes: + db_data: diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..0209fe5 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,13 @@ +FROM node:18-alpine + +WORKDIR /app + +COPY package*.json ./ + +RUN npm install + +COPY . . + +EXPOSE 5173 + +CMD ["npm", "run", "dev", "--", "--host"] diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..18bc70e --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,16 @@ +# React + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh + +## React Compiler + +The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project. diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 0000000..4fa125d --- /dev/null +++ b/frontend/eslint.config.js @@ -0,0 +1,29 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{js,jsx}'], + extends: [ + js.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + parserOptions: { + ecmaVersion: 'latest', + ecmaFeatures: { jsx: true }, + sourceType: 'module', + }, + }, + rules: { + 'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }], + }, + }, +]) diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..c20fbd3 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + frontend + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..7060345 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,32 @@ +{ + "name": "controlpatente-frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "axios": "^1.4.0", + "lucide-react": "^0.260.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "socket.io-client": "^4.7.1" + }, + "devDependencies": { + "@types/react": "^18.2.15", + "@types/react-dom": "^18.2.7", + "@vitejs/plugin-react": "^4.0.3", + "autoprefixer": "^10.4.14", + "eslint": "^8.45.0", + "eslint-plugin-react": "^7.32.2", + "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-react-refresh": "^0.4.3", + "postcss": "^8.4.27", + "tailwindcss": "^3.3.3", + "vite": "^4.4.5" + } +} \ No newline at end of file diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..d41ad63 --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/frontend/public/vite.svg b/frontend/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/frontend/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/App.css b/frontend/src/App.css new file mode 100644 index 0000000..b9d355d --- /dev/null +++ b/frontend/src/App.css @@ -0,0 +1,42 @@ +#root { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; + text-align: center; +} + +.logo { + height: 6em; + padding: 1.5em; + will-change: filter; + transition: filter 300ms; +} +.logo:hover { + filter: drop-shadow(0 0 2em #646cffaa); +} +.logo.react:hover { + filter: drop-shadow(0 0 2em #61dafbaa); +} + +@keyframes logo-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: no-preference) { + a:nth-of-type(2) .logo { + animation: logo-spin infinite 20s linear; + } +} + +.card { + padding: 2em; +} + +.read-the-docs { + color: #888; +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..abd421c --- /dev/null +++ b/frontend/src/App.jsx @@ -0,0 +1,246 @@ +import { useState, useEffect } from 'react' +import io from 'socket.io-client' +import axios from 'axios' +import { Car, AlertCircle, CheckCircle, XCircle, Clock } from 'lucide-react' + +// Env var logic for Vite +const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000'; +const socket = io(API_URL); + +function App() { + const [plates, setPlates] = useState([]); + const [detections, setDetections] = useState([]); + const [loading, setLoading] = useState(true); + const [showModal, setShowModal] = useState(false); + const [newPlate, setNewPlate] = useState({ number: '', owner: '' }); + + const handleRegister = async (e) => { + e.preventDefault(); + try { + if (!newPlate.number) return; + await axios.post(`${API_URL}/api/plates`, { + number: newPlate.number.toUpperCase(), + owner: newPlate.owner + }); + setNewPlate({ number: '', owner: '' }); + setShowModal(false); + fetchPlates(); + } catch (err) { + alert('Error adding plate: ' + err.message); + } + }; + + // Load initial data + useEffect(() => { + fetchPlates(); + + // Socket listeners + socket.on('new_detection', (data) => { + console.log('New detection:', data); + setDetections(prev => [data, ...prev].slice(0, 10)); // Keep last 10 + }); + + return () => { + socket.off('new_detection'); + }; + }, []); + + const fetchPlates = async () => { + try { + const res = await axios.get(`${API_URL}/api/plates`); + setPlates(res.data); + setLoading(false); + } catch (err) { + console.error(err); + setLoading(false); + } + }; + + const StatusBadge = ({ status }) => { + const colors = { + GRANTED: 'bg-green-500/20 text-green-400 border-green-500/50', + DENIED: 'bg-red-500/20 text-red-400 border-red-500/50', + UNKNOWN: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/50', + ALLOWED: 'bg-green-500/20 text-green-400 border-green-500/50', + }; + + return ( + + {status} + + ); + }; + + return ( +
+ {/* Modal */} + {showModal && ( +
+
+

Register New Plate

+
+
+ + setNewPlate({ ...newPlate, number: e.target.value.toUpperCase() })} + className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 focus:ring-2 focus:ring-blue-500 outline-none font-mono uppercase" + placeholder="ABCD12" + /> +
+
+ + setNewPlate({ ...newPlate, owner: e.target.value })} + className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 focus:ring-2 focus:ring-blue-500 outline-none" + placeholder="John Doe" + /> +
+
+ + +
+
+
+
+ )} +
+ + {/* Header */} +
+
+
+ +
+
+

+ Control Patente AI +

+

Real-time ALPR Monitoring System

+
+
+
+
+ System Online +
+
+ +
+ + {/* Live Detections Feed */} +
+

+ + Live Detections +

+ +
+ + {/* Video Feed */} +
+ Live Camera Feed { + e.target.style.display = 'none'; + e.target.nextSibling.style.display = 'flex'; + }} + /> +
+

Camera Offline or Connecting...

+
+
+
+ LIVE +
+
+
+ + {/* Detections List */} +

Recent Scans

+ + {detections.length === 0 ? ( +
+

No detections yet...

+
+ ) : ( +
+ {detections.map((d, i) => ( +
+
+
+ {d.plate} +
+
+ {new Date(d.timestamp).toLocaleTimeString()} +
+
+ +
+ ))} +
+ )} +
+
+ + {/* Database / Stats */} +
+

+ + Registered Plates +

+ +
+ {loading ? ( +

Loading database...

+ ) : ( +
+ {plates.map((p) => ( +
+
+
{p.number}
+
{p.owner || 'Unknown Owner'}
+
+ + {p.status} + +
+ ))} + {plates.length === 0 && ( +

No plates registered.

+ )} +
+ )} + + +
+
+
+ +
+
+ ) +} + +export default App diff --git a/frontend/src/assets/react.svg b/frontend/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/frontend/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..77af715 --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,8 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +body { + background-color: #0f172a; /* Slate 900 */ + color: #f8fafc; /* Slate 50 */ +} diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx new file mode 100644 index 0000000..b9a1a6d --- /dev/null +++ b/frontend/src/main.jsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import './index.css' +import App from './App.jsx' + +createRoot(document.getElementById('root')).render( + + + , +) diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js new file mode 100644 index 0000000..ceb0351 --- /dev/null +++ b/frontend/tailwind.config.js @@ -0,0 +1,11 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: [ + "./index.html", + "./src/**/*.{js,ts,jsx,tsx}", + ], + theme: { + extend: {}, + }, + plugins: [], +} diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..8b0f57b --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react()], +})