├── .dockerignore ├── .eslintrc.json ├── .gitignore ├── 1024.png ├── 180.png ├── 192.png ├── 512.png ├── CHANGELOG.md ├── Dockerfile ├── Images ├── 1.png ├── 2.png ├── 3.png ├── 4.png ├── 5.png ├── Hero.jpg ├── alert.JPG ├── apikey.png ├── app_icon.png ├── app_icon.svg ├── blueiris.png ├── dash.jpg ├── db.jpg ├── hero2.png ├── insights.jpg ├── liveview.jpg ├── push.jpg ├── tags.jpg ├── tpms.jpg └── viewer.jpg ├── LICENSE ├── README.md ├── app ├── 1024.png ├── 180.png ├── 192.png ├── 512.png ├── actions.js ├── api │ ├── _startup.js │ ├── check-update │ │ └── route.js │ ├── health-check │ │ └── route.js │ ├── notifications │ │ └── test │ │ │ └── route.js │ ├── plate-reads │ │ └── route.js │ ├── sse │ │ └── route.js │ ├── verify-key │ │ └── route.js │ ├── verify-session │ │ └── route.js │ └── verify-whitelist │ │ └── route.js ├── apple-icon.png ├── backfill │ ├── BackfillButton.jsx │ └── page.jsx ├── components │ └── ui │ │ └── tooltip.jsx ├── dashboard │ ├── CameraChart.jsx │ ├── CameraSelect.jsx │ ├── DashboardMetrics.jsx │ ├── TagDistribution.jsx │ ├── TimeSelect.jsx │ ├── dummyChart.jsx │ └── page.jsx ├── database │ ├── page.jsx │ └── tags │ │ └── page.jsx ├── favicon.ico ├── favicon.svg ├── flagged │ └── page.jsx ├── fonts │ ├── GeistMonoVF.woff │ └── GeistVF.woff ├── globals.css ├── help │ └── page.jsx ├── images │ └── [...path] │ │ └── route.js ├── jpeg_migration │ └── page.jsx ├── known_plates │ └── page.jsx ├── layout.jsx ├── live_feed │ ├── page.jsx │ └── viewer │ │ └── page.jsx ├── login │ └── page.jsx ├── logs │ ├── LogMessage.jsx │ ├── LogViewer.jsx │ └── page.jsx ├── manifest.js ├── notifications │ └── page.jsx ├── page.jsx ├── settings │ ├── SecuritySettings.jsx │ ├── SettingsForm.jsx │ └── page.jsx ├── tpms │ └── page.jsx ├── training │ ├── TrainingControl.jsx │ └── page.jsx └── update │ ├── BackfillButton.jsx │ └── page.jsx ├── components.json ├── components ├── DashboardSkeleton.jsx ├── FlaggedPlatesTable.jsx ├── ImageViewer.jsx ├── KnownPlatesTable.jsx ├── LiveFeedSkeleton.jsx ├── LiveFeedTable.jsx ├── LiveRecognitionViewer.jsx ├── MetricsHandler.jsx ├── NotificationsTable.jsx ├── PlateDialog.jsx ├── PlateImage.jsx ├── PlateTable.jsx ├── PlateTableClient.jsx ├── PlateTableWrapper.jsx ├── Sidebar.jsx ├── TestNotificationButton.jsx ├── ThemeToggle.jsx ├── TrainingHandler.jsx ├── UpdateAlert.jsx ├── debugTable.jsx ├── icons │ └── tpms.jsx ├── layout │ ├── BasicTitle.jsx │ ├── LiveFeedNav.jsx │ ├── MainLayout.jsx │ └── TitleNav.jsx ├── lottie │ └── camloading.json ├── plateDbTable.jsx ├── plateMetricsModal.jsx └── ui │ ├── accordion.jsx │ ├── alert-dialog.jsx │ ├── alert.jsx │ ├── aspect-ratio.jsx │ ├── avatar.jsx │ ├── badge.jsx │ ├── breadcrumb.jsx │ ├── button.jsx │ ├── calendar.jsx │ ├── card.jsx │ ├── carousel.jsx │ ├── chart.jsx │ ├── checkbox.jsx │ ├── collapsible.jsx │ ├── command.jsx │ ├── context-menu.jsx │ ├── dialog.jsx │ ├── drawer.jsx │ ├── dropdown-menu.jsx │ ├── form.jsx │ ├── hover-card.jsx │ ├── input-otp.jsx │ ├── input.jsx │ ├── label.jsx │ ├── menubar.jsx │ ├── navigation-menu.jsx │ ├── pagination.jsx │ ├── popover.jsx │ ├── progress.jsx │ ├── radio-group.jsx │ ├── resizable.jsx │ ├── scroll-area.jsx │ ├── select.jsx │ ├── separator.jsx │ ├── sheet.jsx │ ├── sidebar.jsx │ ├── skeleton.jsx │ ├── slider.jsx │ ├── sonner.jsx │ ├── switch.jsx │ ├── table.jsx │ ├── tabs.jsx │ ├── textarea.jsx │ ├── toast.jsx │ ├── toaster.jsx │ ├── toggle-group.jsx │ ├── toggle-switch.jsx │ ├── toggle.jsx │ └── tooltip.jsx ├── docker-compose-dbonly.yml ├── docker-compose.without-database.yml ├── docker-compose.yml ├── example.json ├── hooks ├── use-mobile.jsx └── use-toast.js ├── install.ps1 ├── install.sh ├── jsconfig.json ├── json-dump-example.json ├── lib ├── auth.js ├── cleanupService.js ├── clientUtils.js ├── db.js ├── fileStorage.js ├── mqtt-client.js ├── notifications.js ├── settings.js ├── sse.js ├── training.js ├── utils.js └── version.js ├── logging └── logger.js ├── middleware.js ├── migrations.sql ├── next.config.js ├── package.json ├── postcss.config.mjs ├── public ├── 1024.png ├── 180.png ├── 512.png ├── alpr.jpg ├── alpr_icon.svg ├── fallback.jpg ├── file.svg ├── globe.svg ├── grid.svg ├── icon.png ├── icon512_maskable.png ├── icon512_rounded.png ├── license-plate.ico ├── manifest.json ├── next.svg ├── placeholder.jpg ├── splash_screens │ ├── 10.2__iPad_landscape.png │ ├── 10.2__iPad_portrait.png │ ├── 10.5__iPad_Air_landscape.png │ ├── 10.5__iPad_Air_portrait.png │ ├── 10.9__iPad_Air_landscape.png │ ├── 10.9__iPad_Air_portrait.png │ ├── 11__iPad_Pro_M4_landscape.png │ ├── 11__iPad_Pro_M4_portrait.png │ ├── 11__iPad_Pro__10.5__iPad_Pro_landscape.png │ ├── 11__iPad_Pro__10.5__iPad_Pro_portrait.png │ ├── 12.9__iPad_Pro_landscape.png │ ├── 12.9__iPad_Pro_portrait.png │ ├── 13__iPad_Pro_M4_landscape.png │ ├── 13__iPad_Pro_M4_portrait.png │ ├── 4__iPhone_SE__iPod_touch_5th_generation_and_later_landscape.png │ ├── 4__iPhone_SE__iPod_touch_5th_generation_and_later_portrait.png │ ├── 8.3__iPad_Mini_landscape.png │ ├── 8.3__iPad_Mini_portrait.png │ ├── 9.7__iPad_Pro__7.9__iPad_mini__9.7__iPad_Air__9.7__iPad_landscape.png │ ├── 9.7__iPad_Pro__7.9__iPad_mini__9.7__iPad_Air__9.7__iPad_portrait.png │ ├── iPhone_11_Pro_Max__iPhone_XS_Max_landscape.png │ ├── iPhone_11_Pro_Max__iPhone_XS_Max_portrait.png │ ├── iPhone_11__iPhone_XR_landscape.png │ ├── iPhone_11__iPhone_XR_portrait.png │ ├── iPhone_13_mini__iPhone_12_mini__iPhone_11_Pro__iPhone_XS__iPhone_X_landscape.png │ ├── iPhone_13_mini__iPhone_12_mini__iPhone_11_Pro__iPhone_XS__iPhone_X_portrait.png │ ├── iPhone_14_Plus__iPhone_13_Pro_Max__iPhone_12_Pro_Max_landscape.png │ ├── iPhone_14_Plus__iPhone_13_Pro_Max__iPhone_12_Pro_Max_portrait.png │ ├── iPhone_14__iPhone_13_Pro__iPhone_13__iPhone_12_Pro__iPhone_12_landscape.png │ ├── iPhone_14__iPhone_13_Pro__iPhone_13__iPhone_12_Pro__iPhone_12_portrait.png │ ├── iPhone_16_Plus__iPhone_15_Pro_Max__iPhone_15_Plus__iPhone_14_Pro_Max_landscape.png │ ├── iPhone_16_Plus__iPhone_15_Pro_Max__iPhone_15_Plus__iPhone_14_Pro_Max_portrait.png │ ├── iPhone_16_Pro_Max_landscape.png │ ├── iPhone_16_Pro_Max_portrait.png │ ├── iPhone_16_Pro_landscape.png │ ├── iPhone_16_Pro_portrait.png │ ├── iPhone_16__iPhone_15_Pro__iPhone_15__iPhone_14_Pro_landscape.png │ ├── iPhone_16__iPhone_15_Pro__iPhone_15__iPhone_14_Pro_portrait.png │ ├── iPhone_8_Plus__iPhone_7_Plus__iPhone_6s_Plus__iPhone_6_Plus_landscape.png │ ├── iPhone_8_Plus__iPhone_7_Plus__iPhone_6s_Plus__iPhone_6_Plus_portrait.png │ ├── iPhone_8__iPhone_7__iPhone_6s__iPhone_6__4.7__iPhone_SE_landscape.png │ └── iPhone_8__iPhone_7__iPhone_6s__iPhone_6__4.7__iPhone_SE_portrait.png ├── test-plate.jpg ├── tpms.svg ├── vercel.svg └── window.svg ├── schema.sql ├── tailwind.config.js ├── test-mqtt.js ├── test-payload.json ├── update.ps1 ├── update.sh └── yarn.lock /.dockerignore: -------------------------------------------------------------------------------- 1 | .dockerignore 2 | 3 | # Not needed as we are bundling the whole app 4 | node_modules 5 | # Log files 6 | logs 7 | *.log 8 | npm-debug.log* 9 | pnpm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* 12 | .next 13 | .git 14 | install.sh 15 | update.sh 16 | 17 | config/* 18 | logs/* 19 | auth/* 20 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.* 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/versions 12 | 13 | # testing 14 | /coverage 15 | 16 | # next.js 17 | /.next/ 18 | /out/ 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | 32 | # env files (can opt-in for committing if needed) 33 | .env* 34 | 35 | # vercel 36 | .vercel 37 | 38 | # typescript 39 | *.tsbuildinfo 40 | next-env.d.ts 41 | 42 | # # config 43 | config/settings.yaml 44 | auth/auth.json 45 | 46 | #logs 47 | logs/app.log 48 | 49 | #storage 50 | 51 | storage/* 52 | -------------------------------------------------------------------------------- /1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/algertc/ALPR-Database/9b7422865b5c18648faba297c5901e0d9faad253/1024.png -------------------------------------------------------------------------------- /180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/algertc/ALPR-Database/9b7422865b5c18648faba297c5901e0d9faad253/180.png -------------------------------------------------------------------------------- /192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/algertc/ALPR-Database/9b7422865b5c18648faba297c5901e0d9faad253/192.png -------------------------------------------------------------------------------- /512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/algertc/ALPR-Database/9b7422865b5c18648faba297c5901e0d9faad253/512.png -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | 4 | ## [0.1.8] - 03-19-2025 5 | 6 | **This is a major update. It will require some changes to your Blue Iris configuration and an update to the codeproject.ai ALPR module to take full advantage of the functionality. 7 | See release notes for more detail on how to update the other systems: https://github.com/algertc/ALPR-Database/releases** 8 | 9 | - Automatic AI model training to improve recognition accuracy 10 | - Full UI/UX redux 11 | - Mobile Application 12 | - New secondary live view page similar to Motorola law enforcement UI 13 | - Additional dashboard metrics 14 | - Several bug fixes and other improvements 15 | - Foundation for soon-to-come RF fingerprinting functionality 16 | 17 | 18 | ## [0.1.7] - 02-11-2025 19 | 20 | - Complete overhaul of image storage system 21 | - Tables UI improved with more advanced filtering and sorting 22 | - Manually add known plates without prior detection 23 | - Plate image viewer with integrated actions 24 | - System logs page 25 | - Improved timestamp display and time zone handling 26 | - Automatic install and update scripts 27 | - A variety of other bug fixes and performance improvements 28 | - **This update is a major change and will require existing users to complete the update process within the app to migrate their images** 29 | 30 | ## [0.1.6] - 01-03-2025 31 | 32 | - Live update of recognition feed 33 | - New dashboard visualizations & controls 34 | - Speed & loading improvements 35 | - Ability to edit tag name and color 36 | - More sensible default database sorting 37 | - Set ignore flag on known plates to exclude from database 38 | - Time formatting fix 39 | - **Requires new migrations.sql update from GitHub** 40 | 41 | ## [0.1.5] - 12-09-2024 42 | 43 | - Support for 24 hour time 44 | - Fixed max records pruning 45 | - Time based recognition filtering 46 | - Pagination for database page 47 | - Notification time zone fix 48 | - Live feed plate image modal 49 | - UI Improvements 50 | 51 | ## [0.1.4] - 12-01-2024 52 | 53 | - Added camera name column to live feed. Optionally send with "camera":"&CAM" or &NAME for long name. 54 | - Additional sorting options in plate database 55 | - Auth bypass for HomeAssistant dashboards 56 | - Database migration fix (**Requires new migrations.sql file from GitHub**) 57 | - Ability to correct/edit OCR recognitions in the live feed 58 | 59 | ## [0.1.3] - 11-20-2024 60 | 61 | - Database Pruning Fix 62 | 63 | ## [0.1.2] - 11-20-2024 64 | 65 | - Push Notification bug fixes and improvements 66 | 67 | ## [0.1.1] - 11-19-2024 68 | 69 | - Fixed Docker volume mappings 70 | - Fuzzy search 71 | - Optionally use &MEMO instead of &PLATE to capture multiple plates in a single image 72 | 73 | ## [0.1.0] - 11-16-2024 74 | 75 | - Initial release 76 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20-bullseye AS builder 2 | WORKDIR /app 3 | COPY package.json next.config.js ./ 4 | 5 | RUN apt-get update && apt-get install -y \ 6 | build-essential \ 7 | libcairo2-dev \ 8 | libpango1.0-dev \ 9 | libjpeg-dev \ 10 | libgif-dev \ 11 | librsvg2-dev \ 12 | python3 \ 13 | && rm -rf /var/lib/apt/lists/* 14 | 15 | 16 | # A bunch of canvas bs 17 | ENV npm_config_canvas_binary_host_mirror=https://github.com/Automattic/node-canvas/releases/download/ 18 | ENV CXXFLAGS="-DSYZX_FEATURE_FLAG=1" 19 | 20 | COPY package.json yarn.lock* ./ 21 | RUN yarn install --network-timeout 100000 || \ 22 | (echo "Retrying with canvas workaround..." && \ 23 | yarn add canvas@2.11.2 --network-timeout 100000 && \ 24 | yarn install --network-timeout 100000) 25 | 26 | 27 | COPY . . 28 | RUN yarn build 29 | 30 | 31 | FROM node:20-bullseye 32 | WORKDIR /app 33 | 34 | RUN apt-get update && apt-get install -y \ 35 | libcairo2 \ 36 | libpango-1.0-0 \ 37 | libpangocairo-1.0-0 \ 38 | libjpeg62-turbo \ 39 | libgif7 \ 40 | librsvg2-2 \ 41 | && rm -rf /var/lib/apt/lists/* 42 | 43 | COPY --from=builder /app/.next ./.next 44 | COPY --from=builder /app/public ./public 45 | COPY --from=builder /app /app 46 | COPY --from=builder /app/node_modules ./node_modules 47 | COPY --from=builder /app/package.json ./package.json 48 | EXPOSE 3000 49 | RUN mkdir -p /auth 50 | CMD ["yarn", "start"] -------------------------------------------------------------------------------- /Images/1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/algertc/ALPR-Database/9b7422865b5c18648faba297c5901e0d9faad253/Images/1.png -------------------------------------------------------------------------------- /Images/2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/algertc/ALPR-Database/9b7422865b5c18648faba297c5901e0d9faad253/Images/2.png -------------------------------------------------------------------------------- /Images/3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/algertc/ALPR-Database/9b7422865b5c18648faba297c5901e0d9faad253/Images/3.png -------------------------------------------------------------------------------- /Images/4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/algertc/ALPR-Database/9b7422865b5c18648faba297c5901e0d9faad253/Images/4.png -------------------------------------------------------------------------------- /Images/5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/algertc/ALPR-Database/9b7422865b5c18648faba297c5901e0d9faad253/Images/5.png -------------------------------------------------------------------------------- /Images/Hero.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/algertc/ALPR-Database/9b7422865b5c18648faba297c5901e0d9faad253/Images/Hero.jpg -------------------------------------------------------------------------------- /Images/alert.JPG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/algertc/ALPR-Database/9b7422865b5c18648faba297c5901e0d9faad253/Images/alert.JPG -------------------------------------------------------------------------------- /Images/apikey.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/algertc/ALPR-Database/9b7422865b5c18648faba297c5901e0d9faad253/Images/apikey.png -------------------------------------------------------------------------------- /Images/app_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/algertc/ALPR-Database/9b7422865b5c18648faba297c5901e0d9faad253/Images/app_icon.png -------------------------------------------------------------------------------- /Images/blueiris.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/algertc/ALPR-Database/9b7422865b5c18648faba297c5901e0d9faad253/Images/blueiris.png -------------------------------------------------------------------------------- /Images/dash.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/algertc/ALPR-Database/9b7422865b5c18648faba297c5901e0d9faad253/Images/dash.jpg -------------------------------------------------------------------------------- /Images/db.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/algertc/ALPR-Database/9b7422865b5c18648faba297c5901e0d9faad253/Images/db.jpg -------------------------------------------------------------------------------- /Images/hero2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/algertc/ALPR-Database/9b7422865b5c18648faba297c5901e0d9faad253/Images/hero2.png -------------------------------------------------------------------------------- /Images/insights.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/algertc/ALPR-Database/9b7422865b5c18648faba297c5901e0d9faad253/Images/insights.jpg -------------------------------------------------------------------------------- /Images/liveview.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/algertc/ALPR-Database/9b7422865b5c18648faba297c5901e0d9faad253/Images/liveview.jpg -------------------------------------------------------------------------------- /Images/push.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/algertc/ALPR-Database/9b7422865b5c18648faba297c5901e0d9faad253/Images/push.jpg -------------------------------------------------------------------------------- /Images/tags.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/algertc/ALPR-Database/9b7422865b5c18648faba297c5901e0d9faad253/Images/tags.jpg -------------------------------------------------------------------------------- /Images/tpms.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/algertc/ALPR-Database/9b7422865b5c18648faba297c5901e0d9faad253/Images/tpms.jpg -------------------------------------------------------------------------------- /Images/viewer.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/algertc/ALPR-Database/9b7422865b5c18648faba297c5901e0d9faad253/Images/viewer.jpg -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Charlie Algert 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /app/1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/algertc/ALPR-Database/9b7422865b5c18648faba297c5901e0d9faad253/app/1024.png -------------------------------------------------------------------------------- /app/180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/algertc/ALPR-Database/9b7422865b5c18648faba297c5901e0d9faad253/app/180.png -------------------------------------------------------------------------------- /app/192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/algertc/ALPR-Database/9b7422865b5c18648faba297c5901e0d9faad253/app/192.png -------------------------------------------------------------------------------- /app/512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/algertc/ALPR-Database/9b7422865b5c18648faba297c5901e0d9faad253/app/512.png -------------------------------------------------------------------------------- /app/api/_startup.js: -------------------------------------------------------------------------------- 1 | import { initializeAuth } from "@/lib/auth"; 2 | 3 | let initialized = false; 4 | let initializationPromise = null; 5 | 6 | export async function ensureInitialized() { 7 | if (initialized) return; 8 | 9 | // If initialization is in progress, wait for it 10 | if (initializationPromise) { 11 | await initializationPromise; 12 | return; 13 | } 14 | 15 | // Start initialization 16 | initializationPromise = (async () => { 17 | try { 18 | await initializeAuth(); 19 | initialized = true; 20 | } catch (error) { 21 | console.error("Failed to initialize auth system:", error); 22 | throw error; 23 | } finally { 24 | initializationPromise = null; 25 | } 26 | })(); 27 | 28 | await initializationPromise; 29 | } 30 | -------------------------------------------------------------------------------- /app/api/check-update/route.js: -------------------------------------------------------------------------------- 1 | import { checkUpdateStatus } from "@/lib/db"; 2 | import { NextResponse } from "next/server"; 3 | 4 | export async function GET() { 5 | try { 6 | const updateStatus = await checkUpdateStatus(); 7 | return NextResponse.json({ updateRequired: !updateStatus }); 8 | } catch (error) { 9 | console.error("Error checking update status:", error); 10 | return NextResponse.json({ updateRequired: false }); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /app/api/health-check/route.js: -------------------------------------------------------------------------------- 1 | // app/api/health-check/route.js 2 | import { getPool } from "@/lib/db"; 3 | 4 | export async function GET() { 5 | try { 6 | const pool = await getPool(); 7 | const client = await pool.connect(); 8 | await client.query("SELECT 1"); 9 | client.release(); 10 | return Response.json({ status: "ok" }); 11 | } catch (error) { 12 | return Response.json( 13 | { status: "error", message: error.message }, 14 | { status: 500 } 15 | ); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /app/api/notifications/test/route.js: -------------------------------------------------------------------------------- 1 | import { sendPushoverNotification } from "@/lib/notifications"; 2 | import { NextResponse } from "next/server"; 3 | 4 | export async function POST(request) { 5 | try { 6 | const formData = await request.formData(); 7 | const plateNumber = formData.get("plateNumber"); 8 | 9 | if (!plateNumber) { 10 | return NextResponse.json( 11 | { success: false, error: "Plate number is required" }, 12 | { status: 400 } 13 | ); 14 | } 15 | 16 | // Create a test message that makes it clear this is a test 17 | const testMessage = `🔔 TEST NOTIFICATION:\nPlate number ${plateNumber} detected\n\nThis is a test notification sent from the ALPR Database settings panel.`; 18 | 19 | const result = await sendPushoverNotification(plateNumber, testMessage); 20 | 21 | if (result.success) { 22 | return NextResponse.json({ 23 | success: true, 24 | message: "Test notification sent successfully", 25 | data: result.data, 26 | }); 27 | } else { 28 | // If there's a specific error from Pushover, pass it through 29 | return NextResponse.json( 30 | { 31 | success: false, 32 | error: result.error || "Failed to send test notification", 33 | details: result.data, 34 | }, 35 | { status: 500 } 36 | ); 37 | } 38 | } catch (error) { 39 | console.error("Test notification error:", error); 40 | return NextResponse.json( 41 | { 42 | success: false, 43 | error: "Failed to send test notification", 44 | details: error.message, 45 | }, 46 | { status: 500 } 47 | ); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /app/api/verify-key/route.js: -------------------------------------------------------------------------------- 1 | import { getAuthConfig, verifyApiKey } from "@/lib/auth"; 2 | import { ensureInitialized } from "../_startup"; 3 | 4 | export const dynamic = "force-dynamic"; 5 | export const runtime = "nodejs"; 6 | 7 | export async function POST(request) { 8 | await ensureInitialized(); 9 | 10 | try { 11 | const { apiKey } = await request.json(); 12 | console.log("Checking API key"); 13 | const keyInfo = await verifyApiKey(apiKey); 14 | 15 | if (keyInfo) { 16 | return Response.json({ valid: true, user: keyInfo.user }); 17 | } 18 | return Response.json({ valid: false }, { status: 401 }); 19 | } catch (error) { 20 | console.error("Error verifying API key:", error); 21 | return Response.json({ error: "Internal server error" }, { status: 500 }); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /app/api/verify-session/route.js: -------------------------------------------------------------------------------- 1 | // app/api/verify-session/route.js (Re-introduced and improved) 2 | import { verifySession, getSessionInfo, initializeAuth } from "@/lib/auth"; // Import initializeAuth 3 | import { NextResponse } from "next/server"; 4 | 5 | export const dynamic = "force-dynamic"; // Ensures this route is not cached 6 | export const runtime = "nodejs"; // Explicitly run this API route in Node.js runtime 7 | 8 | export async function POST(req) { 9 | // Ensure auth system is initialized before proceeding 10 | try { 11 | await initializeAuth(); 12 | } catch (initError) { 13 | console.error( 14 | "Auth initialization failed in /api/verify-session:", 15 | initError 16 | ); 17 | return new NextResponse( 18 | JSON.stringify({ 19 | valid: false, 20 | message: "Authentication system initialization error", 21 | }), 22 | { status: 500 } 23 | ); 24 | } 25 | 26 | try { 27 | const { sessionId } = await req.json(); 28 | 29 | if (!sessionId) { 30 | return new NextResponse( 31 | JSON.stringify({ valid: false, message: "Session ID is required" }), 32 | { status: 400 } 33 | ); 34 | } 35 | 36 | const isValid = await verifySession(sessionId); 37 | const sessionInfo = isValid ? await getSessionInfo(sessionId) : null; 38 | 39 | return new NextResponse( 40 | JSON.stringify({ 41 | valid: isValid, 42 | sessionInfo: sessionInfo, 43 | }), 44 | { status: 200 } 45 | ); 46 | } catch (error) { 47 | console.error("Error in /api/verify-session:", error); 48 | // Be careful with error messages in production to avoid leaking info 49 | return new NextResponse( 50 | JSON.stringify({ 51 | valid: false, 52 | message: "Internal server error during session verification", 53 | }), 54 | { status: 500 } 55 | ); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /app/api/verify-whitelist/route.js: -------------------------------------------------------------------------------- 1 | import { getConfig } from "@/lib/settings"; 2 | import { NextResponse } from "next/server"; 3 | 4 | // Validates IPv4 address format 5 | const isValidIPv4 = (ip) => { 6 | const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/; 7 | if (!ipv4Regex.test(ip)) return false; 8 | 9 | const parts = ip.split("."); 10 | return parts.every((part) => { 11 | const num = parseInt(part, 10); 12 | return num >= 0 && num <= 255; 13 | }); 14 | }; 15 | 16 | // Validates IPv6 address format 17 | const isValidIPv6 = (ip) => { 18 | // Remove any leading/trailing brackets 19 | ip = ip.replace(/^\[|\]$/g, ""); 20 | 21 | // Handle compressed IPv6 format 22 | const ipv6Regex = 23 | /^([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$|^(([0-9a-fA-F]{1,4}:){0,6}[0-9a-fA-F]{1,4})?::([0-9a-fA-F]{1,4}:){0,6}[0-9a-fA-F]{1,4}$/; 24 | return ipv6Regex.test(ip); 25 | }; 26 | 27 | // Normalizes an IP address 28 | const normalizeIP = (ip) => { 29 | ip = ip.trim(); 30 | 31 | // Handle IPv4-mapped IPv6 addresses 32 | if (ip.startsWith("::ffff:")) { 33 | ip = ip.substring(7); 34 | } 35 | 36 | // Remove any square brackets from IPv6 37 | ip = ip.replace(/^\[|\]$/g, ""); 38 | 39 | return ip; 40 | }; 41 | 42 | // Gets client IP from X-Forwarded-For header 43 | const getClientIP = (forwardedFor) => { 44 | if (!forwardedFor) return null; 45 | 46 | // Split the header into individual IPs 47 | const ips = forwardedFor.split(",").map((ip) => normalizeIP(ip)); 48 | 49 | // Get the leftmost valid IP (original client) 50 | for (const ip of ips) { 51 | if (isValidIPv4(ip) || isValidIPv6(ip)) { 52 | return ip; 53 | } 54 | } 55 | 56 | return null; 57 | }; 58 | 59 | export async function POST(request) { 60 | try { 61 | const { headers } = await request.json(); 62 | const forwardedFor = headers["x-forwarded-for"]; 63 | if (!forwardedFor) { 64 | console.warn("No X-Forwarded-For header present"); 65 | return NextResponse.json({ allowed: false }); 66 | } 67 | 68 | // Get the client IP from X-Forwarded-For header 69 | const clientIP = getClientIP(forwardedFor); 70 | if (!clientIP) { 71 | console.warn("No valid IP found in X-Forwarded-For header"); 72 | return NextResponse.json({ allowed: false }); 73 | } 74 | 75 | console.log("Checking IP:", clientIP); 76 | 77 | // Load the configuration data 78 | const config = await getConfig(); 79 | const whitelist = config.homeassistant?.whitelist || []; 80 | 81 | if (whitelist.length === 0) { 82 | console.warn("No whitelisted IPs configured"); 83 | return NextResponse.json({ allowed: false }); 84 | } 85 | 86 | // Normalize all whitelist IPs and compare 87 | const normalizedWhitelist = whitelist.map(normalizeIP); 88 | const isAllowedIp = normalizedWhitelist.includes(normalizeIP(clientIP)); 89 | 90 | if (!isAllowedIp) { 91 | console.warn(`IP ${clientIP} is not in the whitelist`); 92 | } 93 | 94 | return NextResponse.json({ allowed: isAllowedIp }); 95 | } catch (error) { 96 | console.error("Error checking whitelisted IP:", error); 97 | return NextResponse.json({ allowed: false }, { status: 500 }); 98 | } 99 | } 100 | 101 | -------------------------------------------------------------------------------- /app/apple-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/algertc/ALPR-Database/9b7422865b5c18648faba297c5901e0d9faad253/app/apple-icon.png -------------------------------------------------------------------------------- /app/backfill/BackfillButton.jsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useState } from "react"; 4 | import { Button } from "@/components/ui/button"; 5 | 6 | export function BackfillButton({ dbBackfill }) { 7 | const [response, setResponse] = useState(""); 8 | const [isLoading, setIsLoading] = useState(false); 9 | 10 | return ( 11 | <> 12 | 33 | {response &&

{response}

} 34 | 35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /app/backfill/page.jsx: -------------------------------------------------------------------------------- 1 | import { BackfillButton } from "./BackfillButton"; 2 | import { dbBackfill } from "@/app/actions"; 3 | 4 | export const dynamic = "force-dynamic"; 5 | 6 | export default async function BackfillPage() { 7 | return ( 8 |
9 |

10 | Backfill Occurrence Counts to New Column in Plates Table 11 |

12 | 13 |
14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /app/components/ui/tooltip.jsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as TooltipPrimitive from "@radix-ui/react-tooltip"; 5 | 6 | import { cn } from "@/lib/utils"; 7 | 8 | const TooltipProvider = TooltipPrimitive.Provider; 9 | 10 | const Tooltip = TooltipPrimitive.Root; 11 | 12 | const TooltipTrigger = TooltipPrimitive.Trigger; 13 | 14 | const TooltipContent = React.forwardRef( 15 | ({ className, sideOffset = 4, ...props }, ref) => ( 16 | 17 | 26 | 27 | ) 28 | ); 29 | TooltipContent.displayName = TooltipPrimitive.Content.displayName; 30 | 31 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }; 32 | -------------------------------------------------------------------------------- /app/dashboard/CameraSelect.jsx: -------------------------------------------------------------------------------- 1 | import { 2 | Select, 3 | SelectContent, 4 | SelectItem, 5 | SelectTrigger, 6 | SelectValue, 7 | } from "@/components/ui/select"; 8 | 9 | export function CameraSelector({ value, onValueChange, cameras, loading }) { 10 | return ( 11 |
12 | 29 |
30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /app/dashboard/TimeSelect.jsx: -------------------------------------------------------------------------------- 1 | import { 2 | Select, 3 | SelectContent, 4 | SelectItem, 5 | SelectTrigger, 6 | SelectValue, 7 | } from "@/components/ui/select"; 8 | 9 | export function TimeFrameSelector({ value, onValueChange }) { 10 | const timeFrames = [ 11 | { value: "24h", label: "Last 24 Hours" }, 12 | { value: "3d", label: "Last 3 Days" }, 13 | { value: "7d", label: "Last 7 Days" }, 14 | { value: "30d", label: "Last 30 Days" }, 15 | { value: "all", label: "All Time" }, 16 | ]; 17 | 18 | return ( 19 | 31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /app/dashboard/page.jsx: -------------------------------------------------------------------------------- 1 | import { Suspense } from "react"; 2 | import DashboardLayout from "@/components/layout/MainLayout"; 3 | import DashboardMetrics from "./DashboardMetrics"; 4 | import { DashboardSkeleton } from "@/components/DashboardSkeleton"; 5 | import { MetricsHandler } from "@/components/MetricsHandler"; 6 | import { TrainingDataHandler } from "@/components/TrainingHandler"; 7 | 8 | export default function Dashboard() { 9 | return ( 10 | 11 |
12 | }> 13 | 14 | 15 | 16 | 17 |
18 |
19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /app/database/page.jsx: -------------------------------------------------------------------------------- 1 | import DashboardLayout from "@/components/layout/MainLayout"; 2 | import TitleNavbar from "@/components/layout/TitleNav"; 3 | import PlateDbTable from "@/components/plateDbTable"; 4 | import { getPlates } from "@/app/actions"; 5 | 6 | export default async function Database() { 7 | let plateReads = []; 8 | 9 | if (typeof window !== "undefined") { 10 | // Stop this from trying to connect during build 11 | plateReads = await getPlates(1, 25, { 12 | key: "last_seen_at", 13 | direction: "desc", 14 | }); 15 | } 16 | 17 | return ( 18 | 19 | 20 | 21 | 22 | 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/algertc/ALPR-Database/9b7422865b5c18648faba297c5901e0d9faad253/app/favicon.ico -------------------------------------------------------------------------------- /app/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 8 | 10 | 11 | 13 | 17 | 21 | 22 | 24 | 28 | 33 | 34 | 35 | 37 | 39 | 40 | 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /app/flagged/page.jsx: -------------------------------------------------------------------------------- 1 | import { getFlagged } from "@/app/actions"; 2 | import { FlaggedPlatesTable } from "@/components/FlaggedPlatesTable"; 3 | import DashboardLayout from "@/components/layout/MainLayout"; 4 | import BasicTitle from "@/components/layout/BasicTitle"; 5 | export const dynamic = "force-dynamic"; 6 | 7 | export default async function FlaggedPlatesPage() { 8 | const flaggedPlates = await getFlagged(); 9 | 10 | return ( 11 | 12 | 13 | 14 | 15 | 16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /app/fonts/GeistMonoVF.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/algertc/ALPR-Database/9b7422865b5c18648faba297c5901e0d9faad253/app/fonts/GeistMonoVF.woff -------------------------------------------------------------------------------- /app/fonts/GeistVF.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/algertc/ALPR-Database/9b7422865b5c18648faba297c5901e0d9faad253/app/fonts/GeistVF.woff -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | body { 6 | font-family: Arial, Helvetica, sans-serif; 7 | --webkit-tap-highlight-color: transparent; 8 | } 9 | 10 | @media (max-width: 900px) { 11 | ::-webkit-scrollbar { 12 | display: none; 13 | } 14 | 15 | * { 16 | -ms-overflow-style: none; 17 | scrollbar-width: none; 18 | } 19 | } 20 | 21 | @layer base { 22 | :root { 23 | --radius: 0.5rem; 24 | --sidebar-background: 0 0% 98%; 25 | --sidebar-foreground: 240 5.3% 26.1%; 26 | --sidebar-primary: 240 5.9% 10%; 27 | --sidebar-primary-foreground: 0 0% 98%; 28 | --sidebar-accent: 240 4.8% 95.9%; 29 | --sidebar-accent-foreground: 240 5.9% 10%; 30 | --sidebar-border: 220 13% 91%; 31 | --sidebar-ring: 217.2 91.2% 59.8%; 32 | --background: 0 0% 100%; 33 | --foreground: 240 10% 3.9%; 34 | --card: 0 0% 100%; 35 | --card-foreground: 240 10% 3.9%; 36 | --popover: 0 0% 100%; 37 | --popover-foreground: 240 10% 3.9%; 38 | --primary: 240 5.9% 10%; 39 | --primary-foreground: 0 0% 98%; 40 | --secondary: 240 4.8% 95.9%; 41 | --secondary-foreground: 240 5.9% 10%; 42 | --muted: 240 4.8% 95.9%; 43 | --muted-foreground: 240 3.8% 46.1%; 44 | --accent: 240 4.8% 95.9%; 45 | --accent-foreground: 240 5.9% 10%; 46 | --destructive: 339, 90%, 51%; 47 | --destructive-foreground: 0 0% 98%; 48 | --border: 240 5.9% 90%; 49 | --input: 240 5.9% 90%; 50 | --ring: 240 10% 3.9%; 51 | --chart-1: 221.2 83.2% 53.3%; 52 | --chart-2: 212 95% 68%; 53 | --chart-3: 216 92% 60%; 54 | --chart-4: 210 98% 78%; 55 | --chart-5: 212 97% 87%; 56 | } 57 | .dark { 58 | --sidebar-background: 240 5.9% 10%; 59 | --sidebar-foreground: 240 4.8% 95.9%; 60 | --sidebar-primary: 224.3 76.3% 48%; 61 | --sidebar-primary-foreground: 0 0% 100%; 62 | --sidebar-accent: 240 3.7% 15.9%; 63 | --sidebar-accent-foreground: 240 4.8% 95.9%; 64 | --sidebar-border: 240 3.7% 15.9%; 65 | --sidebar-ring: 217.2 91.2% 59.8%; 66 | --background: 240 10% 3.9%; 67 | --foreground: 0 0% 98%; 68 | --card: 240 10% 3.9%; 69 | --card-foreground: 0 0% 98%; 70 | --popover: 240 10% 3.9%; 71 | --popover-foreground: 0 0% 98%; 72 | --primary: 0 0% 99%; 73 | --primary-foreground: 240 5.9% 10%; 74 | --secondary: 240 3.7% 15.9%; 75 | --secondary-foreground: 0 0% 98%; 76 | --muted: 240 3.7% 15.9%; 77 | --muted-foreground: 240 5% 64.9%; 78 | --accent: 240 3.7% 15.9%; 79 | --accent-foreground: 0 0% 98%; 80 | --destructive: 339, 90%, 51%; 81 | --destructive-foreground: 339, 90%, 51%; 82 | --border: 240 3.7% 15.9%; 83 | --input: 240 3.7% 15.9%; 84 | --ring: 240 4.9% 83.9%; 85 | --chart-1: 221.2 83.2% 53.3%; 86 | --chart-2: 212 95% 68%; 87 | --chart-3: 216 92% 60%; 88 | --chart-4: 210 98% 78%; 89 | --chart-5: 212 97% 87%; 90 | } 91 | } 92 | 93 | @layer base { 94 | * { 95 | @apply border-border; 96 | } 97 | body { 98 | @apply bg-background text-foreground; 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /app/help/page.jsx: -------------------------------------------------------------------------------- 1 | import DashboardLayout from "@/components/layout/MainLayout"; 2 | import Link from "next/link"; 3 | 4 | export const dynamic = "force-dynamic"; 5 | 6 | export default function Page() { 7 | return ( 8 | 9 |
10 |
11 |
12 |

13 | Major Changes - Jan 20 14 |

15 |
16 |

17 | Something broken? See below for development update and how to fix.{" "} 18 |

19 |
20 |

21 | Several changes have been made that will greatly improve the 22 | performance and reliability of the application. A full update 23 | release will be coming soon with a more automated upgrade process, 24 | but I am embedding this quick guide in the meantime for any early 25 | adopters. 26 |

27 |

28 | There are two major changes in the database that will require some 29 | quick manual action to transform your existing data. 30 |

31 |
32 | 33 | - Filesystem Image Storage 34 | 35 | 36 | - Explicit Occurrence Count Tracking 37 | 38 |
39 |

40 | To Migrate Your Existing Data: 41 |

42 |
    43 |
  1. 44 | Ensure you have the latest docker-compose.yml and migrations.sql 45 | files. 46 |
  2. 47 |
  3. 48 | Create a new directory called "storage" in the same 49 | place as your auth and config directories. This is where JPEGs 50 | will now be stored. 51 |
  4. 52 |
  5. 53 | Backfill the new occurrence_count column.{" "} 54 | 59 | This page 60 | {" "} 61 | has a tool that will count them up and fill in the records for 62 | you. 63 |
  6. 64 |
  7. 65 | Convert and transfer all your old base64 images to the new 66 | filesystem storage. You can do that with{" "} 67 | 72 | this tool. 73 | {" "} 74 |
  8. 75 |
  9. 76 | Visit the settings page to set your retention preferences. The 77 | database is wildly faster now and can handle a very large number 78 | of records. You will likely want to increase your max records 79 | value. I am setting the default for new users at 100 thousand. 80 |
  10. 81 |
82 |

83 | Thank you to everyone reporting bugs and leaving suggestions. 84 |

85 |
86 |
87 |
88 |
89 | ); 90 | } 91 | -------------------------------------------------------------------------------- /app/images/[...path]/route.js: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server"; 2 | import fileStorage from "@/lib/fileStorage"; 3 | import path from "path"; 4 | 5 | export async function GET(request, { params }) { 6 | try { 7 | const parameters = await params; 8 | const [folder, ...rest] = await parameters.path; 9 | const filename = rest.join("/"); 10 | 11 | const imageData = await fileStorage.getImage(path.join(folder, filename)); 12 | 13 | if (!imageData) { 14 | return new NextResponse(null, { status: 404 }); 15 | } 16 | 17 | const headers = new Headers(); 18 | headers.set("Content-Type", "image/jpeg"); 19 | headers.set("Cache-Control", "public, max-age=60"); 20 | 21 | return new NextResponse(imageData, { 22 | status: 200, 23 | headers, 24 | }); 25 | } catch (error) { 26 | console.error("Error serving image:", error); 27 | return new NextResponse(null, { status: 500 }); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /app/known_plates/page.jsx: -------------------------------------------------------------------------------- 1 | import { getKnownPlatesList } from "@/app/actions"; 2 | import { KnownPlatesTable } from "@/components/KnownPlatesTable"; 3 | import DashboardLayout from "@/components/layout/MainLayout"; 4 | import BasicTitle from "@/components/layout/BasicTitle"; 5 | 6 | export const dynamic = "force-dynamic"; 7 | 8 | export default async function KnownPlatesPage() { 9 | const response = await getKnownPlatesList(); 10 | const knownPlates = response.success ? response.data : []; 11 | 12 | return ( 13 | 14 | 20 | {knownPlates.length > 0 ? ( 21 | 22 | ) : ( 23 |

No known plates found in the database.

24 | )} 25 |
26 |
27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /app/live_feed/page.jsx: -------------------------------------------------------------------------------- 1 | // app/dashboard/plates/page.jsx 2 | import { 3 | getSettings, 4 | getLatestPlateReads, 5 | getTags, 6 | getCameraNames, 7 | getTimeFormat, 8 | } from "@/app/actions"; 9 | 10 | import PlateTableWrapper from "@/components/PlateTableWrapper"; // Correct path to wrapper 11 | import DashboardLayout from "@/components/layout/MainLayout"; 12 | import BasicTitle from "@/components/layout/BasicTitle"; 13 | import { Suspense } from "react"; 14 | import LiveFeedSkeleton from "@/components/LiveFeedSkeleton"; 15 | import Link from "next/link"; 16 | import TitleNavbar from "@/components/layout/LiveFeedNav"; 17 | 18 | import { Button } from "@/components/ui/button"; 19 | import { unstable_noStore as noStore } from "next/cache"; 20 | 21 | export const dynamic = "force-dynamic"; // Ensures data is fetched on every request 22 | 23 | export default async function LivePlates(props) { 24 | noStore(); // Opt-out of data caching for this component and its data fetches 25 | 26 | const searchParams = await props.searchParams; 27 | 28 | const params = { 29 | page: parseInt(searchParams?.page || "1"), 30 | pageSize: parseInt(searchParams?.pageSize || "25"), 31 | search: searchParams?.search || "", 32 | fuzzySearch: searchParams?.fuzzySearch === "true", 33 | tag: searchParams?.tag || "all", 34 | dateRange: 35 | searchParams?.dateFrom && searchParams?.dateTo 36 | ? { from: searchParams.dateFrom, to: searchParams.dateTo } 37 | : null, 38 | hourRange: 39 | searchParams?.hourFrom && searchParams?.hourTo 40 | ? { 41 | from: parseInt(searchParams.hourFrom), 42 | to: parseInt(searchParams.hourTo), 43 | } 44 | : null, 45 | cameraName: searchParams?.camera, 46 | sortField: searchParams?.sortField, 47 | sortDirection: searchParams?.sortDirection, 48 | }; 49 | 50 | const [platesRes, tagsRes, camerasRes, timeFormat, config] = 51 | await Promise.all([ 52 | getLatestPlateReads(params), 53 | getTags(), 54 | getCameraNames(), 55 | getTimeFormat(), 56 | getSettings(), 57 | ]); 58 | 59 | return ( 60 | 61 | 62 | }> 63 | 71 | 72 | 73 | 74 | ); 75 | } 76 | -------------------------------------------------------------------------------- /app/live_feed/viewer/page.jsx: -------------------------------------------------------------------------------- 1 | import { 2 | getSettings, 3 | getLatestPlateReads, 4 | getTags, 5 | getCameraNames, 6 | getTimeFormat, 7 | } from "@/app/actions"; 8 | 9 | import DashboardLayout from "@/components/layout/MainLayout"; 10 | import TitleNavbar from "@/components/layout/LiveFeedNav"; 11 | import { Suspense } from "react"; 12 | import LiveRecognitionViewer from "@/components/LiveRecognitionViewer"; 13 | import LiveFeedSkeleton from "@/components/LiveFeedSkeleton"; 14 | 15 | export const dynamic = "force-dynamic"; 16 | export const revalidate = 0; // Make sure the page is always fresh 17 | 18 | export default async function LiveViewerPage() { 19 | // We only need the most recent plate read 20 | const params = { 21 | page: 1, 22 | pageSize: 1, 23 | sortField: "timestamp", 24 | sortDirection: "desc", 25 | }; 26 | 27 | const [platesRes, tagsRes, camerasRes, timeFormat, config] = 28 | await Promise.all([ 29 | getLatestPlateReads(params), 30 | getTags(), 31 | getCameraNames(), 32 | getTimeFormat(), 33 | getSettings(), 34 | ]); 35 | const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; 36 | 37 | return ( 38 | 39 | 40 | }> 41 | 48 | 49 | 50 | 51 | ); 52 | } 53 | -------------------------------------------------------------------------------- /app/logs/LogMessage.jsx: -------------------------------------------------------------------------------- 1 | const LogMessage = ({ log }) => { 2 | const getLogColor = (level) => { 3 | switch (level) { 4 | case "ERROR": 5 | return "text-[#F31260]"; 6 | case "WARN": 7 | return "text-[#F5A524]"; 8 | default: 9 | return "text-[#17C964]"; 10 | } 11 | }; 12 | 13 | return ( 14 |
15 | 16 | {new Date(log.timestamp).toLocaleString()} 17 | {" "} 18 | [{log.level}]{" "} 19 | {log.message} 20 |
21 | ); 22 | }; 23 | 24 | export default LogMessage; 25 | -------------------------------------------------------------------------------- /app/logs/LogViewer.jsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useEffect, useRef } from "react"; 4 | import { ScrollArea } from "@/components/ui/scroll-area"; 5 | import { Card, CardContent } from "@/components/ui/card"; 6 | import LogMessage from "./LogMessage"; 7 | 8 | const LogViewer = ({ initialLogs }) => { 9 | const scrollRef = useRef(null); 10 | 11 | useEffect(() => { 12 | const scrollToBottom = () => { 13 | if (scrollRef.current) { 14 | const scrollContainer = scrollRef.current.querySelector( 15 | "[data-radix-scroll-area-viewport]" 16 | ); 17 | if (scrollContainer) { 18 | scrollContainer.scrollTop = scrollContainer.scrollHeight; 19 | } 20 | } 21 | }; 22 | 23 | scrollToBottom(); 24 | }, [initialLogs]); 25 | 26 | if (!initialLogs || !initialLogs.length) { 27 | return ( 28 |
29 | No logs available 30 |
31 | ); 32 | } 33 | 34 | return ( 35 | 36 | 37 | 41 |
42 | {initialLogs.map((log, index) => ( 43 | 44 | ))} 45 |
46 |
47 |
48 |
49 | ); 50 | }; 51 | 52 | export default LogViewer; 53 | -------------------------------------------------------------------------------- /app/logs/page.jsx: -------------------------------------------------------------------------------- 1 | import { Suspense } from "react"; 2 | import { unstable_noStore } from "next/cache"; 3 | import { getSystemLogs } from "@/app/actions"; 4 | import { Card, CardHeader, CardTitle } from "@/components/ui/card"; 5 | import { Alert, AlertDescription } from "@/components/ui/alert"; 6 | import LogViewer from "./LogViewer"; 7 | import DashboardLayout from "@/components/layout/MainLayout"; 8 | import { getVersionInfo } from "@/lib/version"; 9 | 10 | async function LogsContent() { 11 | unstable_noStore(); 12 | const { data: logs, error } = await getSystemLogs(); 13 | 14 | if (error) { 15 | return ( 16 | 17 | {error} 18 | 19 | ); 20 | } 21 | 22 | return ; 23 | } 24 | 25 | export default async function LogsPage() { 26 | const version = await getVersionInfo(); 27 | 28 | return ( 29 | 30 |
31 |
32 |
33 |

System Logs

34 |

35 | Release: {version.current} 36 |

37 |
38 |
39 | 42 |
43 |
44 | } 45 | > 46 | 47 |
48 |
49 |
50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /app/manifest.js: -------------------------------------------------------------------------------- 1 | export default function manifest() { 2 | return { 3 | theme_color: "#000000", 4 | background_color: "#09090b", 5 | icons: { 6 | icon: [ 7 | { 8 | url: "/1024.png", 9 | sizes: "1024x1024", 10 | type: "image/png", 11 | purpose: "any", 12 | }, 13 | ], 14 | apple: [{ url: "/1024.png" }], 15 | }, 16 | orientation: "any", 17 | display: "standalone", 18 | dir: "auto", 19 | lang: "en-US", 20 | name: "ALPR Database", 21 | short_name: "ALPR", 22 | start_url: "/", 23 | scope: "/", 24 | description: "algertc/alpr-database", 25 | }; 26 | } 27 | -------------------------------------------------------------------------------- /app/notifications/page.jsx: -------------------------------------------------------------------------------- 1 | import { getNotificationPlates } from "@/app/actions"; 2 | import { NotificationsTable } from "@/components/NotificationsTable"; 3 | import DashboardLayout from "@/components/layout/MainLayout"; 4 | import BasicTitle from "@/components/layout/BasicTitle"; 5 | 6 | export const dynamic = "force-dynamic"; 7 | 8 | export default async function NotificationsPage() { 9 | const response = await getNotificationPlates(); 10 | const notificationPlates = response.success ? response.data : []; 11 | 12 | return ( 13 | 14 | 20 | 21 | 22 | 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /app/page.jsx: -------------------------------------------------------------------------------- 1 | import { getPlateReads } from "@/lib/db"; 2 | import PlateTable from "@/components/PlateTable"; 3 | import { ThemeToggle } from "@/components/ThemeToggle"; 4 | import DashboardLayout from "@/components/layout/MainLayout"; 5 | import { redirect } from "next/navigation"; 6 | 7 | export default async function Home() { 8 | redirect("/dashboard"); 9 | } 10 | -------------------------------------------------------------------------------- /app/settings/page.jsx: -------------------------------------------------------------------------------- 1 | import SettingsForm from "./SettingsForm"; 2 | import { getSettings } from "@/app/actions"; 3 | import { getAuthConfig } from "@/lib/auth"; 4 | 5 | export const dynamic = "force-dynamic"; 6 | export const revalidate = 0; 7 | 8 | export default async function SettingsPage() { 9 | const [settings, authConfig] = await Promise.all([ 10 | getSettings(), 11 | getAuthConfig(), 12 | ]); 13 | 14 | if (!settings) { 15 | throw new Error("Failed to load settings"); 16 | } 17 | 18 | return ( 19 | 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /app/training/TrainingControl.jsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useState } from "react"; 4 | import { Button } from "@/components/ui/button"; 5 | import { Alert, AlertDescription } from "@/components/ui/alert"; 6 | import { 7 | Card, 8 | CardContent, 9 | CardFooter, 10 | CardHeader, 11 | CardTitle, 12 | } from "@/components/ui/card"; 13 | import { Loader2 } from "lucide-react"; 14 | import { generateTrainingData } from "@/app/actions"; 15 | 16 | export function TrainingControl() { 17 | const [isGenerating, setIsGenerating] = useState(false); 18 | const [status, setStatus] = useState(null); 19 | const [error, setError] = useState(null); 20 | 21 | const startTrainingGeneration = async () => { 22 | try { 23 | setIsGenerating(true); 24 | setError(null); 25 | setStatus("Starting training data generation..."); 26 | 27 | const result = await generateTrainingData(); 28 | 29 | setStatus("Training data successfully generated and uploaded!"); 30 | if (result.ocrCount || result.licensePlateCount) { 31 | setStatus( 32 | (prev) => 33 | prev + 34 | `\n${result.ocrCount || 0} OCR records and ${ 35 | result.licensePlateCount || 0 36 | } license plate records processed.` 37 | ); 38 | } 39 | } catch (err) { 40 | setError(err.message); 41 | setStatus(null); 42 | } finally { 43 | setIsGenerating(false); 44 | } 45 | }; 46 | 47 | return ( 48 | 49 | 50 | Generate Training Data 51 | 52 | 53 |

54 | Generate and upload training data from your validated plate reads. 55 | This process will: 56 |

57 |
    58 |
  • Process validated and unvalidated plate reads
  • 59 |
  • 60 | Generate training datasets for both OCR and license plate detection 61 |
  • 62 |
  • Upload the data securely to the training server
  • 63 |
64 | 65 | {status && ( 66 | 67 | 68 | {status.split("\n").map((line, i) => ( 69 |
{line}
70 | ))} 71 |
72 |
73 | )} 74 | 75 | {error && ( 76 | 77 | {error} 78 | 79 | )} 80 |
81 | 82 | 86 | 87 |
88 | ); 89 | } 90 | -------------------------------------------------------------------------------- /app/training/page.jsx: -------------------------------------------------------------------------------- 1 | import { TrainingControl } from "./TrainingControl"; 2 | 3 | export default function TrainingPage() { 4 | return ( 5 |
6 |
7 |
8 |

Training Data Test

9 |
10 | 11 |
12 |
13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /app/update/BackfillButton.jsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useState } from "react"; 4 | import { Button } from "@/components/ui/button"; 5 | import { Loader2 } from "lucide-react"; 6 | import { dbBackfill } from "../actions"; 7 | 8 | export function BackfillButton({ onComplete }) { 9 | const [response, setResponse] = useState(""); 10 | const [isLoading, setIsLoading] = useState(false); 11 | 12 | const handleBackfill = async () => { 13 | setIsLoading(true); 14 | setResponse(""); 15 | 16 | try { 17 | const result = await dbBackfill(); 18 | const message = result.success 19 | ? "Backfill completed successfully" 20 | : `Backfill failed: ${result.error}`; 21 | setResponse(message); 22 | if (result.success && onComplete) { 23 | onComplete(); 24 | } 25 | } catch (error) { 26 | setResponse(`Error occurred during backfill: ${error.message}`); 27 | } finally { 28 | setIsLoading(false); 29 | } 30 | }; 31 | 32 | return ( 33 |
34 | 44 | {response && ( 45 |
46 |

{response}

47 |
48 | )} 49 |
50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": true, 5 | "tsx": false, 6 | "tailwind": { 7 | "config": "tailwind.config.js", 8 | "css": "app/globals.css", 9 | "baseColor": "zinc", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } -------------------------------------------------------------------------------- /components/FlaggedPlatesTable.jsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useState } from "react"; 4 | import { Badge } from "@/components/ui/badge"; 5 | import { Card, CardContent } from "@/components/ui/card"; 6 | import { 7 | Table, 8 | TableBody, 9 | TableCell, 10 | TableHead, 11 | TableHeader, 12 | TableRow, 13 | } from "@/components/ui/table"; 14 | 15 | export function FlaggedPlatesTable({ initialData }) { 16 | const [data] = useState(initialData); 17 | 18 | return ( 19 | 20 | 21 | 22 | 23 | 24 | Plate Number 25 | Tags 26 | 27 | 28 | 29 | {data.length === 0 ? ( 30 | 31 | 32 | No flagged plates found 33 | 34 | 35 | ) : ( 36 | data.map((plate) => ( 37 | 38 | 39 | {plate.plate_number} 40 | 41 | 42 |
43 | {plate.tags?.length > 0 ? ( 44 | plate.tags.map((tag) => ( 45 | 54 | {tag.name} 55 | 56 | )) 57 | ) : ( 58 |
59 | No tags 60 |
61 | )} 62 |
63 |
64 |
65 | )) 66 | )} 67 |
68 |
69 |
70 |
71 | ); 72 | } 73 | -------------------------------------------------------------------------------- /components/ImageViewer.jsx: -------------------------------------------------------------------------------- 1 | import { useState, useRef, useEffect } from "react"; 2 | import { Slider } from "@/components/ui/slider"; 3 | import { Button } from "@/components/ui/button"; 4 | import { ZoomIn } from "lucide-react"; 5 | import NextImage from "next/image"; 6 | 7 | const ImageViewer = ({ image }) => { 8 | const [zoom, setZoom] = useState(1); 9 | const [imageSize, setImageSize] = useState(null); 10 | const containerRef = useRef(null); 11 | 12 | useEffect(() => { 13 | const img = new Image(); 14 | img.onload = () => { 15 | setImageSize({ width: img.width, height: img.height }); 16 | }; 17 | img.src = image.url; 18 | }, [image.url]); 19 | 20 | const getImageStyle = () => { 21 | if (zoom === 1 || !image?.crop_coordinates || !imageSize) { 22 | return { 23 | transform: "none", 24 | width: "100%", 25 | height: "100%", 26 | }; 27 | } 28 | 29 | const [xMin, yMin, xMax, yMax] = image.crop_coordinates; 30 | 31 | // Calculate true center point of the plate 32 | const centerX = xMin + (xMax - xMin) / 2; 33 | const centerY = yMin + (yMax - yMin) / 2; 34 | 35 | // Calculate percentage positions using actual image dimensions 36 | const originX = (centerX / imageSize.width) * 100; 37 | const originY = (centerY / imageSize.height) * 100; 38 | 39 | return { 40 | transform: `scale(${zoom})`, 41 | transformOrigin: `${originX}% ${originY}%`, 42 | width: "100%", 43 | height: "100%", 44 | transition: "transform 0.2s ease-out", 45 | }; 46 | }; 47 | 48 | return ( 49 |
50 |
54 |
55 | 63 |
64 |
65 | {image?.crop_coordinates && ( 66 |
67 | 70 | {image?.crop_coordinates && ( 71 | 75 | )} 76 |
77 | setZoom(newZoom)} 80 | min={1} 81 | max={5} 82 | step={0.1} 83 | className="w-full" 84 | /> 85 |
86 |
87 | )} 88 |
89 | ); 90 | }; 91 | 92 | export default ImageViewer; 93 | -------------------------------------------------------------------------------- /components/LiveFeedTable.jsx: -------------------------------------------------------------------------------- 1 | // components/LiveFeedTable.jsx 2 | "use server"; 3 | import { Suspense } from "react"; 4 | import PlateTableClient from "./PlateTableClient"; 5 | import { 6 | getCameraNames, 7 | getLatestPlateReads, 8 | getTags, 9 | getTimeFormat, 10 | } from "@/app/actions"; 11 | 12 | export default async function LiveFeedTable(props) { 13 | const searchParams = await props.searchParams; 14 | 15 | const params = { 16 | page: parseInt(searchParams?.page || "1"), 17 | pageSize: parseInt(searchParams?.pageSize || "25"), 18 | search: searchParams?.search || "", 19 | fuzzySearch: searchParams?.fuzzySearch === "true", 20 | tag: searchParams?.tag || "all", 21 | dateRange: 22 | searchParams?.dateFrom && searchParams?.dateTo 23 | ? { from: searchParams.dateFrom, to: searchParams.dateTo } 24 | : null, 25 | hourRange: 26 | searchParams?.hourFrom && searchParams?.hourTo 27 | ? { 28 | from: parseInt(searchParams.hourFrom), 29 | to: parseInt(searchParams.hourTo), 30 | } 31 | : null, 32 | cameraName: searchParams?.camera, 33 | }; 34 | 35 | const [platesRes, tagsRes, camerasRes, timeFormat] = await Promise.all([ 36 | getLatestPlateReads(params), 37 | getTags(), 38 | getCameraNames(), 39 | getTimeFormat(), 40 | ]); 41 | 42 | return ( 43 | Loading...}> 44 | 51 | 52 | ); 53 | } 54 | -------------------------------------------------------------------------------- /components/MetricsHandler.jsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useEffect } from "react"; 4 | import { sendMetricsUpdate } from "@/app/actions"; 5 | 6 | export function MetricsHandler() { 7 | useEffect(() => { 8 | const checkAndSendMetrics = async () => { 9 | // Check localStorage for last send time 10 | const lastSent = localStorage.getItem("metricsLastSent"); 11 | const now = Date.now(); 12 | 13 | if (!lastSent || now - Number(lastSent) > 7 * 24 * 60 * 60 * 1000) { 14 | // More than a week since last send (or never sent) 15 | await sendMetricsUpdate(); 16 | localStorage.setItem("metricsLastSent", now.toString()); 17 | } 18 | }; 19 | 20 | // Run on mount 21 | checkAndSendMetrics(); 22 | }, []); 23 | 24 | return null; 25 | } 26 | -------------------------------------------------------------------------------- /components/PlateImage.jsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | import { useState } from "react"; 3 | 4 | export default function PlateImage({ plate, onClick, className }) { 5 | const [error, setError] = useState(false); 6 | 7 | const getImageUrl = () => { 8 | if (error || (!plate.image_path && !plate.image_data)) { 9 | return "/fallback.jpg"; 10 | } 11 | 12 | // If we have a thumbnail path 13 | if (plate.thumbnail_path) { 14 | // Handle both old paths (that might include 'thumbnails/') and new paths 15 | // const filename = plate.thumbnail_path.replace(/^thumbnails\//, ""); 16 | return `/images/${plate.thumbnail_path}`; 17 | } 18 | 19 | // If we have an image path 20 | if (plate.image_path) { 21 | return `/images/${plate.image_path}`; 22 | } 23 | 24 | // backwards compatibility for old base64 25 | if (plate.image_data) { 26 | if (plate.image_data.startsWith("data:image/jpeg;base64,")) { 27 | return plate.image_data; 28 | } 29 | return `data:image/jpeg;base64,${plate.image_data}`; 30 | } 31 | 32 | return "/fallback.jpg"; 33 | }; 34 | 35 | return ( 36 | {plate.plate_number} { 47 | console.error("Image load error for plate:", plate.plate_number); 48 | setError(true); 49 | }} 50 | /> 51 | ); 52 | } 53 | -------------------------------------------------------------------------------- /components/TestNotificationButton.jsx: -------------------------------------------------------------------------------- 1 | // components/TestNotificationButton.jsx 2 | "use client"; 3 | import { useState } from "react"; 4 | import { Bell } from "lucide-react"; 5 | import { Button } from "@/components/ui/button"; 6 | import { Alert, AlertDescription } from "@/components/ui/alert"; 7 | 8 | export function TestNotificationButton({ plateNumber }) { 9 | const [testStatus, setTestStatus] = useState(null); 10 | 11 | const handleTestNotification = async () => { 12 | try { 13 | setTestStatus({ 14 | type: "loading", 15 | message: "Sending test notification...", 16 | }); 17 | const formData = new FormData(); 18 | formData.append("plateNumber", plateNumber); 19 | formData.append("message", "This is a test notification"); 20 | 21 | const response = await fetch("/api/notifications/test", { 22 | method: "POST", 23 | body: formData, 24 | }); 25 | 26 | const result = await response.json(); 27 | 28 | if (result.success) { 29 | setTestStatus({ 30 | type: "success", 31 | message: "Test notification sent successfully!", 32 | }); 33 | } else { 34 | throw new Error(result.error || "Failed to send test notification"); 35 | } 36 | } catch (error) { 37 | setTestStatus({ type: "error", message: error.message }); 38 | } 39 | 40 | setTimeout(() => setTestStatus(null), 3000); 41 | }; 42 | 43 | return ( 44 | <> 45 | 54 | {testStatus && ( 55 | 64 | {testStatus.message} 65 | 66 | )} 67 | 68 | ); 69 | } 70 | -------------------------------------------------------------------------------- /components/ThemeToggle.jsx: -------------------------------------------------------------------------------- 1 | // components/ThemeToggle.jsx 2 | "use client" 3 | 4 | import { Moon, Sun } from "lucide-react" 5 | import { useTheme } from "next-themes" 6 | 7 | import { Button } from "@/components/ui/button" 8 | 9 | export function ThemeToggle() { 10 | const { theme, setTheme } = useTheme() 11 | 12 | return ( 13 | 22 | ) 23 | } -------------------------------------------------------------------------------- /components/TrainingHandler.jsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useEffect } from "react"; 4 | import { processTrainingData } from "@/app/actions"; 5 | 6 | export function TrainingDataHandler() { 7 | useEffect(() => { 8 | // Simple function to trigger training processing 9 | const triggerTrainingProcess = async () => { 10 | try { 11 | await processTrainingData(); 12 | } catch (error) { 13 | console.error("Error triggering training process:", error); 14 | } 15 | }; 16 | 17 | // Run with a slight delay to avoid blocking page load 18 | const timer = setTimeout(triggerTrainingProcess, 2000); 19 | return () => clearTimeout(timer); 20 | }, []); 21 | 22 | return null; 23 | } 24 | -------------------------------------------------------------------------------- /components/debugTable.jsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React, { useState, useEffect } from 'react'; 4 | import { Search } from 'lucide-react'; 5 | import { Input } from "@/components/ui/input"; 6 | import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; 7 | import { Button } from "@/components/ui/button"; 8 | import { getLatestPlateReads } from '@/app/actions'; 9 | 10 | export default function PlateTable({ initialData }) { 11 | // Initialize with the data directly since it's an array 12 | const [data, setData] = useState(initialData || []); 13 | const [loading, setLoading] = useState(false); 14 | const [search, setSearch] = useState(''); 15 | const [page, setPage] = useState(1); 16 | const pageSize = 25; 17 | 18 | useEffect(() => { 19 | if (page === 1 && !search && initialData) return; 20 | 21 | const loadData = async () => { 22 | setLoading(true); 23 | try { 24 | const result = await getLatestPlateReads({ 25 | page, 26 | pageSize, 27 | search 28 | }); 29 | setData(result || []); 30 | } catch (error) { 31 | console.error('Failed to load data:', error); 32 | setData([]); 33 | } 34 | setLoading(false); 35 | }; 36 | 37 | loadData(); 38 | }, [search, page, initialData]); 39 | 40 | return ( 41 |
42 |
43 | 44 | { 48 | setSearch(e.target.value); 49 | setPage(1); 50 | }} 51 | className="w-64" 52 | /> 53 |
54 | 55 |
56 | 57 | 58 | 59 | Plate Number 60 | Occurrences 61 | Name 62 | Notes 63 | Timestamp 64 | 65 | 66 | 67 | {loading ? ( 68 | 69 | 70 | Loading... 71 | 72 | 73 | ) : data.length === 0 ? ( 74 | 75 | 76 | No results found 77 | 78 | 79 | ) : ( 80 | data.map((plate) => ( 81 | 82 | {plate.plate_number} 83 | {plate.occurrence_count} 84 | {plate.known_name || '-'} 85 | {plate.notes || '-'} 86 | {new Date(plate.timestamp).toLocaleString()} 87 | 88 | )) 89 | )} 90 | 91 |
92 |
93 | 94 |
95 | 102 | 109 |
110 |
111 | ); 112 | } -------------------------------------------------------------------------------- /components/icons/tpms.jsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | import TPMSIcon from "/public/tpms.svg"; 3 | 4 | const TPMS = ({ size = 20 }) => { 5 | return ( 6 |
7 | TPMS Icon 8 |
9 | ); 10 | }; 11 | 12 | export default TPMS; 13 | -------------------------------------------------------------------------------- /components/layout/BasicTitle.jsx: -------------------------------------------------------------------------------- 1 | import { Radio } from "lucide-react"; 2 | 3 | export default function BasicTitle({ 4 | title, 5 | recording, 6 | subtitle = null, 7 | children, 8 | }) { 9 | return ( 10 |
11 |
12 |
13 |
14 |

15 | 16 | {title} 17 | {recording && } 18 | 19 |

20 |
21 |
22 | {subtitle && ( 23 |

{subtitle}

24 | )} 25 |
26 |
27 |
{children}
28 |
29 |
30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /components/layout/LiveFeedNav.jsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React, { useState, useEffect } from "react"; 4 | import { ChevronDown } from "lucide-react"; 5 | import { useRouter, usePathname } from "next/navigation"; 6 | 7 | export default function Component({ 8 | title = "Plate Database", 9 | navigation = [ 10 | { title: "Records Table", href: "/live_feed" }, 11 | { title: "Live Viewer", href: "/live_feed/viewer" }, 12 | ], 13 | children, 14 | }) { 15 | const router = useRouter(); 16 | const pathname = usePathname(); 17 | const [activeIndex, setActiveIndex] = useState(0); 18 | 19 | useEffect(() => { 20 | const index = navigation.findIndex((item) => item.href === pathname); 21 | setActiveIndex(index !== -1 ? index : 0); 22 | }, [pathname, navigation]); 23 | 24 | const handleNavClick = (href, index) => { 25 | setActiveIndex(index); 26 | router.push(href); 27 | }; 28 | 29 | return ( 30 |
31 |
32 |
33 |
34 |

{title}

35 |
36 |
37 | 59 |
60 |
61 |
{children}
62 |
63 |
64 | ); 65 | } 66 | -------------------------------------------------------------------------------- /components/layout/MainLayout.jsx: -------------------------------------------------------------------------------- 1 | import { Sidebar } from "@/components/Sidebar"; 2 | 3 | export default function DashboardLayout({ children }) { 4 | return ( 5 |
6 | 7 |
{children}
8 |
9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /components/layout/TitleNav.jsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React, { useState, useEffect } from "react"; 4 | import { ChevronDown } from "lucide-react"; 5 | import { useRouter, usePathname } from "next/navigation"; 6 | 7 | export default function Component({ 8 | title = "Plate Database", 9 | navigation = [ 10 | { title: "Database", href: "/database" }, 11 | { title: "Tags", href: "/database/tags" }, 12 | { title: "Download", href: "/database/#" }, 13 | ], 14 | children, 15 | }) { 16 | const router = useRouter(); 17 | const pathname = usePathname(); 18 | const [activeIndex, setActiveIndex] = useState(0); 19 | 20 | useEffect(() => { 21 | const index = navigation.findIndex((item) => item.href === pathname); 22 | setActiveIndex(index !== -1 ? index : 0); 23 | }, [pathname, navigation]); 24 | 25 | const handleNavClick = (href, index) => { 26 | setActiveIndex(index); 27 | router.push(href); 28 | }; 29 | 30 | return ( 31 |
32 |
33 |
34 |
35 |

{title}

36 |
37 |
38 | 60 |
61 |
62 |
{children}
63 |
64 |
65 | ); 66 | } 67 | -------------------------------------------------------------------------------- /components/ui/accordion.jsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as AccordionPrimitive from "@radix-ui/react-accordion" 5 | import { ChevronDown } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const Accordion = AccordionPrimitive.Root 10 | 11 | const AccordionItem = React.forwardRef(({ className, ...props }, ref) => ( 12 | 13 | )) 14 | AccordionItem.displayName = "AccordionItem" 15 | 16 | const AccordionTrigger = React.forwardRef(({ className, children, ...props }, ref) => ( 17 | 18 | svg]:rotate-180", 22 | className 23 | )} 24 | {...props}> 25 | {children} 26 | 28 | 29 | 30 | )) 31 | AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName 32 | 33 | const AccordionContent = React.forwardRef(({ className, children, ...props }, ref) => ( 34 | 38 |
{children}
39 |
40 | )) 41 | AccordionContent.displayName = AccordionPrimitive.Content.displayName 42 | 43 | export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } 44 | -------------------------------------------------------------------------------- /components/ui/alert-dialog.jsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" 5 | 6 | import { cn } from "@/lib/utils" 7 | import { buttonVariants } from "@/components/ui/button" 8 | 9 | const AlertDialog = AlertDialogPrimitive.Root 10 | 11 | const AlertDialogTrigger = AlertDialogPrimitive.Trigger 12 | 13 | const AlertDialogPortal = AlertDialogPrimitive.Portal 14 | 15 | const AlertDialogOverlay = React.forwardRef(({ className, ...props }, ref) => ( 16 | 23 | )) 24 | AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName 25 | 26 | const AlertDialogContent = React.forwardRef(({ className, ...props }, ref) => ( 27 | 28 | 29 | 36 | 37 | )) 38 | AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName 39 | 40 | const AlertDialogHeader = ({ 41 | className, 42 | ...props 43 | }) => ( 44 |
47 | ) 48 | AlertDialogHeader.displayName = "AlertDialogHeader" 49 | 50 | const AlertDialogFooter = ({ 51 | className, 52 | ...props 53 | }) => ( 54 |
57 | ) 58 | AlertDialogFooter.displayName = "AlertDialogFooter" 59 | 60 | const AlertDialogTitle = React.forwardRef(({ className, ...props }, ref) => ( 61 | 62 | )) 63 | AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName 64 | 65 | const AlertDialogDescription = React.forwardRef(({ className, ...props }, ref) => ( 66 | 70 | )) 71 | AlertDialogDescription.displayName = 72 | AlertDialogPrimitive.Description.displayName 73 | 74 | const AlertDialogAction = React.forwardRef(({ className, ...props }, ref) => ( 75 | 76 | )) 77 | AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName 78 | 79 | const AlertDialogCancel = React.forwardRef(({ className, ...props }, ref) => ( 80 | 84 | )) 85 | AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName 86 | 87 | export { 88 | AlertDialog, 89 | AlertDialogPortal, 90 | AlertDialogOverlay, 91 | AlertDialogTrigger, 92 | AlertDialogContent, 93 | AlertDialogHeader, 94 | AlertDialogFooter, 95 | AlertDialogTitle, 96 | AlertDialogDescription, 97 | AlertDialogAction, 98 | AlertDialogCancel, 99 | } 100 | -------------------------------------------------------------------------------- /components/ui/alert.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { cva } from "class-variance-authority"; 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const alertVariants = cva( 7 | "relative w-full rounded-lg border border-zinc-200 px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-zinc-950 [&>svg~*]:pl-7 dark:border-zinc-800 dark:[&>svg]:text-zinc-50", 8 | { 9 | variants: { 10 | variant: { 11 | default: "bg-white text-zinc-950 dark:bg-zinc-950 dark:text-zinc-50", 12 | destructive: 13 | "border-red-500/50 text-red-500 dark:border-red-500 [&>svg]:text-red-500 dark:border-red-900/50 dark:text-red-900 dark:dark:border-red-900 dark:[&>svg]:text-red-900", 14 | }, 15 | }, 16 | defaultVariants: { 17 | variant: "default", 18 | }, 19 | } 20 | ) 21 | 22 | const Alert = React.forwardRef(({ className, variant, ...props }, ref) => ( 23 |
28 | )) 29 | Alert.displayName = "Alert" 30 | 31 | const AlertTitle = React.forwardRef(({ className, ...props }, ref) => ( 32 |
36 | )) 37 | AlertTitle.displayName = "AlertTitle" 38 | 39 | const AlertDescription = React.forwardRef(({ className, ...props }, ref) => ( 40 |
44 | )) 45 | AlertDescription.displayName = "AlertDescription" 46 | 47 | export { Alert, AlertTitle, AlertDescription } 48 | -------------------------------------------------------------------------------- /components/ui/aspect-ratio.jsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio" 4 | 5 | const AspectRatio = AspectRatioPrimitive.Root 6 | 7 | export { AspectRatio } 8 | -------------------------------------------------------------------------------- /components/ui/avatar.jsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as AvatarPrimitive from "@radix-ui/react-avatar" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Avatar = React.forwardRef(({ className, ...props }, ref) => ( 9 | 13 | )) 14 | Avatar.displayName = AvatarPrimitive.Root.displayName 15 | 16 | const AvatarImage = React.forwardRef(({ className, ...props }, ref) => ( 17 | 21 | )) 22 | AvatarImage.displayName = AvatarPrimitive.Image.displayName 23 | 24 | const AvatarFallback = React.forwardRef(({ className, ...props }, ref) => ( 25 | 32 | )) 33 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName 34 | 35 | export { Avatar, AvatarImage, AvatarFallback } 36 | -------------------------------------------------------------------------------- /components/ui/badge.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { cva } from "class-variance-authority"; 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const badgeVariants = cva( 7 | "inline-flex items-center rounded-md border border-zinc-200 px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-zinc-950 focus:ring-offset-2 dark:border-zinc-800 dark:focus:ring-zinc-300", 8 | { 9 | variants: { 10 | variant: { 11 | default: 12 | "border-transparent bg-zinc-900 text-zinc-50 shadow hover:bg-zinc-900/80 dark:bg-zinc-50 dark:text-zinc-900 dark:hover:bg-zinc-50/80", 13 | secondary: 14 | "border-transparent bg-zinc-100 text-zinc-900 hover:bg-zinc-100/80 dark:bg-zinc-800 dark:text-zinc-50 dark:hover:bg-zinc-800/80", 15 | destructive: 16 | "border-transparent bg-red-500 text-zinc-50 shadow hover:bg-red-500/80 dark:bg-red-900 dark:text-zinc-50 dark:hover:bg-red-900/80", 17 | outline: "text-zinc-950 dark:text-zinc-50", 18 | }, 19 | }, 20 | defaultVariants: { 21 | variant: "default", 22 | }, 23 | } 24 | ) 25 | 26 | function Badge({ 27 | className, 28 | variant, 29 | ...props 30 | }) { 31 | return (
); 32 | } 33 | 34 | export { Badge, badgeVariants } 35 | -------------------------------------------------------------------------------- /components/ui/breadcrumb.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { ChevronRight, MoreHorizontal } from "lucide-react" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const Breadcrumb = React.forwardRef( 8 | ({ ...props }, ref) =>