Initial commit: Auto-Charger Logging Web-App

MQTT-Collector (Python/paho-mqtt) speichert abgeschlossene go-e
Wallbox-Ladungen in SQLite; Next.js-Web-App zeigt sie als Tabelle mit
Energie-Summen (Monat/Jahr/gesamt) im werte-next-Design. Zwei
Docker-Container mit gemeinsamem DB-Volume.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-03 19:12:04 +02:00
commit 7dd3215520
26 changed files with 8022 additions and 0 deletions
+15
View File
@@ -0,0 +1,15 @@
# MQTT-Broker der go-e Wallbox
MQTT_HOST=nuccy
MQTT_PORT=1883
MQTT_USER=
MQTT_PASS=
MQTT_TOPIC_BASE=go-eCharger/310014
# Gemeinsame SQLite-Datenbank (Volume-Mount)
DB_PATH=/data/charges.db
# Zeitzone (für Kalendermonat/-jahr der Summen)
TZ=Europe/Berlin
# Host-Port, unter dem die Web-App erreichbar ist
WEB_PORT=8082
+16
View File
@@ -0,0 +1,16 @@
# secrets & data
.env
data/
# python
__pycache__/
*.pyc
# node / next
node_modules/
.next/
*.tsbuildinfo
# misc
.DS_Store
*.log
+68
View File
@@ -0,0 +1,68 @@
# Auto-Charger Log
Kleine Web-App, die die Ladevorgänge des Autos protokolliert. Die Daten kommen
per MQTT von der **go-e Wallbox** (Device-ID `310014`), werden in einer
**SQLite**-Datenbank gespeichert und als Tabelle mit Energie-Summen angezeigt.
Läuft als zwei Docker-Container auf einem lokalen Server.
## Architektur
| Container | Stack | Aufgabe |
|-------------|--------------------------------|----------------------------------------------------|
| `collector` | Python + `paho-mqtt` | MQTT abonnieren, abgeschlossene Ladungen → SQLite |
| `web` | Next.js 16 / React 19 / Tailwind 4 | SQLite (read-only) lesen, Tabelle + Summen anzeigen |
Beide teilen sich das Volume `./data` mit der Datei `charges.db`. Der Collector
besitzt alle Schreibzugriffe, die Web-App liest nur.
## MQTT-Daten (go-e API v2)
Basis-Topic `go-eCharger/310014/<key>`:
- `car` — Fahrzeug-Status (`1`=Idle, `2`=Charging, `3`=WaitCar, `4`=Complete).
Steuert die Session-Erkennung: Start bei Wechsel nach `2`, Abschluss bei
Wechsel nach `1` oder `4`.
- `wh` — geladene Energie der aktuellen Ladung in Wh.
- `cdi` — Objekt `{"type":1,"value":<ms>}` mit der Ladedauer in Millisekunden.
## Einrichtung
```bash
cp .env.example .env
# MQTT_USER / MQTT_PASS in .env eintragen
docker compose up --build
```
Web-App danach unter `http://<server>:8082/` (Port via `WEB_PORT` in `.env`).
## Konfiguration (`.env`)
| Variable | Bedeutung | Default |
|-------------------|--------------------------------------------|----------------------|
| `MQTT_HOST` | MQTT-Broker | `nuccy` |
| `MQTT_PORT` | MQTT-Port | `1883` |
| `MQTT_USER/PASS` | MQTT-Credentials | – |
| `MQTT_TOPIC_BASE` | Basis-Topic der Wallbox | `go-eCharger/310014` |
| `DB_PATH` | Pfad der SQLite-DB im Container | `/data/charges.db` |
| `TZ` | Zeitzone (für Monats-/Jahres-Summen) | `Europe/Berlin` |
| `WEB_PORT` | Host-Port der Web-App | `8082` |
## Test ohne Auto
Eine Ladung lässt sich per `mosquitto_pub` simulieren (Reihenfolge beachten):
```bash
mosquitto_pub -h nuccy -t go-eCharger/310014/car -m 2
mosquitto_pub -h nuccy -t go-eCharger/310014/wh -m 7350
mosquitto_pub -h nuccy -t go-eCharger/310014/cdi -m '{"type":1,"value":3600000}'
mosquitto_pub -h nuccy -t go-eCharger/310014/car -m 4
```
Erwartung: eine neue Zeile (Dauer 1:00, Energie 7,35 kWh); die Summen
aktualisieren sich beim Neuladen der Seite.
## Bekannte Einschränkung
Der Collector hält den Session-Zustand nur im Speicher. Startet er mitten in
einer laufenden Ladung, wird genau diese eine Ladung ggf. nicht (vollständig)
erfasst.
+10
View File
@@ -0,0 +1,10 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY schema.sql db.py collector.py ./
CMD ["python", "collector.py"]
+133
View File
@@ -0,0 +1,133 @@
#!/usr/bin/env python3
"""MQTT-Collector: liest go-e Wallbox-Daten und speichert abgeschlossene
Ladungen als je eine Zeile in SQLite.
Session-Erkennung über den 'car'-State:
1=Idle, 2=Charging, 3=WaitCar, 4=Complete.
Eine Session startet beim Wechsel nach 2 (Charging) und wird beim Wechsel
nach 1 (Idle) oder 4 (Complete) abgeschlossen. Dauer kommt aus 'cdi', die
geladene Energie aus 'wh'.
"""
import json
import os
import sys
from datetime import datetime
import paho.mqtt.client as mqtt
from dotenv import load_dotenv
import db
load_dotenv()
MQTT_HOST = os.environ.get("MQTT_HOST", "localhost")
MQTT_PORT = int(os.environ.get("MQTT_PORT", "1883"))
MQTT_USER = os.environ.get("MQTT_USER") or None
MQTT_PASS = os.environ.get("MQTT_PASS") or None
TOPIC_BASE = os.environ.get("MQTT_TOPIC_BASE", "go-eCharger/310014").rstrip("/")
def log(msg: str) -> None:
print(f"[{datetime.now().isoformat(timespec='seconds')}] {msg}", flush=True)
class Session:
"""In-Memory-Zustand der aktuell laufenden Ladung."""
def __init__(self) -> None:
self.active = False
self.start_time = None
self.energy_wh = 0.0 # letzter wh-Wert
self.duration_ms = 0 # letzter cdi.value
def start(self) -> None:
self.active = True
self.start_time = datetime.now().isoformat(timespec="seconds")
self.energy_wh = 0.0
self.duration_ms = 0
log(f"Ladung gestartet um {self.start_time}")
def finish(self) -> None:
if not self.active:
return
end_time = datetime.now().isoformat(timespec="seconds")
duration_s = int(self.duration_ms / 1000)
if self.energy_wh > 0:
db.insert_charge(self.start_time, end_time, duration_s, self.energy_wh)
log(f"Ladung gespeichert: {self.energy_wh:.0f} Wh, {duration_s} s")
else:
log("Ladung ohne Energie (>0 Wh) verworfen")
self.active = False
session = Session()
def on_connect(client, userdata, flags, reason_code, properties=None):
log(f"Verbunden mit MQTT (rc={reason_code})")
for key in ("car", "cdi", "wh"):
topic = f"{TOPIC_BASE}/{key}"
client.subscribe(topic)
log(f"abonniert: {topic}")
def on_disconnect(client, userdata, flags, reason_code=None, properties=None):
log(f"Verbindung getrennt (rc={reason_code})")
def on_message(client, userdata, msg):
payload = msg.payload.decode("utf-8", errors="replace").strip()
key = msg.topic.rsplit("/", 1)[-1]
if key == "wh":
try:
session.energy_wh = float(payload)
except ValueError:
pass
elif key == "cdi":
# {"type":0|1,"value":<ms>} oder null
try:
data = json.loads(payload)
except (ValueError, TypeError):
data = None
if isinstance(data, dict) and data.get("type") == 1:
session.duration_ms = int(data.get("value") or 0)
elif key == "car":
try:
car = int(payload)
except ValueError:
return
if car == 2 and not session.active:
session.start()
elif car in (1, 4) and session.active:
session.finish()
# car == 3 (WaitCar/Pause): Session bleibt aktiv
def main() -> None:
db.init_db()
log(f"DB bereit: {db.get_db_path()}")
client = mqtt.Client(
callback_api_version=mqtt.CallbackAPIVersion.VERSION2,
client_id="auto-charger-collector",
)
if MQTT_USER:
client.username_pw_set(MQTT_USER, MQTT_PASS)
client.on_connect = on_connect
client.on_disconnect = on_disconnect
client.on_message = on_message
log(f"Verbinde zu {MQTT_HOST}:{MQTT_PORT} ...")
client.connect(MQTT_HOST, MQTT_PORT, keepalive=60)
client.loop_forever(retry_first_connection=True)
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
sys.exit(0)
+50
View File
@@ -0,0 +1,50 @@
"""SQLite-Helfer für den Collector (Schema-Init + Schreibzugriffe)."""
import os
import sqlite3
from datetime import datetime
from pathlib import Path
SCHEMA_PATH = Path(__file__).with_name("schema.sql")
def get_db_path() -> str:
return os.environ.get("DB_PATH", "/data/charges.db")
def connect() -> sqlite3.Connection:
path = get_db_path()
Path(path).parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(path)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL;")
return conn
def init_db() -> None:
"""Legt Schema idempotent an und setzt install_date beim ersten Start."""
schema = SCHEMA_PATH.read_text(encoding="utf-8")
conn = connect()
try:
conn.executescript(schema)
conn.execute(
"INSERT OR IGNORE INTO meta (key, value) VALUES ('install_date', ?)",
(datetime.now().isoformat(timespec="seconds"),),
)
conn.commit()
finally:
conn.close()
def insert_charge(start_time: str, end_time: str, duration_s: int, energy_wh: float) -> None:
conn = connect()
try:
conn.execute(
"""INSERT INTO charges (start_time, end_time, duration_s, energy_wh, created_at)
VALUES (?, ?, ?, ?, ?)""",
(start_time, end_time, duration_s, energy_wh,
datetime.now().isoformat(timespec="seconds")),
)
conn.commit()
finally:
conn.close()
+2
View File
@@ -0,0 +1,2 @@
paho-mqtt==2.1.0
python-dotenv==1.0.1
+17
View File
@@ -0,0 +1,17 @@
PRAGMA journal_mode=WAL;
CREATE TABLE IF NOT EXISTS charges (
id INTEGER PRIMARY KEY AUTOINCREMENT,
start_time TEXT NOT NULL, -- ISO 8601, lokale Zeit (Europe/Berlin)
end_time TEXT NOT NULL,
duration_s INTEGER NOT NULL, -- aus cdi.value (ms/1000)
energy_wh REAL NOT NULL, -- aus wh
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_charges_start ON charges(start_time);
CREATE TABLE IF NOT EXISTS meta (
key TEXT PRIMARY KEY,
value TEXT
);
+28
View File
@@ -0,0 +1,28 @@
# Auto-Charger Log — lokaler Server (ohne Traefik, nur Port-Mapping)
services:
collector:
build: ./collector
container_name: auto-charger-collector
restart: unless-stopped
env_file: .env
volumes:
- ./data:/data
web:
build:
context: ./web
args:
BUILD_DATE: ${BUILD_DATE:-local}
container_name: auto-charger-web
restart: unless-stopped
env_file: .env
environment:
- NODE_ENV=production
- DB_PATH=/data/charges.db
ports:
- "${WEB_PORT:-8082}:3000"
volumes:
- ./data:/data
depends_on:
- collector
+33
View File
@@ -0,0 +1,33 @@
# dependencies
node_modules
npm-debug.log*
# next.js
.next/
out/
build
dist
# misc
.DS_Store
*.pem
*.log
# local env files
.env
.env.local
# typescript
*.tsbuildinfo
# git
.git
.gitignore
# editor
.vscode
.idea
# docker
Dockerfile
.dockerignore
+43
View File
@@ -0,0 +1,43 @@
# Multi-stage build für die Next.js-App (standalone output)
FROM node:22-alpine AS base
# Dependencies (better-sqlite3 ist ein natives Modul -> Build-Tools nötig)
FROM base AS deps
RUN apk add --no-cache libc6-compat python3 make g++
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install
# Build
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ARG BUILD_DATE
ENV NEXT_PUBLIC_BUILD_DATE=${BUILD_DATE}
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
# Runner
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs \
&& adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]
+22
View File
@@ -0,0 +1,22 @@
/* stylelint-disable at-rule-no-unknown */
@import "tailwindcss";
@source "../components/**/*.tsx";
@source "../app/**/*.tsx";
:root {
--background: #ffffff;
--foreground: #171717;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}
+32
View File
@@ -0,0 +1,32 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Auto-Charger Log",
description: "Protokoll der Auto-Ladevorgänge (go-e Wallbox)",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="de">
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
{children}
</body>
</html>
);
}
+20
View File
@@ -0,0 +1,20 @@
import AppLayout from "@/components/AppLayout";
import ChargesTable from "@/components/ChargesTable";
import Totals from "@/components/Totals";
import { getCharges, getTotals } from "@/lib/db";
// Immer frisch aus der DB lesen (kein Caching).
export const dynamic = "force-dynamic";
export default function Home() {
const charges = getCharges();
const totals = getTotals();
return (
<AppLayout>
<h2 className="text-xl font-semibold mb-4">Ladungen</h2>
<ChargesTable charges={charges} />
<Totals totals={totals} />
</AppLayout>
);
}
+37
View File
@@ -0,0 +1,37 @@
import packageJson from "@/package.json";
export default function AppLayout({ children }: { children: React.ReactNode }) {
const version = packageJson.version;
const buildDate =
process.env.NEXT_PUBLIC_BUILD_DATE ||
new Date().toLocaleDateString("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
return (
<div className="min-h-screen py-8 px-4">
<div className="max-w-316 mx-auto border-2 border-black rounded-xl bg-gray-200 p-6">
<h1 className="text-4xl font-bold text-center mb-6 tracking-tight">
Auto-Charger Log
</h1>
<div className="max-w-6xl mx-auto">
<main className="border-2 border-black rounded-lg p-6 bg-[#FFFFDD]">
{children}
</main>
<footer className="mt-8 flex justify-between items-center text-sm text-gray-600 px-4">
<a href="mailto:rxf@gmx.de" className="hover:underline">
mailto:rxf@gmx.de
</a>
<div>
Version {version} - {buildDate}
</div>
</footer>
</div>
</div>
</div>
);
}
+86
View File
@@ -0,0 +1,86 @@
import { Charge } from "@/lib/db";
const WEEKDAYS = ["So", "Mo", "Di", "Mi", "Do", "Fr", "Sa"];
function parts(iso: string) {
// erwartet "YYYY-MM-DDTHH:MM:SS" (lokale Zeit vom Collector)
const [date = "", time = ""] = iso.split("T");
const [y, m, d] = date.split("-");
return { y, m, d, hm: time.slice(0, 5) };
}
function formatDate(iso: string): string {
const { y, m, d } = parts(iso);
return y && m && d ? `${d}.${m}.${y}` : iso;
}
function weekday(iso: string): string {
const { y, m, d } = parts(iso);
if (!y || !m || !d) return "-";
return WEEKDAYS[new Date(Number(y), Number(m) - 1, Number(d)).getDay()];
}
function formatTime(iso: string): string {
return parts(iso).hm || "-";
}
function formatDuration(seconds: number): string {
const h = Math.floor(seconds / 3600);
const min = Math.floor((seconds % 3600) / 60);
return `${h}:${String(min).padStart(2, "0")}`;
}
export default function ChargesTable({ charges }: { charges: Charge[] }) {
if (charges.length === 0) {
return (
<div className="text-center py-8 text-gray-500">
Noch keine Ladungen erfasst
</div>
);
}
return (
<div className="overflow-x-auto">
<table className="w-full border-collapse">
<thead>
<tr className="bg-[#CCCCFF] border-b-2 border-gray-400">
<th className="p-2 text-center">Datum</th>
<th className="p-2 text-center">Tag</th>
<th className="p-2 text-center">Start</th>
<th className="p-2 text-center">Ende</th>
<th className="p-2 text-center">
Dauer<br />
<span className="text-xs font-normal">h:mm</span>
</th>
<th className="p-2 text-center">
Energie<br />
<span className="text-xs font-normal">kWh</span>
</th>
</tr>
</thead>
<tbody>
{charges.map((c, index) => (
<tr
key={c.id}
className={`border-b border-gray-300 hover:bg-gray-50 ${
index % 2 === 0 ? "bg-white" : "bg-gray-50"
}`}
>
<td className="p-2 text-center">{formatDate(c.start_time)}</td>
<td className="p-2 text-center">{weekday(c.start_time)}</td>
<td className="p-2 text-center">{formatTime(c.start_time)}</td>
<td className="p-2 text-center">{formatTime(c.end_time)}</td>
<td className="p-2 text-center">{formatDuration(c.duration_s)}</td>
<td className="p-2 text-center">
{(c.energy_wh / 1000).toLocaleString("de-DE", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
+42
View File
@@ -0,0 +1,42 @@
import { Totals as TotalsData } from "@/lib/db";
function kwh(wh: number): string {
return (wh / 1000).toLocaleString("de-DE", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
}
function formatInstall(iso: string | null): string {
if (!iso) return "";
const [date = ""] = iso.split("T");
const [y, m, d] = date.split("-");
return y && m && d ? ` (seit ${d}.${m}.${y})` : "";
}
function Card({ label, value }: { label: string; value: string }) {
return (
<div className="flex-1 min-w-[10rem] border border-black rounded-lg bg-white p-4 text-center">
<div className="text-sm text-gray-600">{label}</div>
<div className="text-2xl font-bold">
{value} <span className="text-base font-normal">kWh</span>
</div>
</div>
);
}
export default function Totals({ totals }: { totals: TotalsData }) {
return (
<div className="mt-6">
<h2 className="text-lg font-semibold mb-3">Geladene Energie</h2>
<div className="flex flex-wrap gap-4">
<Card label="Aktueller Monat" value={kwh(totals.month_wh)} />
<Card label="Aktuelles Jahr" value={kwh(totals.year_wh)} />
<Card
label={`Gesamt${formatInstall(totals.install_date)}`}
value={kwh(totals.total_wh)}
/>
</div>
</div>
);
}
+16
View File
@@ -0,0 +1,16 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
globalIgnores([
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;
+86
View File
@@ -0,0 +1,86 @@
import Database from "better-sqlite3";
const DB_PATH = process.env.DB_PATH || "/data/charges.db";
export interface Charge {
id: number;
start_time: string;
end_time: string;
duration_s: number;
energy_wh: number;
}
export interface Totals {
month_wh: number;
year_wh: number;
total_wh: number;
install_date: string | null;
}
let db: Database.Database | null = null;
/**
* Öffnet die DB read-only. Gibt null zurück, falls die Datei noch nicht
* existiert (Collector legt sie beim ersten Start an) oder noch kein Schema
* vorhanden ist — die Seite bleibt dann leer statt zu crashen.
*/
function getDb(): Database.Database | null {
if (db) return db;
try {
const conn = new Database(DB_PATH, { readonly: true, fileMustExist: true });
const hasTable = conn
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='charges'")
.get();
if (!hasTable) {
conn.close();
return null;
}
db = conn;
return db;
} catch {
return null;
}
}
export function getCharges(): Charge[] {
const conn = getDb();
if (!conn) return [];
return conn
.prepare(
`SELECT id, start_time, end_time, duration_s, energy_wh
FROM charges ORDER BY start_time DESC`,
)
.all() as Charge[];
}
export function getTotals(): Totals {
const conn = getDb();
if (!conn) {
return { month_wh: 0, year_wh: 0, total_wh: 0, install_date: null };
}
const month = conn
.prepare(
`SELECT COALESCE(SUM(energy_wh), 0) AS s FROM charges
WHERE strftime('%Y-%m', start_time) = strftime('%Y-%m', 'now', 'localtime')`,
)
.get() as { s: number };
const year = conn
.prepare(
`SELECT COALESCE(SUM(energy_wh), 0) AS s FROM charges
WHERE strftime('%Y', start_time) = strftime('%Y', 'now', 'localtime')`,
)
.get() as { s: number };
const total = conn
.prepare(`SELECT COALESCE(SUM(energy_wh), 0) AS s FROM charges`)
.get() as { s: number };
const install = conn
.prepare(`SELECT value FROM meta WHERE key = 'install_date'`)
.get() as { value: string } | undefined;
return {
month_wh: month.s,
year_wh: year.s,
total_wh: total.s,
install_date: install?.value ?? null,
};
}
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+26
View File
@@ -0,0 +1,26 @@
import type { NextConfig } from "next";
import path from "path";
const nextConfig: NextConfig = {
output: "standalone",
// Natives Modul nicht ins Bundle ziehen, sondern zur Laufzeit laden.
serverExternalPackages: ["better-sqlite3"],
turbopack: {
root: path.resolve(__dirname),
},
async headers() {
return [
{
source: "/(.*)",
headers: [
{ key: "X-Frame-Options", value: "DENY" },
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
{ key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" },
],
},
];
},
};
export default nextConfig;
+7158
View File
File diff suppressed because it is too large Load Diff
+28
View File
@@ -0,0 +1,28 @@
{
"name": "auto-charger-web",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"better-sqlite3": "^11.8.1",
"next": "16.1.6",
"react": "19.2.3",
"react-dom": "19.2.3"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/better-sqlite3": "^7.6.12",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.1.6",
"tailwindcss": "^4",
"typescript": "^5"
}
}
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
View File
+41
View File
@@ -0,0 +1,41 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./*"
]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}