From c2a629e2c036e154e789904046882f248b337c91 Mon Sep 17 00:00:00 2001 From: Thomas Hackner Date: Fri, 19 Jun 2026 04:01:37 +0200 Subject: [PATCH] Refactor UI/views, rework Docker build, untrack local data - Views umstrukturiert: einstellungen.ejs -> bewerbung.ejs, neues partials/head.ejs, header/footer/index angepasst - CSS umbenannt: style.css -> styles.css - server.js und public/js/main.js ueberarbeitet - Dockerfile auf schlankes Multi-Stage-Setup umgestellt; docker-compose.yml und .dockerignore entfernt - npm-Scripts docker:build/push/deploy ergaenzt - SQLite-DB und .idea aus Git entfernt und via .gitignore ignoriert Co-Authored-By: Claude Opus 4.8 --- .dockerignore | 20 - .gitignore | 36 + .idea/.gitignore | 10 - .idea/jobbi-bewerbung.iml | 8 - .idea/material_theme_project_new.xml | 10 - .idea/modules.xml | 8 - Dockerfile | 38 +- README.md | 212 ++-- data/.gitkeep | 0 data/bewerbungen.db | Bin 4096 -> 0 bytes data/bewerbungen.db-shm | Bin 32768 -> 0 bytes data/bewerbungen.db-wal | Bin 61832 -> 0 bytes docker-compose.yml | 13 - package-lock.json | 1329 +++++++++++++++++++++++++- package.json | 28 +- public/css/style.css | 822 ---------------- public/css/styles.css | 231 +++++ public/js/main.js | 861 ++++++++++++----- server.js | 681 +++++++++---- views/bewerbung.ejs | 218 +++++ views/einstellungen.ejs | 62 -- views/index.ejs | 879 +++++++++++------ views/partials/footer.ejs | 17 +- views/partials/head.ejs | 7 + views/partials/header.ejs | 89 +- 25 files changed, 3693 insertions(+), 1886 deletions(-) delete mode 100644 .dockerignore delete mode 100644 .idea/.gitignore delete mode 100644 .idea/jobbi-bewerbung.iml delete mode 100644 .idea/material_theme_project_new.xml delete mode 100644 .idea/modules.xml delete mode 100644 data/.gitkeep delete mode 100644 data/bewerbungen.db delete mode 100644 data/bewerbungen.db-shm delete mode 100644 data/bewerbungen.db-wal delete mode 100644 docker-compose.yml delete mode 100644 public/css/style.css create mode 100644 public/css/styles.css create mode 100644 views/bewerbung.ejs delete mode 100644 views/einstellungen.ejs create mode 100644 views/partials/head.ejs diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index fc9d7e0..0000000 --- a/.dockerignore +++ /dev/null @@ -1,20 +0,0 @@ -.git -.gitignore -.github -.env -.env.* -!.env.example -*.pem -*.key -README.md -LICENSE -docs -.idea -.vscode -*.swp -.DS_Store -node_modules -data -Dockerfile -.dockerignore -docker-compose*.yml diff --git a/.gitignore b/.gitignore index 2e8157a..aaa15f2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,39 @@ +# Dependencies node_modules/ + +# Database +/data/ +*.db +*.sqlite +*.sqlite3 + +# Environment .env +.env.local +.env.*.local + +# Logs +logs/ *.log +npm-debug.log* + +# Build output +dist/ +build/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Test coverage +coverage/ + +# Temporary files +tmp/ +temp/ diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index 30cf57e..0000000 --- a/.idea/.gitignore +++ /dev/null @@ -1,10 +0,0 @@ -# Default ignored files -/shelf/ -/workspace.xml -# Editor-based HTTP Client requests -/httpRequests/ -# Ignored default folder with query files -/queries/ -# Datasource local storage ignored files -/dataSources/ -/dataSources.local.xml diff --git a/.idea/jobbi-bewerbung.iml b/.idea/jobbi-bewerbung.iml deleted file mode 100644 index c956989..0000000 --- a/.idea/jobbi-bewerbung.iml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/material_theme_project_new.xml b/.idea/material_theme_project_new.xml deleted file mode 100644 index 704a6b0..0000000 --- a/.idea/material_theme_project_new.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml deleted file mode 100644 index f29b82b..0000000 --- a/.idea/modules.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index d6284c3..e010d8e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,35 +1,45 @@ -# ── Stage 1: Dependencies (mit Compiler für better-sqlite3) ────────────────── -FROM node:20.19.2-slim AS deps +# syntax=docker/dockerfile:1 + +# ----- Build stage: install (and compile native sqlite3) deps ----- +FROM node:20-bookworm-slim AS builder WORKDIR /app -RUN apt-get update && apt-get install -y --no-install-recommends \ - python3 make g++ \ +# Build toolchain required to compile the native sqlite3 binding +RUN apt-get update \ + && apt-get install -y --no-install-recommends python3 make g++ \ && rm -rf /var/lib/apt/lists/* +# Install production dependencies deterministically (cached unless lockfile changes) COPY package.json package-lock.json ./ RUN npm ci --omit=dev -# ── Stage 2: Runtime ────────────────────────────────────────────────────────── -FROM node:20.19.2-slim AS runtime +# ----- Runtime stage: slim image without build tooling ----- +FROM node:20-bookworm-slim AS runtime + +ENV NODE_ENV=production \ + PORT=3000 WORKDIR /app -RUN groupadd --system --gid 1001 nodejs \ - && useradd --system --uid 1001 --gid nodejs --no-create-home appuser +# Bring in the already-installed (and compiled) dependencies +COPY --from=builder /app/node_modules ./node_modules -COPY --from=deps /app/node_modules ./node_modules -COPY . . - -RUN mkdir -p data && chown -R appuser:nodejs data +# Application source +COPY package.json ./ +COPY server.js ./ +COPY views ./views +COPY public ./public +# Persisted SQLite data lives here; make it writable for the non-root user +RUN mkdir -p /app/data && chown -R node:node /app VOLUME ["/app/data"] -USER appuser +USER node EXPOSE 3000 HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ - CMD node -e "require('http').get('http://localhost:3000/',r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))" + CMD node -e "fetch('http://localhost:'+(process.env.PORT||3000)+'/').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" CMD ["node", "server.js"] diff --git a/README.md b/README.md index a53359e..0d9065b 100644 --- a/README.md +++ b/README.md @@ -1,110 +1,194 @@ # Bewerbungs-Tracker -Lokale Web-Anwendung zur Verwaltung von Stellenbewerbungen – optimiert für die monatliche Nachweispflicht beim Jobcenter (Grundsicherung / Bürgergeld). +Ein professioneller Job Application Tracker für Jobcenter Grundsicherung Monatsberichte. -## Funktionen +## Features -- **Bewerbungen verwalten** – Anlegen, Bearbeiten, Löschen -- **Monatsansicht** – Filter nach Monat und Jahr -- **Statistiken** – Gesamt, Positiv, Absagen, Ausstehend -- **PDF-Export** – Professionelles Dokument mit Ihren Daten für das Jobcenter -- **Dunkelmodus** – Standard oder per Schalter umschaltbar -- **Datenschutz** – Alle Daten bleiben lokal auf Ihrem Rechner (SQLite) +- **Benutzerprofile**: Speichern Sie Name, Adresse und Jobcenter Kundennummer +- **Dunkler Modus**: Vollständige Dark Mode Unterstützung mit lokaler Speicherung +- **CRUD-Operationen**: Komplette Verwaltung von Bewerbungen (Hinzufügen, Bearbeiten, Löschen) +- **Filterfunktion**: Filterung nach Monat und Jahr +- **Statistiken**: Übersicht über Gesamtbewerbungen, nach Art und Status +- **PDF-Export**: Professionelle PDF-Generierung für Jobcenter-Berichte +- **Responsive Design**: Optimiert für Desktop und Mobile Geräte + +## Technologien + +- **Backend**: Node.js + Express.js +- **Datenbank**: SQLite +- **Frontend**: EJS Template Engine, Tailwind CSS (CDN) +- **PDF-Generierung**: jsPDF + jspdf-autotable (CDN) ## Installation ### Voraussetzungen -- Node.js ≥ 18 (https://nodejs.org) -- npm (im Lieferumfang von Node.js) -- Build-Tools (für `better-sqlite3`): - - **Linux/Mac:** `build-essential` / Xcode Command Line Tools - - **Windows:** `windows-build-tools` oder Visual Studio Build Tools +- Node.js (Version 14 oder höher) +- npm oder yarn ### Schritte +1. **Projekt klonen** ```bash -# 1. In das Projektverzeichnis wechseln cd bewerbungs-tracker +``` -# 2. Abhängigkeiten installieren +2. **Abhängigkeiten installieren** +```bash npm install +``` -# 3. Server starten +3. **Datenbank-Verzeichnis erstellen** +```bash +mkdir -p data +``` + +Die SQLite-Datenbank wird automatisch beim ersten Start erstellt. + +4. **Server starten** +```bash npm start ``` -Die Anwendung ist dann unter **http://localhost:3000** erreichbar. - -### Entwicklungsmodus (Auto-Reload) - +Für Entwicklung mit automatischem Neuladen: ```bash npm run dev ``` +5. **Anwendung öffnen** + +Öffnen Sie Ihren Browser und navigieren Sie zu: +``` +http://localhost:3000 +``` + ## Projektstruktur ``` bewerbungs-tracker/ -├── server.js # Express-Server mit allen Routen -├── package.json +├── server.js # Express Server mit API-Routen +├── package.json # Projektabhängigkeiten und Skripte ├── views/ -│ ├── index.ejs # Übersichtsseite -│ ├── einstellungen.ejs # Einstellungsseite +│ ├── index.ejs # Hauptseite │ └── partials/ -│ ├── header.ejs # HTML-Head + Navigation -│ └── footer.ejs # Abschlusselemente + Scripts +│ ├── header.ejs # Kopfzeile mit Dark Mode Toggle +│ └── footer.ejs # Fußzeile ├── public/ -│ ├── css/style.css # Tailwind-Utility-Klassen -│ └── js/main.js # Dark Mode, Modals, PDF-Generierung -└── data/ - └── bewerbungen.db # SQLite-Datenbank (wird automatisch erstellt) +│ ├── css/ +│ │ └── styles.css # Benutzerdefinierte Styles +│ └── js/ +│ └── main.js # Client-seitige Logik +├── data/ +│ └── bewerbungen.db # SQLite Datenbank (wird automatisch erstellt) +└── README.md # Dokumentation ``` ## Datenbank-Schema -```sql --- Bewerbungen -CREATE TABLE bewerbungen ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - datum DATE NOT NULL, - firma TEXT NOT NULL, - stelle TEXT NOT NULL, - art TEXT, - status TEXT, - notizen TEXT, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP -); +### Bewerbungen --- Benutzerprofil (wird im PDF verwendet) -CREATE TABLE settings ( - id INTEGER PRIMARY KEY CHECK (id = 1), - name TEXT, - adresse TEXT, - kundennummer TEXT -); -``` +| Feld | Typ | Beschreibung | +|------|-----|--------------| +| id | INTEGER PRIMARY KEY | Eindeutige ID | +| datum | DATE | Bewerbungsdatum | +| firma | TEXT | Firmenname | +| stelle | TEXT | Stellenbezeichnung | +| art | TEXT | Art der Bewerbung | +| status | TEXT | Status der Bewerbung | +| notizen | TEXT | Zusätzliche Notizen | +| created_at | DATETIME | Erstellungsdatum | +| updated_at | DATETIME | Letztes Update | + +### Einstellungen + +| Feld | Typ | Beschreibung | +|------|-----|--------------| +| id | INTEGER | Immer 1 (Single Row) | +| name | TEXT | Benutzername | +| adresse | TEXT | Benutzeradresse | +| kundennummer | TEXT | Jobcenter Kundennummer | + +## Verwendbare Optionen + +### Art der Bewerbung +- E-Mail +- Online-Portal +- Indeed +- StepStone +- Firmenwebsite +- Post +- Initiativbewerbung +- Arbeitsagentur +- Sonstiges + +### Status +- Gesendet +- Eingangsbestätigung +- Vorstellungsgespräch +- Absage +- Einstellung +- Keine Rückmeldung + +## API-Endpunkte + +### GET / +Hauptseite mit allen Bewerbungen + +### GET /api/settings +Benutzereinstellungen abrufen + +### POST /api/settings +Benutzereinstellungen speichern + +### GET /api/bewerbungen +Alle Bewerbungen abrufen (mit Filter: ?month=MM&year=YYYY) + +### POST /api/bewerbungen +Neue Bewerbung erstellen + +### PUT /api/bewerbungen/:id +Bewerbung aktualisieren + +### DELETE /api/bewerbungen/:id +Bewerbung löschen + +### GET /api/bewerbungen/filter +Bewerbungen mit Filter abrufen ## PDF-Export -1. Öffnen Sie **Einstellungen** und tragen Sie Ihren Namen, Adresse und Kundennummer ein. -2. Filtern Sie auf der Übersicht den gewünschten Monat. -3. Klicken Sie auf **„PDF exportieren"**. +Der PDF-Export generiert ein professionelles Dokument mit: +- Benutzerdaten (Name, Adresse, Kundennummer) +- Überschrift mit Monat und Jahr +- Zusammenfassung der Bewerbungsaktivitäten +- Tabelle mit allen Bewerbungen +- Bestätigungstext und Datum -Das PDF enthält: -- Briefkopf mit Ihren persönlichen Daten -- Titel „Bewerbungsaktivitäten – Monat Jahr" -- Zusammenfassungssatz -- Tabelle aller Bewerbungen des Monats -- Unterschriftszeile mit Datum +## Browser-Unterstützung -## Port ändern +- Chrome (empfohlen) +- Firefox +- Safari +- Edge -```bash -PORT=8080 npm start -``` +## Dark Mode + +Der Dark Mode kann manuell über den Toggle-Button in der Kopfzeile aktiviert werden. Die Einstellung wird in localStorage gespeichert und bleibt beim nächsten Besuch erhalten. + +## Sicherheit + +- Eingabefelder werden gegen XSS geschützt +- SQL-Injection wird durch parametrisierte Abfragen verhindert +- Formulare validieren Pflichtfelder ## Lizenz MIT + +## Autor + +Bewerbungs-Tracker für Jobcenter Grundsicherung + +--- + +**Hinweis**: Diese Anwendung ist speziell für die Anforderungen des deutschen Jobcenters (Grundsicherung) entwickelt worden. Sie hilft bei der Dokumentation von Bewerbungsaktivitäten für die monatlichen Berichte. diff --git a/data/.gitkeep b/data/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/data/bewerbungen.db b/data/bewerbungen.db deleted file mode 100644 index 73a069272b27c57da406e6748d103073062bafc4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4096 zcmWFz^vNtqRY=P(%1ta$FlG>7U}9o$P*7lCU|@t|AVoG{WYC*>k{6_1fNV2HHI9bB nXb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeDP#6LLBNzv7 diff --git a/data/bewerbungen.db-shm b/data/bewerbungen.db-shm deleted file mode 100644 index 642bf8faa0ac8f5009111f0d21ee2be9660db085..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 32768 zcmeI)J!%3$6ae6-#=mIP(%L?SjR(jjgd8Mb?-|lsS=)M#6d_Ft3&GMlh|UBTmUO{t z@q6&t+1Z)5Z@vS}c=sGd)FMX1^zxAM_51MId;3|=SDV@XWiefUy&os1?Z^0^>xKr>qLbYDkyS^@+J5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pkl@quC`v@!W diff --git a/data/bewerbungen.db-wal b/data/bewerbungen.db-wal deleted file mode 100644 index 4fd213fe6c87abae95d2720300cf442b1ebcf352..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 61832 zcmeI*Uu@gP9S3kpj^o6CyRNP04Mr|-0mpSzOIB>x&a#fSX-A3ec$Shh3A%ui&N40P z=#VFSHju8#x<2e51{B?Z4MmHg*suZZFkpSyQ1m4a%MkQoLxDZ4?SMTD=pNRzSWzs) zhW$>o6w=kuI5m*Q{t~wSoJbz;!{bpRe@6~|efQ;1^sk=_h3*Nd;q>#r9Q@npYP{(O zKUw_ehp&D>NnwBbTi;o0UhFPDkowA$DLI?BouVOGf~Jl{LSJT##*NT_otkESP}A?K zyrJjv{Cw|gy^Mq&e)jh*^hu=k;ZW<7^nd{Z5P$##AOHafKmY;|fB*#U0)g4c?)Ki^ z@EK`jEbd;g=%?d(mtQDxk=^*VZfs81(mG3PqfQLGA~is(p=+0rQRGf>u^Kz+UO`twpzh0!kbqC-3m3{TCYrlQaJ1+2C zsO`D!R&bOD0uX=z1Rwwb2tWV=5P$##AaIum7~#gy{;vHUo%bga{jvVRXpH{i(nfssUfBsNeZi6qo@2dn)E{}Q|00Izz00bZa0SG_<0uX?}#|pHD>$>*(?(|3#1mXo| z7hk!d{o(KpbzC6Q_7KGj&;tetKmY;|fB*y_009U<00Izzz>XKV5TTRz`};aNdweJF z6WaVd7v*#JqlPd97u*!m;bvyzDf}tUrfpGRInHRB4QK8XBa~1H2=LDDtE4*+FVOb* z_4i+!Jok4mUf|tO+q*k{#RC8W5P$##AOHafKmY;|fB*y_uw4Y&C|n@y3l~ta0)cpe zW7-dYboJV6zf;Er>RPXr;|1sm0|X!d0SG_<0uX=z1Rwwb2teS|F7S9AozUOc-rd>a zKcTO>+LzDgYq_E+P6*2sJUVMT(x~>v51y$%bZ8_oR3m9;#0%J=fp7hJ=1V_Dyuhcu zx?}ku009U<00Izz00bZa0SG_<0-F}t7%x!1<{-_bdfmaX5B}48>}M-)ddCG`54F9% z={zt21Rwwb2tWV=5P$##AOHafKmY=FLLg4}9_&fBq!;X>;j*Jfwjj7O!L6Jt9plRL zoDB`OG#(ll9(;sNT1GCzvjtkrKtF=!&%f}{OJ9BBLv>uBz5e^_{Rmpmw*0Xr)BJw( z)6MrbJ=64s##b7(y>IM2yXTEPX2UxTPD5n(H|qaOqho*o1Rwx`Ef%oa)zxhh-JPME z!EpVFQJ!6p7G1GsGMSF1R_Q)96cZk zMINB+dXIDu__K>8XC~P=FG)AMU>PDeZD&l%e4H-g1m)?b$QvT> zW+-m&%8Sy>mqflsjx~}l-cQTWGt?9JEkk@*bC#)a%qy%r!{yovcM66I1~k=fjtZ_h z8E#6~$a5h}&T+cp4`*{%UdzXJi-r&^rJv>*%47YO zKrFqw#8X+vT#~`N1Jx*7jil0NDZi-J(d94h&^k9~boru!x1Kd&ShT9yxS2O8>d|yf z?og?cbR#03BW|h%mUYX;eBxfpc1R0UtoUG3%#)feSej~>uq``pa#t^AcrM3tEM?Ej z6~p077kL_uzGK{eRWmNSxY>IO`GlwHL-CHYcH+Jb8t zwVrEm)=0WkcaK`7k9H6F^Gamx9M95fbj&OPr?@a}hq$u^y0ugEW|o06p0b?^vzzDc zl5^$7>_Uw!Yb1TGO)W%kVAG2C7u`H_%Nwvu3vtX?Hhfuc(FRp$rJVP7#{)Gwu3468 zh&9i8%oc9Rl7^5;VM^1G=5n>y>E@acr$rSr-VxY(W0h9QEr;-qS#~K`twc;ao$@O2 z?QXKUX3Dd(Z(pE*v2iV}pJIG+c2zFe!pB;zM4Kzwa+6|=25M1k_^hFtL{(+yxRI5< zY__W6Tx_I#`}zVECpI)ynwJZPaE(fnNL$RTV=wZ8q(+Lijjd{qoxPNO$C(cQh8i0j zojy(Vb;8Ilm}1^`id16?w5@8i-w~>CAe7`16(@OR%@$QQ$ImFAa+xJ-NS*( z8cUqAt%AFvc3oa$@9pOTySLk{wwLPbDZ|9!VEyT@?qJf~;sv`%*?zR`W2!+4*xvqb z-QJ~~hlA@ho-#RoSy63YW#>|X)dV2#+;O15=`VmxnMtp+%6u$HJ*{A-!+=PAv jTl~cyvx5KxAOHafKmY;|fB*y_009Wxd4Y}n2-^P#=6.9.0" + } + }, + "node_modules/@gar/promisify": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", + "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==", + "license": "MIT", + "optional": true + }, + "node_modules/@npmcli/fs": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-1.1.1.tgz", + "integrity": "sha512-8KG5RD0GVP4ydEzRn/I4BNDuxDtqVbOdm8675T49OIG/NGhaK0pjPX7ZcDlvKYbA+ulvVK3ztfcF4uBdOxuJbQ==", + "license": "ISC", + "optional": true, + "dependencies": { + "@gar/promisify": "^1.0.1", + "semver": "^7.3.5" + } + }, + "node_modules/@npmcli/move-file": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@npmcli/move-file/-/move-file-1.1.2.tgz", + "integrity": "sha512-1SUf/Cg2GzGDyaf15aR9St9TWlb+XvbZXWpDx8YKs7MLzMH/BCeopv+y9vzrzgkfykCGuWOlSu3mZhj2+FQcrg==", + "deprecated": "This functionality has been moved to @npmcli/fs", + "license": "MIT", + "optional": true, + "dependencies": { + "mkdirp": "^1.0.4", + "rimraf": "^3.0.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@tootallnate/once": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", + "integrity": "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/@types/raf": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz", + "integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==", + "license": "MIT", + "optional": true + }, + "node_modules/abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "license": "ISC", + "optional": true + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -30,6 +98,81 @@ "node": ">= 0.6" } }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/agent-base/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "optional": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/agent-base/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT", + "optional": true + }, + "node_modules/agentkeepalive": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", + "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "humanize-ms": "^1.2.1" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/aggregate-error": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", + "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", + "license": "MIT", + "optional": true, + "dependencies": { + "clean-stack": "^2.0.0", + "indent-string": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, "node_modules/anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", @@ -44,6 +187,28 @@ "node": ">= 8" } }, + "node_modules/aproba": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz", + "integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==", + "license": "ISC", + "optional": true + }, + "node_modules/are-we-there-yet": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-3.0.1.tgz", + "integrity": "sha512-QZW4EDmGwlYur0Yyf/b2uGucHQMa8aFUP7eu9ddR73vvhFyt4V0Vl3QHPcTNJ8l6qYOBdxgXdnBXQrHilfRQBg==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "optional": true, + "dependencies": { + "delegates": "^1.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", @@ -56,12 +221,34 @@ "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", "license": "MIT" }, + "node_modules/atob": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/atob/-/atob-2.1.2.tgz", + "integrity": "sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==", + "license": "(MIT OR Apache-2.0)", + "bin": { + "atob": "bin/atob.js" + }, + "engines": { + "node": ">= 4.5.0" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT" }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -82,20 +269,6 @@ ], "license": "MIT" }, - "node_modules/better-sqlite3": { - "version": "12.10.0", - "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.10.0.tgz", - "integrity": "sha512-CyzaZRQKyHkB2ZInfTTl2nvT33EbDpjkLEbE8/Zck3Ll6O0qqvuGdrJ45HgtH+HykRg88ITY3AdreBGN70aBSQ==", - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "bindings": "^1.5.0", - "prebuild-install": "^7.1.1" - }, - "engines": { - "node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x" - } - }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -175,6 +348,18 @@ "node": ">=8" } }, + "node_modules/btoa": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/btoa/-/btoa-1.2.1.tgz", + "integrity": "sha512-SB4/MIGlsiVkMcHmT+pSmIPoNDoHg+7cMzmt3Uxt628MTz2487DKSqK/fuhFBrkuqrYv5UCEnACpF4dTFNKc/g==", + "license": "(MIT OR Apache-2.0)", + "bin": { + "btoa": "bin/btoa.js" + }, + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/buffer": { "version": "5.7.1", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", @@ -208,6 +393,36 @@ "node": ">= 0.8" } }, + "node_modules/cacache": { + "version": "15.3.0", + "resolved": "https://registry.npmjs.org/cacache/-/cacache-15.3.0.tgz", + "integrity": "sha512-VVdYzXEn+cnbXpFgWs5hTT7OScegHVmLhJIR8Ufqk3iFD6A6j5iSX1KuBTfNEv4tdJWE2PzA6IVFtcLC7fN9wQ==", + "license": "ISC", + "optional": true, + "dependencies": { + "@npmcli/fs": "^1.0.0", + "@npmcli/move-file": "^1.0.1", + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "glob": "^7.1.4", + "infer-owner": "^1.0.4", + "lru-cache": "^6.0.0", + "minipass": "^3.1.1", + "minipass-collect": "^1.0.2", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.2", + "mkdirp": "^1.0.3", + "p-map": "^4.0.0", + "promise-inflight": "^1.0.1", + "rimraf": "^3.0.2", + "ssri": "^8.0.1", + "tar": "^6.0.2", + "unique-filename": "^1.1.1" + }, + "engines": { + "node": ">= 10" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -237,6 +452,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/canvg": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz", + "integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==", + "license": "MIT", + "optional": true, + "dependencies": { + "@babel/runtime": "^7.12.5", + "@types/raf": "^3.4.0", + "core-js": "^3.8.3", + "raf": "^3.4.1", + "regenerator-runtime": "^0.13.7", + "rgbcolor": "^1.0.1", + "stackblur-canvas": "^2.0.0", + "svg-pathdata": "^6.0.3" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -263,10 +498,47 @@ } }, "node_modules/chownr": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", - "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", - "license": "ISC" + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/clean-stack": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", + "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/color-support": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", + "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", + "license": "ISC", + "optional": true, + "bin": { + "color-support": "bin.js" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT", + "optional": true + }, + "node_modules/console-control-strings": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", + "license": "ISC", + "optional": true }, "node_modules/content-disposition": { "version": "0.5.4", @@ -304,6 +576,28 @@ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", "license": "MIT" }, + "node_modules/core-js": { + "version": "3.49.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz", + "integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/css-line-break": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz", + "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", + "license": "MIT", + "optional": true, + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -337,6 +631,13 @@ "node": ">=4.0.0" } }, + "node_modules/delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", + "license": "MIT", + "optional": true + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -365,6 +666,13 @@ "node": ">=8" } }, + "node_modules/dompurify": { + "version": "2.5.9", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-2.5.9.tgz", + "integrity": "sha512-i6mvVmWN4xo9LrhCOZrDgSs9noW6nOahbrmzjRbPF36YPyj5Ue5lgok0MHDWkG7xzpWFO2OYttXdzM7rJxHvNA==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optional": true + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -400,6 +708,13 @@ "node": ">=0.10.0" } }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT", + "optional": true + }, "node_modules/encodeurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", @@ -409,6 +724,29 @@ "node": ">= 0.8" } }, + "node_modules/encoding": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", + "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", + "license": "MIT", + "optional": true, + "dependencies": { + "iconv-lite": "^0.6.2" + } + }, + "node_modules/encoding/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "optional": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", @@ -418,6 +756,23 @@ "once": "^1.4.0" } }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/err-code": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", + "license": "MIT", + "optional": true + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -518,6 +873,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, "node_modules/file-uri-to-path": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", @@ -588,6 +949,25 @@ "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", "license": "MIT" }, + "node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC", + "optional": true + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -612,6 +992,27 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gauge": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-4.0.4.tgz", + "integrity": "sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "optional": true, + "dependencies": { + "aproba": "^1.0.3 || ^2.0.0", + "color-support": "^1.1.3", + "console-control-strings": "^1.1.0", + "has-unicode": "^2.0.1", + "signal-exit": "^3.0.7", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "wide-align": "^1.1.5" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -655,6 +1056,28 @@ "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", "license": "MIT" }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "optional": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/glob-parent": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", @@ -668,6 +1091,30 @@ "node": ">= 6" } }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "license": "MIT", + "optional": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "license": "ISC", + "optional": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -680,6 +1127,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC", + "optional": true + }, "node_modules/has-flag": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", @@ -702,6 +1156,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", + "license": "ISC", + "optional": true + }, "node_modules/hasown": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", @@ -714,6 +1175,27 @@ "node": ">= 0.4" } }, + "node_modules/html2canvas": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", + "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", + "license": "MIT", + "optional": true, + "dependencies": { + "css-line-break": "^2.1.0", + "text-segmentation": "^1.0.3" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "license": "BSD-2-Clause", + "optional": true + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -734,6 +1216,95 @@ "url": "https://opencollective.com/express" } }, + "node_modules/http-proxy-agent": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz", + "integrity": "sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==", + "license": "MIT", + "optional": true, + "dependencies": { + "@tootallnate/once": "1", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/http-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "optional": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/http-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT", + "optional": true + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "optional": true, + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/https-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "optional": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/https-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT", + "optional": true + }, + "node_modules/humanize-ms": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", + "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "ms": "^2.0.0" + } + }, "node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -773,6 +1344,45 @@ "dev": true, "license": "ISC" }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/infer-owner": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/infer-owner/-/infer-owner-1.0.4.tgz", + "integrity": "sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==", + "license": "ISC", + "optional": true + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "optional": true, + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -785,6 +1395,16 @@ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", "license": "ISC" }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -817,6 +1437,16 @@ "node": ">=0.10.0" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -830,6 +1460,13 @@ "node": ">=0.10.0" } }, + "node_modules/is-lambda": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-lambda/-/is-lambda-1.0.1.tgz", + "integrity": "sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==", + "license": "MIT", + "optional": true + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -840,6 +1477,13 @@ "node": ">=0.12.0" } }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC", + "optional": true + }, "node_modules/jake": { "version": "10.9.4", "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz", @@ -857,6 +1501,74 @@ "node": ">=10" } }, + "node_modules/jspdf": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-2.5.2.tgz", + "integrity": "sha512-myeX9c+p7znDWPk0eTrujCzNjT+CXdXyk7YmJq5nD5V7uLLKmSXnlQ/Jn/kuo3X09Op70Apm0rQSnFWyGK8uEQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.2", + "atob": "^2.1.2", + "btoa": "^1.2.1", + "fflate": "^0.8.1" + }, + "optionalDependencies": { + "canvg": "^3.0.6", + "core-js": "^3.6.0", + "dompurify": "^2.5.4", + "html2canvas": "^1.0.0-rc.5" + } + }, + "node_modules/jspdf-autotable": { + "version": "3.8.4", + "resolved": "https://registry.npmjs.org/jspdf-autotable/-/jspdf-autotable-3.8.4.tgz", + "integrity": "sha512-rSffGoBsJYX83iTRv8Ft7FhqfgEL2nLpGAIiqruEQQ3e4r0qdLFbPUB7N9HAle0I3XgpisvyW751VHCqKUVOgQ==", + "license": "MIT", + "peerDependencies": { + "jspdf": "^2.5.1" + } + }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "license": "ISC", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/make-fetch-happen": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-9.1.0.tgz", + "integrity": "sha512-+zopwDy7DNknmwPQplem5lAZX/eCOzSvSNNcSKm5eVwTkOBzoktEfXsa9L23J/GIRhxRsaxzkPEhrJEpE2F4Gg==", + "license": "ISC", + "optional": true, + "dependencies": { + "agentkeepalive": "^4.1.3", + "cacache": "^15.2.0", + "http-cache-semantics": "^4.1.0", + "http-proxy-agent": "^4.0.1", + "https-proxy-agent": "^5.0.0", + "is-lambda": "^1.0.1", + "lru-cache": "^6.0.0", + "minipass": "^3.1.3", + "minipass-collect": "^1.0.2", + "minipass-fetch": "^1.3.2", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^0.6.2", + "promise-retry": "^2.0.1", + "socks-proxy-agent": "^6.0.0", + "ssri": "^8.0.0" + }, + "engines": { + "node": ">= 10" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -884,30 +1596,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/method-override": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/method-override/-/method-override-3.0.0.tgz", - "integrity": "sha512-IJ2NNN/mSl9w3kzWB92rcdHpz+HjkxhDJWNDBqSlas+zQdP8wBiJzITPg08M/k2uVvMow7Sk41atndNtt/PHSA==", - "license": "MIT", - "dependencies": { - "debug": "3.1.0", - "methods": "~1.1.2", - "parseurl": "~1.3.2", - "vary": "~1.1.2" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/method-override/node_modules/debug": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", - "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, "node_modules/methods": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", @@ -983,6 +1671,113 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-collect": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-1.0.2.tgz", + "integrity": "sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==", + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minipass-fetch": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-1.4.1.tgz", + "integrity": "sha512-CGH1eblLq26Y15+Azk7ey4xh0J/XfJfrCox5LDJiKqI2Q2iwOLOKrlmIaODiSQS8d18jalF6y2K2ePUm0CmShw==", + "license": "MIT", + "optional": true, + "dependencies": { + "minipass": "^3.1.0", + "minipass-sized": "^1.0.3", + "minizlib": "^2.0.0" + }, + "engines": { + "node": ">=8" + }, + "optionalDependencies": { + "encoding": "^0.1.12" + } + }, + "node_modules/minipass-flush": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.7.tgz", + "integrity": "sha512-TbqTz9cUwWyHS2Dy89P3ocAGUGxKjjLuR9z8w4WUTGAVgEj17/4nhgo2Du56i0Fm3Pm30g4iA8Lcqctc76jCzA==", + "license": "BlueOak-1.0.0", + "optional": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minipass-pipeline": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", + "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-sized": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz", + "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==", + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "license": "MIT", + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/mkdirp-classic": { "version": "0.5.3", "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", @@ -1022,6 +1817,37 @@ "node": ">=10" } }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "license": "MIT" + }, + "node_modules/node-gyp": { + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-8.4.1.tgz", + "integrity": "sha512-olTJRgUtAb/hOXG0E93wZDs5YiJlgbXxTwQAFHyNlRsXQnYzUaF2aGgujZbw+hR8aF4ZG/rST57bWMWD16jr9w==", + "license": "MIT", + "optional": true, + "dependencies": { + "env-paths": "^2.2.0", + "glob": "^7.1.4", + "graceful-fs": "^4.2.6", + "make-fetch-happen": "^9.1.0", + "nopt": "^5.0.0", + "npmlog": "^6.0.0", + "rimraf": "^3.0.2", + "semver": "^7.3.5", + "tar": "^6.1.2", + "which": "^2.0.2" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": ">= 10.12.0" + } + }, "node_modules/nodemon": { "version": "3.1.14", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz", @@ -1115,6 +1941,22 @@ "dev": true, "license": "MIT" }, + "node_modules/nopt": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", + "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", + "license": "ISC", + "optional": true, + "dependencies": { + "abbrev": "1" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -1125,6 +1967,23 @@ "node": ">=0.10.0" } }, + "node_modules/npmlog": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-6.0.2.tgz", + "integrity": "sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "optional": true, + "dependencies": { + "are-we-there-yet": "^3.0.0", + "console-control-strings": "^1.1.0", + "gauge": "^4.0.3", + "set-blocking": "^2.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -1158,6 +2017,22 @@ "wrappy": "1" } }, + "node_modules/p-map": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", + "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "aggregate-error": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -1167,12 +2042,29 @@ "node": ">= 0.8" } }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/path-to-regexp": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", "license": "MIT" }, + "node_modules/performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", + "license": "MIT", + "optional": true + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1219,6 +2111,27 @@ "node": ">=10" } }, + "node_modules/promise-inflight": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", + "integrity": "sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==", + "license": "ISC", + "optional": true + }, + "node_modules/promise-retry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", + "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "license": "MIT", + "optional": true, + "dependencies": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -1264,6 +2177,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/raf": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", + "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==", + "license": "MIT", + "optional": true, + "dependencies": { + "performance-now": "^2.1.0" + } + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -1330,6 +2253,50 @@ "node": ">=8.10.0" } }, + "node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "license": "MIT", + "optional": true + }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/rgbcolor": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz", + "integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==", + "license": "MIT OR SEE LICENSE IN FEEL-FREE.md", + "optional": true, + "engines": { + "node": ">= 0.8.15" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "optional": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -1413,6 +2380,13 @@ "node": ">= 0.8.0" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC", + "optional": true + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -1491,6 +2465,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC", + "optional": true + }, "node_modules/simple-concat": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", @@ -1549,6 +2530,119 @@ "node": ">=10" } }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz", + "integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==", + "license": "MIT", + "optional": true, + "dependencies": { + "ip-address": "^10.1.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-6.2.1.tgz", + "integrity": "sha512-a6KW9G+6B3nWZ1yB8G7pJwL3ggLy1uTzKAgCb7ttblwqdz9fMGJUuTy3uFzEP48FAs9FLILlmzDlE2JJhVQaXQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "agent-base": "^6.0.2", + "debug": "^4.3.3", + "socks": "^2.6.2" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/socks-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "optional": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socks-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT", + "optional": true + }, + "node_modules/sqlite3": { + "version": "5.1.7", + "resolved": "https://registry.npmjs.org/sqlite3/-/sqlite3-5.1.7.tgz", + "integrity": "sha512-GGIyOiFaG+TUra3JIfkI/zGP8yZYLPQ0pl1bH+ODjiX57sPhrLU5sQJn1y9bDKZUFYkX1crlrPfSYt0BKKdkog==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "bindings": "^1.5.0", + "node-addon-api": "^7.0.0", + "prebuild-install": "^7.1.1", + "tar": "^6.1.11" + }, + "optionalDependencies": { + "node-gyp": "8.x" + }, + "peerDependencies": { + "node-gyp": "8.x" + }, + "peerDependenciesMeta": { + "node-gyp": { + "optional": true + } + } + }, + "node_modules/ssri": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-8.0.1.tgz", + "integrity": "sha512-97qShzy1AiyxvPNIkLWoGua7xoQzzPjQ0HAH4B0rWKo7SZ6USuPcrUiAFrws0UH8RrbWmgq3LMTObhPIHbbBeQ==", + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^3.1.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/stackblur-canvas": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz", + "integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.1.14" + } + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -1567,6 +2661,34 @@ "safe-buffer": "~5.2.0" } }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "optional": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", @@ -1589,6 +2711,34 @@ "node": ">=4" } }, + "node_modules/svg-pathdata": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz", + "integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "deprecated": "Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/tar-fs": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", @@ -1601,6 +2751,12 @@ "tar-stream": "^2.1.4" } }, + "node_modules/tar-fs/node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, "node_modules/tar-stream": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", @@ -1617,6 +2773,25 @@ "node": ">=6" } }, + "node_modules/tar/node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "license": "ISC", + "engines": { + "node": ">=8" + } + }, + "node_modules/text-segmentation": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", + "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", + "license": "MIT", + "optional": true, + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -1681,6 +2856,26 @@ "dev": true, "license": "MIT" }, + "node_modules/unique-filename": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-1.1.1.tgz", + "integrity": "sha512-Vmp0jIp2ln35UTXuryvjzkjGdRyf9b2lTXuSYUiPmzRcl3FDtYqAwOnTJkAngD9SWhnoJzDbTKwaOrZ+STtxNQ==", + "license": "ISC", + "optional": true, + "dependencies": { + "unique-slug": "^2.0.0" + } + }, + "node_modules/unique-slug": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-2.0.2.tgz", + "integrity": "sha512-zoWr9ObaxALD3DOPfjPSqxt4fnZiWblxHIgeWqW8x7UqDzEtHEQLzji2cuJYQFCU6KmoJikOYAZlrTHHebjx2w==", + "license": "ISC", + "optional": true, + "dependencies": { + "imurmurhash": "^0.1.4" + } + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -1705,6 +2900,16 @@ "node": ">= 0.4.0" } }, + "node_modules/utrie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", + "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", + "license": "MIT", + "optional": true, + "dependencies": { + "base64-arraybuffer": "^1.0.2" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -1714,11 +2919,43 @@ "node": ">= 0.8" } }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "optional": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wide-align": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", + "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", + "license": "ISC", + "optional": true, + "dependencies": { + "string-width": "^1.0.2 || 2 || 3 || 4" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" } } } diff --git a/package.json b/package.json index c5ecd76..85c67ab 100644 --- a/package.json +++ b/package.json @@ -1,19 +1,33 @@ { "name": "bewerbungs-tracker", "version": "1.0.0", - "description": "Bewerbungs-Tracker für die monatliche Jobcenter-Berichterstattung", + "description": "Job Application Tracker für Jobcenter Grundsicherung Monatsberichte", "main": "server.js", "scripts": { "start": "node server.js", - "dev": "nodemon server.js" + "dev": "nodemon server.js", + "docker:build": "docker build -t git.hackner.dev/thomas/jobbi-bewerbung:latest .", + "docker:push": "docker push git.hackner.dev/thomas/jobbi-bewerbung:latest", + "docker:deploy": "npm run docker:build && npm run docker:push" }, "dependencies": { - "better-sqlite3": "^12.10.0", - "ejs": "^3.1.10", - "express": "^4.22.2", - "method-override": "^3.0.0" + "ejs": "^3.1.9", + "express": "^4.18.2", + "jspdf": "^2.5.1", + "jspdf-autotable": "^3.8.2", + "sqlite3": "^5.1.6" }, "devDependencies": { "nodemon": "^3.0.2" - } + }, + "keywords": [ + "job", + "application", + "tracker", + "jobcenter", + "grundsicherung", + "bewerbung" + ], + "author": "", + "license": "MIT" } diff --git a/public/css/style.css b/public/css/style.css deleted file mode 100644 index 469f0c2..0000000 --- a/public/css/style.css +++ /dev/null @@ -1,822 +0,0 @@ -/* ── Bewerbungs-Tracker — Design System ──────────────────────────────────── */ - -@import url('https://fonts.googleapis.com/css2?family=Barlow+Semi+Condensed:wght@600;700;800&family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;1,9..40,400&family=DM+Mono:wght@400;500&display=swap'); - -/* ── Variables ───────────────────────────────────────────────────────────── */ - -:root { - --bg: #f3f5fb; - --surface: #ffffff; - --surface-2: #edf0f7; - --surface-3: #e4e9f4; - --border: #dde2ef; - --border-2: #cad2e4; - --text: #18202f; - --text-2: #4a556b; - --text-muted: #8896b0; - --accent: #e07b00; - --accent-dim: rgba(224,123,0,0.1); - --accent-hover: #c96e00; - --green: #16a34a; - --green-dim: rgba(22,163,74,0.1); - --red: #dc2626; - --red-dim: rgba(220,38,38,0.1); - --amber: #d97706; - --amber-dim: rgba(217,119,6,0.1); - --blue: #2563eb; - --blue-dim: rgba(37,99,235,0.1); - --purple: #7c3aed; - --purple-dim: rgba(124,58,237,0.1); - --shadow-sm: 0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04); - --shadow: 0 4px 16px rgba(0,0,0,0.08); - --shadow-xl: 0 20px 60px rgba(0,0,0,0.14); - --radius: 6px; - --radius-lg: 10px; - --radius-xl: 14px; - --font-sans: 'DM Sans', system-ui, -apple-system, sans-serif; - --font-display: 'Barlow Semi Condensed', system-ui, sans-serif; - --font-mono: 'DM Mono', 'Fira Mono', monospace; -} - -html.dark { - --bg: #0c0e14; - --surface: #131720; - --surface-2: #192030; - --surface-3: #1f2840; - --border: #232d42; - --border-2: #2c3a56; - --text: #d0d9ea; - --text-2: #7d8ea8; - --text-muted: #435069; - --accent: #f08c00; - --accent-dim: rgba(240,140,0,0.12); - --accent-hover: #e07b00; - --green: #22c55e; - --green-dim: rgba(34,197,94,0.12); - --red: #f87171; - --red-dim: rgba(248,113,113,0.12); - --amber: #fbbf24; - --amber-dim: rgba(251,191,36,0.12); - --blue: #60a5fa; - --blue-dim: rgba(96,165,250,0.12); - --purple: #a78bfa; - --purple-dim: rgba(167,139,250,0.12); - --shadow-sm: 0 1px 3px rgba(0,0,0,0.4); - --shadow: 0 4px 16px rgba(0,0,0,0.5); - --shadow-xl: 0 20px 60px rgba(0,0,0,0.7); -} - -/* ── Reset ───────────────────────────────────────────────────────────────── */ - -*, *::before, *::after { box-sizing: border-box; } -body { margin: 0; } - -/* ── Base ────────────────────────────────────────────────────────────────── */ - -body { - font-family: var(--font-sans); - background: var(--bg); - color: var(--text); - min-height: 100vh; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - transition: background 0.2s, color 0.2s; -} - -/* Ambient gradient for dark mode */ -html.dark body::before { - content: ''; - position: fixed; - inset: 0; - z-index: 0; - pointer-events: none; - background: - radial-gradient(ellipse 70% 50% at 5% 0%, rgba(240,140,0,0.05) 0%, transparent 65%), - radial-gradient(ellipse 50% 40% at 95% 100%, rgba(96,165,250,0.04) 0%, transparent 65%); -} - -/* ── Navigation ──────────────────────────────────────────────────────────── */ - -.app-nav { - position: sticky; - top: 0; - z-index: 40; - height: 56px; - display: flex; - align-items: center; - padding: 0 1.5rem; - gap: 1.5rem; - background: var(--surface); - border-bottom: 1px solid var(--border); -} - -.nav-brand { - display: flex; - align-items: center; - gap: 0.5rem; - text-decoration: none; - font-family: var(--font-display); - font-weight: 700; - font-size: 1.05rem; - letter-spacing: 0.01em; - color: var(--text); - flex-shrink: 0; -} - -/* Animated dot */ -.brand-dot { - width: 8px; - height: 8px; - border-radius: 50%; - background: var(--accent); - flex-shrink: 0; - animation: pulse-dot 3s ease-in-out infinite; -} - -@keyframes pulse-dot { - 0%, 100% { box-shadow: 0 0 0 0 var(--accent-dim); } - 50% { box-shadow: 0 0 0 5px transparent; } -} - -.nav-links { - display: flex; - align-items: center; - gap: 2px; - flex: 1; -} - -.nav-link { - padding: 0.3125rem 0.6875rem; - border-radius: var(--radius); - font-size: 0.875rem; - font-weight: 500; - color: var(--text-2); - text-decoration: none; - transition: color 0.15s, background 0.15s; -} - -.nav-link:hover { color: var(--text); background: var(--surface-2); } -.nav-link.active { color: var(--accent); background: var(--accent-dim); } - -/* Pill toggle for dark mode */ -.dark-toggle { - position: relative; - width: 42px; - height: 24px; - border-radius: 12px; - background: var(--surface-3); - border: 1px solid var(--border-2); - cursor: pointer; - transition: background 0.2s, border-color 0.2s; - flex-shrink: 0; - padding: 0; - outline: none; -} - -.dark-toggle:focus-visible { - box-shadow: 0 0 0 2px var(--accent); -} - -.dark-toggle::after { - content: ''; - position: absolute; - top: 3px; - left: 3px; - width: 16px; - height: 16px; - border-radius: 50%; - background: var(--text-muted); - transition: transform 0.22s cubic-bezier(0.4,0,0.2,1), background 0.2s; -} - -html.dark .dark-toggle { background: var(--accent-dim); border-color: var(--accent); } -html.dark .dark-toggle::after { - transform: translateX(18px); - background: var(--accent); -} - -/* ── Page wrapper ────────────────────────────────────────────────────────── */ - -.page-wrapper { - position: relative; - z-index: 1; - max-width: 88rem; - margin: 0 auto; - padding: 2rem 1.5rem 3rem; -} - -/* ── Page header ─────────────────────────────────────────────────────────── */ - -.page-header { - display: flex; - flex-wrap: wrap; - align-items: flex-start; - justify-content: space-between; - gap: 1rem; - margin-bottom: 1.75rem; -} - -.page-title { - font-family: var(--font-display); - font-weight: 700; - font-size: 1.875rem; - letter-spacing: 0.01em; - line-height: 1.15; - color: var(--text); -} - -.page-meta { - font-size: 0.8125rem; - color: var(--text-muted); - margin-top: 0.3rem; - display: flex; - align-items: center; - gap: 0.5rem; -} - -.page-meta .sep { opacity: 0.4; } - -.page-meta .tag { - font-family: var(--font-mono); - font-size: 0.7rem; - background: var(--surface-2); - border: 1px solid var(--border); - padding: 0.1rem 0.5rem; - border-radius: 4px; - color: var(--text-2); -} - -/* ── Filter bar ──────────────────────────────────────────────────────────── */ - -.filter-bar { - display: flex; - align-items: center; - gap: 0.5rem; - flex-wrap: wrap; -} - -/* ── Stats ───────────────────────────────────────────────────────────────── */ - -.stats-grid { - display: grid; - grid-template-columns: repeat(2, 1fr); - gap: 0.875rem; - margin-bottom: 1.5rem; -} - -@media (min-width: 560px) { - .stats-grid { grid-template-columns: repeat(4, 1fr); } -} - -.stat-card { - background: var(--surface); - border: 1px solid var(--border); - border-radius: var(--radius-xl); - padding: 1.125rem 1.25rem 1rem; - position: relative; - overflow: hidden; - animation: fadeUp 0.45s cubic-bezier(0.22,1,0.36,1) both; -} - -.stat-card:nth-child(1) { animation-delay: 0.04s; } -.stat-card:nth-child(2) { animation-delay: 0.09s; } -.stat-card:nth-child(3) { animation-delay: 0.14s; } -.stat-card:nth-child(4) { animation-delay: 0.19s; } - -/* Bottom color strip */ -.stat-card::after { - content: ''; - position: absolute; - bottom: 0; left: 0; right: 0; - height: 2px; - border-radius: 0 0 var(--radius-xl) var(--radius-xl); -} - -.stat-card.s-total::after { background: var(--accent); } -.stat-card.s-positiv::after { background: var(--green); } -.stat-card.s-absage::after { background: var(--red); } -.stat-card.s-pending::after { background: var(--amber); } - -.stat-label { - font-size: 0.65rem; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.1em; - color: var(--text-muted); - margin-bottom: 0.375rem; -} - -.stat-value { - font-family: var(--font-display); - font-weight: 800; - font-size: 2.5rem; - line-height: 1; - letter-spacing: 0.01em; - color: var(--text); - font-variant-numeric: tabular-nums; -} - -.stat-card.s-total .stat-value { color: var(--accent); } -.stat-card.s-positiv .stat-value { color: var(--green); } -.stat-card.s-absage .stat-value { color: var(--red); } -.stat-card.s-pending .stat-value { color: var(--amber); } - -/* ── Action bar ──────────────────────────────────────────────────────────── */ - -.action-bar { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 0.75rem; -} - -.entry-count { - font-size: 0.6875rem; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.1em; - color: var(--text-muted); -} - -/* ── Data card ───────────────────────────────────────────────────────────── */ - -.data-card { - background: var(--surface); - border: 1px solid var(--border); - border-radius: var(--radius-xl); - overflow: hidden; - animation: fadeUp 0.45s 0.24s cubic-bezier(0.22,1,0.36,1) both; -} - -/* ── Table ───────────────────────────────────────────────────────────────── */ - -.data-table { - width: 100%; - border-collapse: collapse; -} - -.data-table thead th { - padding: 0.6875rem 1rem; - text-align: left; - font-size: 0.65rem; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.1em; - color: var(--text-muted); - background: var(--surface-2); - border-bottom: 1px solid var(--border); - white-space: nowrap; -} - -.data-table thead th:first-child { border-radius: 0; } - -.data-table tbody tr { - border-bottom: 1px solid var(--border); - transition: background 0.1s; -} - -.data-table tbody tr:last-child { border-bottom: none; } -.data-table tbody tr:hover { background: var(--surface-2); } - -/* Row actions: only visible on hover */ -.data-table tbody tr .row-actions { opacity: 0; transition: opacity 0.15s; } -.data-table tbody tr:hover .row-actions { opacity: 1; } - -.data-table td { - padding: 0.8125rem 1rem; - font-size: 0.875rem; - vertical-align: middle; -} - -.td-date { - font-family: var(--font-mono); - font-size: 0.75rem; - color: var(--text-muted); - white-space: nowrap; - letter-spacing: 0.02em; -} - -.td-firma { font-weight: 600; color: var(--text); } -.td-stelle { color: var(--text-2); } - -.td-art { - font-family: var(--font-mono); - font-size: 0.7rem; - color: var(--text-muted); -} - -.td-notizen { - max-width: 14rem; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-size: 0.75rem; - color: var(--text-muted); -} - -/* ── Status chip ─────────────────────────────────────────────────────────── */ - -.status-chip { - display: inline-flex; - align-items: center; - gap: 0.375rem; - font-size: 0.8rem; - font-weight: 500; - white-space: nowrap; -} - -.status-chip::before { - content: ''; - width: 6px; - height: 6px; - border-radius: 50%; - flex-shrink: 0; -} - -.st-gesendet { color: var(--blue); } -.st-gesendet::before { background: var(--blue); } -.st-eingang { color: var(--amber); } -.st-eingang::before { background: var(--amber); } -.st-vorstellung { color: var(--purple); } -.st-vorstellung::before { background: var(--purple); } -.st-absage { color: var(--red); } -.st-absage::before { background: var(--red); } -.st-einstellung { color: var(--green); } -.st-einstellung::before { background: var(--green); box-shadow: 0 0 5px var(--green); } -.st-keine { color: var(--text-muted); } -.st-keine::before { background: var(--text-muted); } - -/* ── Row action buttons ──────────────────────────────────────────────────── */ - -.row-btn { - display: inline-flex; - align-items: center; - justify-content: center; - width: 30px; - height: 30px; - border-radius: var(--radius); - border: none; - background: transparent; - cursor: pointer; - transition: background 0.12s, color 0.12s; - color: var(--text-muted); -} - -.row-btn:hover { background: var(--surface-3); color: var(--text); } -.row-btn.btn-edit:hover { background: var(--blue-dim); color: var(--blue); } -.row-btn.btn-del:hover { background: var(--red-dim); color: var(--red); } - -/* ── Empty state ─────────────────────────────────────────────────────────── */ - -.empty-state { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - padding: 5rem 2rem; - text-align: center; -} - -.empty-icon { - width: 52px; - height: 52px; - border-radius: 50%; - background: var(--surface-2); - border: 1px solid var(--border); - display: flex; - align-items: center; - justify-content: center; - margin-bottom: 1.125rem; - color: var(--text-muted); -} - -.empty-title { font-weight: 600; color: var(--text-2); margin-bottom: 0.3rem; font-size: 0.9375rem; } -.empty-sub { font-size: 0.8125rem; color: var(--text-muted); } - -/* ── Buttons ─────────────────────────────────────────────────────────────── */ - -.btn { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 0.375rem; - padding: 0.5rem 1rem; - border-radius: var(--radius); - font-family: var(--font-sans); - font-size: 0.875rem; - font-weight: 600; - line-height: 1; - cursor: pointer; - border: 1px solid transparent; - transition: background 0.13s, border-color 0.13s, color 0.13s, opacity 0.13s, transform 0.1s; - text-decoration: none; - white-space: nowrap; - user-select: none; -} - -.btn:active { transform: scale(0.975); } -.btn:disabled { opacity: 0.45; cursor: not-allowed; transform: none !important; } - -.btn-primary { - background: var(--accent); - color: #0d0d0d; - border-color: var(--accent); -} - -.btn-primary:hover:not(:disabled) { - background: var(--accent-hover); - border-color: var(--accent-hover); -} - -.btn-secondary { - background: var(--surface-2); - color: var(--text-2); - border-color: var(--border); -} - -.btn-secondary:hover:not(:disabled) { - background: var(--surface-3); - border-color: var(--border-2); -} - -.btn-danger { - background: var(--red-dim); - color: var(--red); - border-color: transparent; -} - -.btn-danger:hover:not(:disabled) { - background: var(--red); - color: #fff; -} - -.btn-sm { - padding: 0.375rem 0.75rem; - font-size: 0.8125rem; -} - -/* ── Form controls ───────────────────────────────────────────────────────── */ - -.form-group { - display: flex; - flex-direction: column; - gap: 0.375rem; -} - -.form-label { - font-size: 0.8125rem; - font-weight: 600; - color: var(--text-2); - letter-spacing: -0.01em; -} - -.form-label .req { color: var(--red); margin-left: 2px; } - -.form-hint { - font-size: 0.75rem; - color: var(--text-muted); - margin-top: 0.2rem; -} - -.form-input, -.form-select, -.form-textarea { - width: 100%; - padding: 0.5625rem 0.75rem; - font-family: var(--font-sans); - font-size: 0.875rem; - line-height: 1.5; - color: var(--text); - background: var(--surface); - border: 1px solid var(--border-2); - border-radius: var(--radius); - outline: none; - transition: border-color 0.15s, box-shadow 0.15s; - -webkit-appearance: none; - -moz-appearance: none; -} - -.form-input::placeholder, -.form-textarea::placeholder { color: var(--text-muted); } - -.form-input:focus, -.form-select:focus, -.form-textarea:focus { - border-color: var(--accent); - box-shadow: 0 0 0 3px var(--accent-dim); -} - -.form-select { - cursor: pointer; - padding-right: 2.25rem; - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%237d8ea8'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M19 9l-7 7-7-7'/%3E%3C/svg%3E"); - background-repeat: no-repeat; - background-position: right 0.625rem center; - background-size: 1em; -} - -.form-textarea { resize: none; line-height: 1.65; } - -.form-grid { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 1rem; -} - -.form-grid .span-2 { grid-column: span 2; } - -@media (max-width: 500px) { - .form-grid { grid-template-columns: 1fr; } - .form-grid .span-2 { grid-column: span 1; } -} - -/* ── Modal ───────────────────────────────────────────────────────────────── */ - -.modal-backdrop { - position: fixed; - inset: 0; - z-index: 50; - display: flex; - align-items: center; - justify-content: center; - padding: 1rem; - background: rgba(0, 0, 0, 0); - backdrop-filter: blur(0px); - visibility: hidden; - opacity: 0; - transition: - background 0.22s ease, - backdrop-filter 0.22s ease, - opacity 0.22s ease, - visibility 0s 0.22s; -} - -.modal-backdrop.is-open { - background: rgba(0,0,0,0.72); - backdrop-filter: blur(5px); - visibility: visible; - opacity: 1; - transition: - background 0.22s ease, - backdrop-filter 0.22s ease, - opacity 0.22s ease, - visibility 0s 0s; -} - -.modal-box { - width: 100%; - max-width: 36rem; - background: var(--surface); - border: 1px solid var(--border-2); - border-radius: var(--radius-xl); - box-shadow: var(--shadow-xl); - display: flex; - flex-direction: column; - max-height: 90vh; - overflow-y: auto; - transform: scale(0.94) translateY(14px); - opacity: 0; - transition: transform 0.28s cubic-bezier(0.32,0.72,0,1), opacity 0.22s ease; -} - -.modal-backdrop.is-open .modal-box { - transform: scale(1) translateY(0); - opacity: 1; -} - -.modal-box.compact { max-width: 26rem; } - -.modal-head { - display: flex; - align-items: center; - justify-content: space-between; - padding: 1.25rem 1.5rem 1.125rem; - border-bottom: 1px solid var(--border); - flex-shrink: 0; -} - -.modal-title { - font-family: var(--font-display); - font-weight: 700; - font-size: 1.125rem; - letter-spacing: 0.01em; - color: var(--text); -} - -.modal-body { padding: 1.25rem 1.5rem; } - -.modal-foot { - display: flex; - align-items: center; - justify-content: flex-end; - gap: 0.625rem; - padding: 1rem 1.5rem; - border-top: 1px solid var(--border); - flex-shrink: 0; -} - -.modal-close { - width: 28px; - height: 28px; - display: flex; - align-items: center; - justify-content: center; - border-radius: var(--radius); - background: transparent; - border: none; - cursor: pointer; - color: var(--text-muted); - transition: background 0.12s, color 0.12s; - padding: 0; -} - -.modal-close:hover { background: var(--surface-2); color: var(--text); } - -/* ── Alert ───────────────────────────────────────────────────────────────── */ - -.alert { - display: flex; - align-items: center; - gap: 0.625rem; - padding: 0.75rem 1rem; - border-radius: var(--radius-lg); - font-size: 0.875rem; - margin-bottom: 1.25rem; - animation: fadeUp 0.3s ease-out both; -} - -.alert-error { - background: var(--red-dim); - border: 1px solid rgba(248,113,113,0.25); - color: var(--red); -} - -.alert-success { - background: var(--green-dim); - border: 1px solid rgba(34,197,94,0.25); - color: var(--green); -} - -/* ── Settings ────────────────────────────────────────────────────────────── */ - -.settings-card { - background: var(--surface); - border: 1px solid var(--border); - border-radius: var(--radius-xl); - padding: 2rem 2rem 1.75rem; - animation: fadeUp 0.4s ease-out both; -} - -.settings-form > * + * { margin-top: 1.25rem; } - -.info-box { - padding: 1rem 1.125rem; - border-radius: var(--radius-lg); - background: var(--blue-dim); - border: 1px solid rgba(96,165,250,0.2); - font-size: 0.8125rem; - color: var(--blue); - animation: fadeUp 0.4s 0.1s ease-out both; -} - -html:not(.dark) .info-box { - background: rgba(37,99,235,0.06); - border-color: rgba(37,99,235,0.18); - color: var(--blue); -} - -.info-box strong { - display: block; - font-weight: 600; - margin-bottom: 0.3rem; - font-size: 0.875rem; -} - -/* ── Footer ──────────────────────────────────────────────────────────────── */ - -.app-footer { - position: relative; - text-align: center; - padding: 1.5rem; - font-size: 0.75rem; - color: var(--text-muted); - border-top: 1px solid var(--border); -} - -/* ── Animations ──────────────────────────────────────────────────────────── */ - -@keyframes fadeUp { - from { opacity: 0; transform: translateY(10px); } - to { opacity: 1; transform: translateY(0); } -} - -/* ── Scrollbar (webkit) ──────────────────────────────────────────────────── */ - -::-webkit-scrollbar { width: 6px; height: 6px; } -::-webkit-scrollbar-track { background: transparent; } -::-webkit-scrollbar-thumb { background: var(--border-2); border-radius: 3px; } -::-webkit-scrollbar-thumb:hover { background: var(--text-muted); } - -/* ── Selection ───────────────────────────────────────────────────────────── */ - -::selection { background: var(--accent-dim); color: var(--text); } diff --git a/public/css/styles.css b/public/css/styles.css new file mode 100644 index 0000000..9d384fa --- /dev/null +++ b/public/css/styles.css @@ -0,0 +1,231 @@ +/* ============================================ + Bewerbungs-Tracker - Custom Styles + ============================================ */ + +/* Base styles */ +* { + box-sizing: border-box; +} + +/* Smooth scrolling */ +html { + scroll-behavior: smooth; +} + +/* Custom scrollbar for dark mode */ +.dark ::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +.dark ::-webkit-scrollbar-track { + background: #374151; +} + +.dark ::-webkit-scrollbar-thumb { + background: #6b7280; + border-radius: 4px; +} + +.dark ::-webkit-scrollbar-thumb:hover { + background: #8b94a3; +} + +/* Light mode scrollbar */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: #f1f1f1; +} + +::-webkit-scrollbar-thumb { + background: #c1c1c1; + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: #a1a1a1; +} + +/* Modal backdrop animation */ +.fixed.inset-0.bg-black.bg-opacity-50 { + animation: fadeIn 0.2s ease-out; +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +/* Modal content animation */ +.fixed.inset-0 > div > div { + animation: slideIn 0.3s ease-out; +} + +@keyframes slideIn { + from { + opacity: 0; + transform: translateY(-20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Table hover effect */ +.tbody tr:hover { + transition: background-color 0.15s ease; +} + +/* Focus styles for inputs */ +input:focus, +textarea:focus, +select:focus { + outline: none; + box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.5); +} + +/* Dark mode focus */ +.dark input:focus, +.dark textarea:focus, +.dark select:focus { + box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.3); +} + +/* Button focus */ +button:focus { + outline: none; + box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.5); +} + +/* Loading spinner for buttons */ +button.loading { + position: relative; + pointer-events: none; +} + +button.loading::after { + content: ''; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 16px; + height: 16px; + border: 2px solid transparent; + border-top-color: currentColor; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { + transform: translate(-50%, -50%) rotate(360deg); + } +} + +/* Status badges */ +.status-badge { + display: inline-block; + padding: 4px 8px; + border-radius: 9999px; + font-size: 12px; + font-weight: 500; +} + +/* Table cell wrapping */ +.table-cell-wrap { + white-space: normal; + word-wrap: break-word; +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + /* Make filter section stack on mobile */ + #filterForm { + flex-direction: column; + align-items: stretch; + } + + #filterForm > div { + margin-bottom: 1rem; + } + + /* Adjust buttons on mobile */ + #filterForm .flex.space-x-2, + #filterForm .ml-auto, + #filterForm > div:last-child { + margin-bottom: 0 !important; + } + + /* Make table scroll horizontally on mobile */ + .overflow-hidden { + overflow-x: auto; + } + + table { + min-width: 768px; + } + + /* Adjust modal width on mobile */ + .max-w-lg { + max-width: 90vw; + margin: 0 5vw; + } + + .max-w-md { + max-width: 90vw; + margin: 0 5vw; + } +} + +/* Print styles */ +@media print { + .no-print { + display: none !important; + } + + body { + background: white; + color: black; + } + + .dark body { + background: white; + color: black; + } +} + +/* Header shadow */ +header { + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); +} + +/* Card shadows */ +.bg-white, +.bg-gray-800 { + box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1); +} + +/* Utility classes */ +.truncate-2 { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.truncate-3 { + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; +} diff --git a/public/js/main.js b/public/js/main.js index 2d50b46..9d64b28 100644 --- a/public/js/main.js +++ b/public/js/main.js @@ -1,267 +1,624 @@ -'use strict'; +// ============================================ +// Bewerbungs-Tracker - Client-side JavaScript +// ============================================ -// ── Dark mode toggle ────────────────────────────────────────────────────────── +// DOM Elements +const body = document.getElementById('body'); +const darkModeToggle = document.getElementById('darkModeToggle'); +const sunIcon = document.getElementById('sunIcon'); +const moonIcon = document.getElementById('moonIcon'); -document.getElementById('darkModeToggle').addEventListener('click', () => { - const isDark = document.documentElement.classList.toggle('dark'); - localStorage.setItem('darkMode', isDark ? 'true' : 'false'); -}); +// Modal Elements +const settingsModal = document.getElementById('settingsModal'); +const applicationModal = document.getElementById('applicationModal'); +const deleteModal = document.getElementById('deleteModal'); +const pdfExportModal = document.getElementById('pdfExportModal'); -// ── Stat counter animation ──────────────────────────────────────────────────── +// Form Elements +const settingsForm = document.getElementById('settingsForm'); +const applicationForm = document.getElementById('applicationForm'); +const pdfExportForm = document.getElementById('pdfExportForm'); +const filterForm = document.getElementById('filterForm'); -(function animateStats() { - const DURATION = 700; +// Button Elements +const settingsBtn = document.getElementById('settingsBtn'); +const addApplicationBtn = document.getElementById('addApplicationBtn'); +const exportPdfBtn = document.getElementById('exportPdfBtn'); - document.querySelectorAll('.stat-value[data-count]').forEach((el, i) => { - const target = parseInt(el.dataset.count, 10) || 0; - if (target === 0) return; +// Close Modal Buttons +const closeSettingsModal = document.getElementById('closeSettingsModal'); +const closeApplicationModal = document.getElementById('closeApplicationModal'); +const closeDeleteModal = document.getElementById('closeDeleteModal'); +const closePdfModal = document.getElementById('closePdfModal'); - const delay = 80 + i * 60; - setTimeout(() => { - const start = performance.now(); - const tick = (now) => { - const p = Math.min((now - start) / DURATION, 1); - // ease-out expo - const eased = p === 1 ? 1 : 1 - Math.pow(2, -10 * p); - el.textContent = Math.round(eased * target); - if (p < 1) requestAnimationFrame(tick); - }; - requestAnimationFrame(tick); - }, delay); - }); -})(); +const cancelSettings = document.getElementById('cancelSettings'); +const cancelApplication = document.getElementById('cancelApplication'); +const cancelDelete = document.getElementById('cancelDelete'); +const cancelPdfExport = document.getElementById('cancelPdfExport'); +const confirmDelete = document.getElementById('confirmDelete'); -// ── Load bewerbungen from embedded JSON ─────────────────────────────────────── +// Global variables +let currentApplicationId = null; +let currentDeleteId = null; +let pdfLibrariesLoaded = false; -let BEWERBUNGEN = []; -const dataEl = document.getElementById('bewerbungenData'); -if (dataEl) { - try { BEWERBUNGEN = JSON.parse(dataEl.textContent); } catch (_) {} -} +// ============================================ +// Dark Mode +// ============================================ -// ── Modal system ────────────────────────────────────────────────────────────── - -function openModal(id) { - const el = document.getElementById(id); - el.classList.add('is-open'); - document.body.style.overflow = 'hidden'; - setTimeout(() => { - const first = el.querySelector( - 'input[type="date"], input[type="text"]:not([type="hidden"]), select, textarea' - ); - if (first) first.focus(); - }, 60); -} - -function closeModal(id) { - const el = document.getElementById(id); - el.classList.remove('is-open'); - document.body.style.overflow = ''; -} - -function closeOnBackdrop(event, id) { - if (event.target === event.currentTarget) closeModal(id); -} - -document.addEventListener('keydown', (e) => { - if (e.key !== 'Escape') return; - ['bewerbungModal', 'deleteModal'].forEach(id => { - const el = document.getElementById(id); - if (el && el.classList.contains('is-open')) closeModal(id); - }); -}); - -// ── Add modal ───────────────────────────────────────────────────────────────── - -function openAddModal() { - const form = document.getElementById('bewerbungForm'); - form.reset(); - form.action = '/bewerbungen'; - - document.getElementById('modalTitle').textContent = 'Neue Bewerbung'; - document.getElementById('datum').value = todayISO(); - - openModal('bewerbungModal'); -} - -// ── Edit modal ──────────────────────────────────────────────────────────────── - -function openEditModal(id) { - const b = BEWERBUNGEN.find(x => x.id === id); - if (!b) return; - - const form = document.getElementById('bewerbungForm'); - form.action = `/bewerbungen/${id}?_method=PUT`; - - document.getElementById('modalTitle').textContent = 'Bewerbung bearbeiten'; - document.getElementById('datum').value = b.datum || ''; - document.getElementById('firma').value = b.firma || ''; - document.getElementById('stelle').value = b.stelle || ''; - document.getElementById('art').value = b.art || ''; - document.getElementById('status').value = b.status || ''; - document.getElementById('notizen').value = b.notizen || ''; - - openModal('bewerbungModal'); -} - -// ── Delete modal ────────────────────────────────────────────────────────────── - -function openDeleteModal(id, firma, stelle) { - document.getElementById('deleteInfo').textContent = `${firma} – ${stelle}`; - document.getElementById('deleteForm').action = `/bewerbungen/${id}?_method=DELETE`; - openModal('deleteModal'); -} - -// ── PDF generation ──────────────────────────────────────────────────────────── - -async function generatePDF(monat, jahr) { - const btn = event.currentTarget; - const saved = btn.innerHTML; - btn.disabled = true; - btn.innerHTML = 'Wird erstellt …'; - - try { - const res = await fetch(`/api/pdf-daten?monat=${monat}&jahr=${jahr}`); - if (!res.ok) throw new Error(`Server ${res.status}`); - buildPDF(await res.json(), monat, jahr); - } catch (err) { - alert('PDF-Erstellung fehlgeschlagen:\n' + err.message); - } finally { - btn.disabled = false; - btn.innerHTML = saved; - } -} - -function buildPDF({ bewerbungen, settings }, monat, jahr) { - const { jsPDF } = window.jspdf; - const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' }); - const PW = doc.internal.pageSize.getWidth(); // 210 mm - const PH = doc.internal.pageSize.getHeight(); // 297 mm - const monatName = MONATE_DE[monat - 1]; - const count = bewerbungen.length; - - // Accent stripe - doc.setFillColor(224, 123, 0); - doc.rect(0, 0, PW, 2.5, 'F'); - - // ── User info (top right) ────────────────────────────────────────────────── - doc.setFontSize(9); - doc.setTextColor(120, 120, 120); - let uy = 10; - const ux = PW - 12; - - if (settings?.name) { - doc.setFont('helvetica', 'bold'); - doc.text(settings.name, ux, uy, { align: 'right' }); - uy += 5; - doc.setFont('helvetica', 'normal'); - } - if (settings?.adresse) { - settings.adresse.split('\n').forEach(line => { - doc.text(line.trim(), ux, uy, { align: 'right' }); - uy += 4.5; - }); - } - if (settings?.kundennummer) { - uy += 1; - doc.text(`Kundennr.: ${settings.kundennummer}`, ux, uy, { align: 'right' }); - } - - // ── Title ────────────────────────────────────────────────────────────────── - doc.setFontSize(17); - doc.setFont('helvetica', 'bold'); - doc.setTextColor(24, 32, 47); - const titleText = `Bewerbungsaktivitäten – ${monatName} ${jahr}`; - doc.text(titleText, 12, 14); - - // Divider — only under the title, never touching the address block on the right - const titleWidth = doc.getTextWidth(titleText); - doc.setDrawColor(220, 226, 237); - doc.setLineWidth(0.35); - doc.line(12, 18.5, 12 + titleWidth + 6, 18.5); - - // ── Summary ──────────────────────────────────────────────────────────────── - doc.setFontSize(10); - doc.setFont('helvetica', 'normal'); - doc.setTextColor(74, 85, 107); - const pl = count !== 1 ? 'n' : ''; - doc.text( - `Im Monat ${monatName} ${jahr} habe ich mich insgesamt auf ${count} Stelle${pl} beworben.`, - 12, 25 - ); - - // ── Table ────────────────────────────────────────────────────────────────── - doc.autoTable({ - startY: 31, - head: [['Datum', 'Firma / Unternehmen', 'Stelle / Position', 'Art', 'Status', 'Notizen']], - body: bewerbungen.map(b => [ - formatDateDE(b.datum), - b.firma || '', - b.stelle || '', - b.art || '', - b.status || '', - b.notizen ? b.notizen.slice(0, 120) : '' - ]), - margin: { left: 12, right: 12 }, - styles: { - fontSize: 7, - cellPadding: 2.8, - textColor: [24, 32, 47], - lineColor: [221, 226, 239], - lineWidth: 0.25, - font: 'helvetica' - }, - headStyles: { - fillColor: [224, 123, 0], - textColor: [255, 255, 255], - fontStyle: 'bold', - halign: 'left', - fontSize: 7 - }, - alternateRowStyles: { fillColor: [248, 250, 254] }, - columnStyles: { - 0: { cellWidth: 26, overflow: 'hidden' }, - 1: { cellWidth: 38 }, - 2: { cellWidth: 38 }, - 3: { cellWidth: 28 }, - 4: { cellWidth: 32 }, - 5: { cellWidth: 'auto' } - }, - didDrawPage: ({ pageNumber }) => { - doc.setFontSize(7.5); - doc.setTextColor(160, 170, 185); - doc.text(`Seite ${pageNumber}`, PW / 2, PH - 4.5, { align: 'center' }); +function initializeDarkMode() { + // Check localStorage for dark mode preference + const darkMode = localStorage.getItem('darkMode'); + + if (darkMode === 'enabled' || (!darkMode && window.matchMedia('(prefers-color-scheme: dark)').matches)) { + body.classList.add('dark'); + sunIcon.classList.add('hidden'); + moonIcon.classList.remove('hidden'); + } else { + body.classList.remove('dark'); + sunIcon.classList.remove('hidden'); + moonIcon.classList.add('hidden'); } - }); - - // ── Declaration line ─────────────────────────────────────────────────────── - const finalY = doc.lastAutoTable.finalY; - const declY = finalY + 10; - - doc.setFontSize(8.5); - doc.setTextColor(100, 110, 130); - doc.setFont('helvetica', 'italic'); - doc.text( - 'Ich erkläre hiermit, dass die vorstehenden Angaben vollständig und wahrheitsgemäß sind.', - 12, declY - ); - - doc.save(`Bewerbungen_${monatName}_${jahr}.pdf`); } -// ── Utilities ───────────────────────────────────────────────────────────────── - -function todayISO() { - const d = new Date(); - return [ - d.getFullYear(), - String(d.getMonth() + 1).padStart(2, '0'), - String(d.getDate()).padStart(2, '0') - ].join('-'); +function toggleDarkMode() { + body.classList.toggle('dark'); + sunIcon.classList.toggle('hidden'); + moonIcon.classList.toggle('hidden'); + + // Save preference to localStorage + if (body.classList.contains('dark')) { + localStorage.setItem('darkMode', 'enabled'); + } else { + localStorage.setItem('darkMode', 'disabled'); + } } -function formatDateDE(s) { - if (!s) return ''; - const p = s.split('-'); - return p.length === 3 ? `${p[2]}.${p[1]}.${p[0]}` : s; +// ============================================ +// Modal Functions +// ============================================ + +function showModal(modal) { + modal.classList.remove('hidden'); + body.style.overflow = 'hidden'; } + +function hideModal(modal) { + modal.classList.add('hidden'); + body.style.overflow = ''; +} + +function resetApplicationForm() { + applicationForm.reset(); + document.getElementById('modalTitle').textContent = 'Bewerbung hinzufügen'; + currentApplicationId = null; +} + +// ============================================ +// Settings Management +// ============================================ + +function loadSettings() { + fetch('/api/settings') + .then(response => response.json()) + .then(settings => { + if (settings) { + document.getElementById('userName').value = settings.name || ''; + document.getElementById('userAddress').value = settings.adresse || ''; + document.getElementById('customerNumber').value = settings.kundennummer || ''; + } + }) + .catch(error => console.error('Error loading settings:', error)); +} + +function saveSettings(event) { + event.preventDefault(); + + const formData = new FormData(settingsForm); + const settings = { + name: formData.get('name'), + adresse: formData.get('adresse'), + kundennummer: formData.get('kundennummer') + }; + + fetch('/api/settings', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(settings) + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + hideModal(settingsModal); + // Refresh page to update settings in header + location.reload(); + } + }) + .catch(error => console.error('Error saving settings:', error)); +} + +// ============================================ +// Application Management (CRUD) +// ============================================ + +function openAddApplicationModal() { + resetApplicationForm(); + // Set default date to today + const today = new Date().toISOString().split('T')[0]; + document.getElementById('applicationDatum').value = today; + showModal(applicationModal); +} + +function openEditApplicationModal(id) { + // Fetch application data + fetch(`/api/bewerbungen/${id}`) + .then(response => response.json()) + .then(application => { + currentApplicationId = application.id; + document.getElementById('modalTitle').textContent = 'Bewerbung bearbeiten'; + document.getElementById('applicationId').value = application.id; + document.getElementById('applicationDatum').value = application.datum; + document.getElementById('applicationFirma').value = application.firma; + document.getElementById('applicationStelle').value = application.stelle; + document.getElementById('applicationArt').value = application.art || ''; + document.getElementById('applicationStatus').value = application.status || ''; + document.getElementById('applicationNotizen').value = application.notizen || ''; + showModal(applicationModal); + }) + .catch(error => console.error('Error loading application:', error)); +} + +function saveApplication(event) { + event.preventDefault(); + + const formData = new FormData(applicationForm); + const application = { + datum: formData.get('datum'), + firma: formData.get('firma'), + stelle: formData.get('stelle'), + art: formData.get('art'), + status: formData.get('status'), + notizen: formData.get('notizen'), + interne_notizen: formData.get('interne_notizen'), + kommentar: formData.get('kommentar') + }; + + let url = '/api/bewerbungen'; + let method = 'POST'; + + if (currentApplicationId) { + url = `/api/bewerbungen/${currentApplicationId}`; + method = 'PUT'; + } + + fetch(url, { + method: method, + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(application) + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + hideModal(applicationModal); + resetApplicationForm(); + // Refresh the page to show updated data + location.reload(); + } + }) + .catch(error => console.error('Error saving application:', error)); +} + +function openDeleteModal(id) { + currentDeleteId = id; + showModal(deleteModal); +} + +function deleteApplication() { + if (!currentDeleteId) return; + + fetch(`/api/bewerbungen/${currentDeleteId}`, { + method: 'DELETE' + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + hideModal(deleteModal); + currentDeleteId = null; + // Refresh the page + location.reload(); + } + }) + .catch(error => console.error('Error deleting application:', error)); +} + +// ============================================ +// PDF Export +// ============================================ + +function openPdfExportModal() { + // Load PDF libraries if not already loaded + loadPdfLibraries().then(() => { + showModal(pdfExportModal); + }); +} + +function generatePDF(event) { + event.preventDefault(); + + const month = document.getElementById('pdfMonth').value; + const year = document.getElementById('pdfYear').value; + + // Fetch data for PDF + let url = '/api/export?'; + const params = []; + if (month) params.push(`month=${month}`); + if (year) params.push(`year=${year}`); + + if (params.length > 0) { + url += params.join('&') + '&'; + } + + Promise.all([ + fetch('/api/settings').then(res => res.json()), + fetch(url).then(res => res.json()) + ]) + .then(([settings, applications]) => { + generatePdfDocument(settings, applications, month, year); + hideModal(pdfExportModal); + }) + .catch(error => console.error('Error generating PDF:', error)); +} + +// PDF Generation with jsPDF +function generatePdfDocument(settings, applications, month, year) { + // Check if jsPDF is loaded + if (typeof jsPDF === 'undefined') { + console.error('jsPDF not loaded, please wait for libraries to load'); + alert('Bitte warten Sie einen Moment und versuchen Sie es erneut.'); + return; + } + + const doc = new jsPDF({ + orientation: 'portrait', + unit: 'mm', + format: 'a4' + }); + + const monthNames = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', + 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember']; + + const monthName = month ? monthNames[parseInt(month) - 1] : 'alle'; + const title = `Bewerbungsaktivitäten - ${monthName} ${year || 'Jahre'}`; + const dateStr = new Date().toLocaleDateString('de-DE'); + + // Page geometry + const pageWidth = doc.internal.pageSize.getWidth(); // 210 mm + const pageHeight = doc.internal.pageSize.getHeight(); // 297 mm + const margin = 15; + const contentWidth = pageWidth - margin * 2; // 180 mm + const bottomLimit = pageHeight - margin; + + let yPos = 20; + + // Start a new page if the next block would not fit + function ensureSpace(needed) { + if (yPos + needed > bottomLimit) { + doc.addPage(); + yPos = margin; + } + } + + // Header with user data + doc.setFont('helvetica', 'bold'); + doc.setFontSize(16); + doc.text(title, pageWidth / 2, yPos, { align: 'center' }); + yPos += 12; + + // User information + doc.setFontSize(12); + doc.setFont('helvetica', 'normal'); + if (settings && settings.name) { + doc.text(`Name: ${settings.name}`, margin, yPos); + yPos += 7; + } + if (settings && settings.adresse) { + doc.text(`Adresse: ${settings.adresse}`, margin, yPos); + yPos += 7; + } + if (settings && settings.kundennummer) { + doc.text(`Kundennummer: ${settings.kundennummer}`, margin, yPos); + yPos += 7; + } + + yPos += 5; + + // Summary text + const totalApplications = applications.length; + const summaryText = year + ? `Im Monat ${monthName} ${year} habe ich mich insgesamt auf ${totalApplications} Stellen beworben.` + : `Insgesamt habe ich mich auf ${totalApplications} Stellen beworben.`; + + doc.setFont('helvetica', 'bold'); + doc.text(summaryText, pageWidth / 2, yPos, { align: 'center' }); + + yPos += 12; + + // Group entries by status (keep this order in sync with views/index.ejs) + const statusOrder = ['Gesendet', 'Eingangsbestätigung', 'Vorstellungsgespräch', + 'Einstellung', 'Absage', 'Keine Rückmeldung']; + const groups = {}; + applications.forEach((app) => { + const key = (app.status && app.status.trim()) ? app.status.trim() : 'Ohne Status'; + (groups[key] = groups[key] || []).push(app); + }); + const orderedKeys = [ + ...statusOrder.filter((s) => groups[s]), + ...Object.keys(groups).filter((k) => !statusOrder.includes(k)) + ]; + + // One block per application — no table, so the note (which documents the + // full Verlauf) gets the entire page width and is shown completely. + orderedKeys.forEach((statusKey) => { + const group = groups[statusKey]; + + // Status group heading (dark bar), kept together with its first entry + ensureSpace(40); + doc.setFillColor(55, 65, 81); + doc.rect(margin, yPos, contentWidth, 10, 'F'); + doc.setFont('helvetica', 'bold'); + doc.setFontSize(12); + doc.setTextColor(255, 255, 255); + doc.text(`${statusKey} (${group.length})`, margin + 3, yPos + 6.8); + yPos += 14; + doc.setTextColor(0, 0, 0); + + group.forEach((app) => { + const datum = new Date(app.datum).toLocaleDateString('de-DE'); + const firma = app.firma || '—'; + const stelle = app.stelle || '—'; + const art = app.art || '—'; + const notizen = (app.notizen || '').trim(); + + // Keep the heading together with the start of its content + ensureSpace(28); + + // Heading bar: Firma – Stelle (left), Datum (right) + doc.setFillColor(225, 232, 240); + doc.rect(margin, yPos, contentWidth, 9, 'F'); + doc.setFont('helvetica', 'bold'); + doc.setFontSize(11); + doc.setTextColor(20, 20, 20); + const heading = doc.splitTextToSize(`${firma} – ${stelle}`, contentWidth - 40)[0]; + doc.text(heading, margin + 2, yPos + 6); + doc.text(datum, pageWidth - margin - 2, yPos + 6, { align: 'right' }); + yPos += 9; + + // Meta line: Art + doc.setFont('helvetica', 'normal'); + doc.setFontSize(9); + doc.setTextColor(90, 90, 90); + doc.text(`Art: ${art}`, margin + 2, yPos + 5); + yPos += 9; + + // Status-Verlauf timeline (chronological status changes with comments) + const verlauf = Array.isArray(app.verlauf) ? app.verlauf : []; + if (verlauf.length) { + doc.setFont('helvetica', 'bold'); + doc.setFontSize(10); + doc.setTextColor(20, 20, 20); + ensureSpace(6); + doc.text('Status-Verlauf:', margin + 2, yPos + 4); + yPos += 6; + + doc.setFontSize(9); + verlauf.forEach((v) => { + const vDatum = new Date(v.datum).toLocaleDateString('de-DE'); + doc.setFont('helvetica', 'bold'); + doc.setTextColor(50, 50, 50); + ensureSpace(5); + doc.text(`${vDatum} — ${v.status || ''}`, margin + 5, yPos + 4); + yPos += 5; + + const kommentar = (v.kommentar || '').trim(); + if (kommentar) { + doc.setFont('helvetica', 'normal'); + doc.setTextColor(0, 0, 0); + kommentar.split(/\r?\n/).forEach((paragraph) => { + const lines = doc.splitTextToSize(paragraph.length ? paragraph : ' ', contentWidth - 12); + lines.forEach((line) => { + ensureSpace(4.5); + doc.text(line, margin + 9, yPos + 3.5); + yPos += 4.5; + }); + }); + } + }); + yPos += 3; + } + + // Notizen label + doc.setFont('helvetica', 'bold'); + doc.setFontSize(10); + doc.setTextColor(20, 20, 20); + doc.text('Notizen:', margin + 2, yPos + 4); + yPos += 6; + + // Notizen body — full width, complete, with page breaks line by line + doc.setFont('helvetica', 'normal'); + doc.setFontSize(10); + doc.setTextColor(0, 0, 0); + const lineHeight = 5; + const noteText = notizen || '(keine Notizen)'; + noteText.split(/\r?\n/).forEach((paragraph) => { + const lines = doc.splitTextToSize(paragraph.length ? paragraph : ' ', contentWidth - 4); + lines.forEach((line) => { + ensureSpace(lineHeight); + doc.text(line, margin + 2, yPos + 4); + yPos += lineHeight; + }); + }); + + // Separator before the next entry + yPos += 4; + ensureSpace(6); + doc.setDrawColor(210, 210, 210); + doc.line(margin, yPos, pageWidth - margin, yPos); + yPos += 8; + }); + + // Extra spacing after a status group + yPos += 4; + }); + + // Footer with confirmation + ensureSpace(20); + yPos += 6; + doc.setFont('helvetica', 'italic'); + doc.setFontSize(10); + doc.setTextColor(0, 0, 0); + doc.text('Ich versichere, dass die oben genannten Angaben der Wahrheit entsprechen.', pageWidth / 2, yPos, { align: 'center' }); + yPos += 7; + doc.text(`Datum: ${dateStr}`, pageWidth / 2, yPos, { align: 'center' }); + + // Save the PDF + const fileName = `Bewerbungsaktivitaeten_${monthName}_${year || 'alle'}.pdf`; + doc.save(fileName); +} + +// Load PDF libraries dynamically +function loadPdfLibraries() { + return new Promise((resolve) => { + if (pdfLibrariesLoaded) { + resolve(); + return; + } + + // Check if already loading + if (document.getElementById('jspdf-script')) { + const checkLoaded = setInterval(() => { + if (typeof jsPDF !== 'undefined') { + clearInterval(checkLoaded); + pdfLibrariesLoaded = true; + resolve(); + } + }, 100); + return; + } + + // Load jsPDF from CDN + const script1 = document.createElement('script'); + script1.id = 'jspdf-script'; + script1.src = 'https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js'; + script1.onload = () => { + const script2 = document.createElement('script'); + script2.src = 'https://cdnjs.cloudflare.com/ajax/libs/jspdf-autotable/3.8.2/jspdf.plugin.autotable.min.js'; + script2.onload = () => { + pdfLibrariesLoaded = true; + resolve(); + }; + document.head.appendChild(script2); + }; + document.head.appendChild(script1); + }); +} + +// ============================================ +// Event Listeners +// ============================================ + +// Dark Mode Toggle +darkModeToggle.addEventListener('click', toggleDarkMode); + +// Settings Modal +settingsBtn.addEventListener('click', () => { + loadSettings(); + showModal(settingsModal); +}); +closeSettingsModal.addEventListener('click', () => hideModal(settingsModal)); +cancelSettings.addEventListener('click', () => hideModal(settingsModal)); +settingsForm.addEventListener('submit', saveSettings); + +// Application Modal +addApplicationBtn.addEventListener('click', openAddApplicationModal); +closeApplicationModal.addEventListener('click', () => { + hideModal(applicationModal); + resetApplicationForm(); +}); +cancelApplication.addEventListener('click', () => { + hideModal(applicationModal); + resetApplicationForm(); +}); +applicationForm.addEventListener('submit', saveApplication); + +// Delete Modal +closeDeleteModal.addEventListener('click', () => hideModal(deleteModal)); +cancelDelete.addEventListener('click', () => hideModal(deleteModal)); +confirmDelete.addEventListener('click', deleteApplication); + +// PDF Export Modal +exportPdfBtn.addEventListener('click', openPdfExportModal); +closePdfModal.addEventListener('click', () => hideModal(pdfExportModal)); +cancelPdfExport.addEventListener('click', () => hideModal(pdfExportModal)); +pdfExportForm.addEventListener('submit', generatePDF); + +// Filter Form +document.getElementById('filterForm').addEventListener('submit', function(e) { + e.preventDefault(); + const month = document.getElementById('filterMonth').value; + const year = document.getElementById('filterYear').value; + let url = '/'; + if (month || year) { + url += '?'; + if (month) url += `month=${month}&`; + if (year) url += `year=${year}&`; + } + window.location.href = url; +}); + +// Delete buttons (event delegation). Editing opens its own page (/bewerbung/:id). +document.addEventListener('click', (e) => { + if (e.target.closest('.delete-btn')) { + const id = e.target.closest('.delete-btn').dataset.id; + openDeleteModal(id); + } +}); + +// Close modals when clicking outside +document.addEventListener('click', (e) => { + if (e.target === settingsModal) hideModal(settingsModal); + if (e.target === applicationModal) { + hideModal(applicationModal); + resetApplicationForm(); + } + if (e.target === deleteModal) hideModal(deleteModal); + if (e.target === pdfExportModal) hideModal(pdfExportModal); +}); + +// Close modals with Escape key +document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + if (!settingsModal.classList.contains('hidden')) hideModal(settingsModal); + if (!applicationModal.classList.contains('hidden')) { + hideModal(applicationModal); + resetApplicationForm(); + } + if (!deleteModal.classList.contains('hidden')) hideModal(deleteModal); + if (!pdfExportModal.classList.contains('hidden')) hideModal(pdfExportModal); + } +}); + +// Set current year in footer +document.getElementById('currentYear').textContent = new Date().getFullYear(); + +// ============================================ +// Initialize +// ============================================ + +// Initialize dark mode +initializeDarkMode(); + +console.log('Bewerbungs-Tracker initialized'); diff --git a/server.js b/server.js index 49341da..5ad644c 100644 --- a/server.js +++ b/server.js @@ -1,234 +1,497 @@ -'use strict'; +const express = require('express'); +const sqlite3 = require('sqlite3').verbose(); +const path = require('path'); +const fs = require('fs'); -const express = require('express'); -const path = require('path'); -const fs = require('fs'); -const methodOverride = require('method-override'); -const Database = require('better-sqlite3'); - -const app = express(); +const app = express(); const PORT = process.env.PORT || 3000; -// ── Database bootstrap ──────────────────────────────────────────────────────── - -const dataDir = path.join(__dirname, 'data'); -if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true }); - -const db = new Database(path.join(dataDir, 'bewerbungen.db')); -db.pragma('journal_mode = WAL'); - -db.exec(` - CREATE TABLE IF NOT EXISTS bewerbungen ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - datum DATE NOT NULL, - firma TEXT NOT NULL, - stelle TEXT NOT NULL, - art TEXT, - status TEXT, - notizen TEXT, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP - ); - - CREATE TABLE IF NOT EXISTS settings ( - id INTEGER PRIMARY KEY CHECK (id = 1), - name TEXT, - adresse TEXT, - kundennummer TEXT - ); - - INSERT OR IGNORE INTO settings (id) VALUES (1); -`); - -// ── Constants ───────────────────────────────────────────────────────────────── - -const ART_OPTIONEN = [ - 'E-Mail', 'Online-Portal', 'Indeed', 'StepStone', 'Firmenwebsite', - 'Post', 'Initiativbewerbung', 'Arbeitsagentur', 'Sonstiges' +// Shared option lists (used in multiple views) +const ART_OPTIONS = [ + 'E-Mail', 'Online-Portal', 'Indeed', 'StepStone', + 'Firmenwebsite', 'Post', 'Initiativbewerbung', + 'Arbeitsagentur', 'Sonstiges' ]; - -const STATUS_OPTIONEN = [ +const STATUS_OPTIONS = [ 'Gesendet', 'Eingangsbestätigung', 'Vorstellungsgespräch', 'Absage', 'Einstellung', 'Keine Rückmeldung' ]; -const MONATE = [ - 'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', - 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember' -]; - -// ── Helpers ─────────────────────────────────────────────────────────────────── - -function sanitize(val, maxLen = 2000) { - if (val === null || val === undefined) return ''; - return String(val).trim().slice(0, maxLen); -} - -function safeJson(obj) { - return JSON.stringify(obj) - .replace(//g, '\\u003e') - .replace(/&/g, '\\u0026'); -} - -function getSettings() { - return db.prepare('SELECT * FROM settings WHERE id = 1').get() || {}; -} - -// ── Middleware ──────────────────────────────────────────────────────────────── - -app.use(express.urlencoded({ extended: true })); +// Middleware app.use(express.json()); -app.use(methodOverride('_method')); +app.use(express.urlencoded({ extended: true })); app.use(express.static(path.join(__dirname, 'public'))); +// Set EJS as template engine app.set('view engine', 'ejs'); app.set('views', path.join(__dirname, 'views')); -// ── Routes ──────────────────────────────────────────────────────────────────── +// Ensure data directory exists +const dataDir = path.join(__dirname, 'data'); +if (!fs.existsSync(dataDir)) { + fs.mkdirSync(dataDir, { recursive: true }); +} -// GET / – Übersicht -app.get('/', (req, res) => { - const now = new Date(); - const monat = Math.min(12, Math.max(1, parseInt(req.query.monat) || (now.getMonth() + 1))); - const jahr = parseInt(req.query.jahr) || now.getFullYear(); +// Database setup +const dbPath = path.join(dataDir, 'bewerbungen.db'); +const db = new sqlite3.Database(dbPath); - const monatStr = String(monat).padStart(2, '0'); - const vonDatum = `${jahr}-${monatStr}-01`; - const bisDatum = `${jahr}-${monatStr}-31`; +// Sanitize input to prevent XSS +function sanitizeInput(input) { + if (typeof input !== 'string') return input; + return input + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} - const bewerbungen = db.prepare(` - SELECT * FROM bewerbungen - WHERE datum BETWEEN ? AND ? - ORDER BY datum DESC, id DESC - `).all(vonDatum, bisDatum); - - // Statistics - const stats = { gesamt: bewerbungen.length, positiv: 0, absagen: 0, ausstehend: 0 }; - for (const b of bewerbungen) { - if (b.status === 'Vorstellungsgespräch' || b.status === 'Einstellung') stats.positiv++; - else if (b.status === 'Absage') stats.absagen++; - else stats.ausstehend++; - } - - // Years for filter dropdown - const dbJahre = db.prepare( - `SELECT DISTINCT strftime('%Y', datum) AS j FROM bewerbungen ORDER BY j DESC` - ).all().map(r => parseInt(r.j)).filter(Boolean); - const jahre = [...new Set([...dbJahre, now.getFullYear()])].sort((a, b) => b - a); - - res.render('index', { - bewerbungen, - bewerbungenJson: safeJson(bewerbungen), - stats, - monat, - jahr, - monate: MONATE, - jahre, - artOptionen: ART_OPTIONEN, - statusOptionen: STATUS_OPTIONEN, - settings: getSettings(), - monatName: MONATE[monat - 1], - fehler: req.query.fehler || null, - currentPage: 'uebersicht' +// Promise wrapper for db operations +function dbGet(sql, params = []) { + return new Promise((resolve, reject) => { + db.get(sql, params, (err, result) => { + if (err) reject(err); + else resolve(result); + }); }); -}); +} -// POST /bewerbungen – Neu anlegen -app.post('/bewerbungen', (req, res) => { - const { datum, firma, stelle, art, status, notizen } = req.body; - - if (!datum || !firma || !stelle) { - const d = new Date(); - return res.redirect(`/?fehler=pflichtfelder&monat=${d.getMonth() + 1}&jahr=${d.getFullYear()}`); - } - - db.prepare(` - INSERT INTO bewerbungen (datum, firma, stelle, art, status, notizen) - VALUES (?, ?, ?, ?, ?, ?) - `).run( - sanitize(datum, 20), - sanitize(firma), - sanitize(stelle), - sanitize(art), - sanitize(status), - sanitize(notizen) - ); - - const d = new Date(datum + 'T00:00:00'); - res.redirect(`/?monat=${d.getMonth() + 1}&jahr=${d.getFullYear()}`); -}); - -// PUT /bewerbungen/:id – Aktualisieren -app.put('/bewerbungen/:id', (req, res) => { - const id = parseInt(req.params.id); - const { datum, firma, stelle, art, status, notizen, monat, jahr } = req.body; - - if (!datum || !firma || !stelle) { - return res.redirect(`/?fehler=pflichtfelder&monat=${monat}&jahr=${jahr}`); - } - - db.prepare(` - UPDATE bewerbungen - SET datum=?, firma=?, stelle=?, art=?, status=?, notizen=?, updated_at=CURRENT_TIMESTAMP - WHERE id=? - `).run( - sanitize(datum, 20), - sanitize(firma), - sanitize(stelle), - sanitize(art), - sanitize(status), - sanitize(notizen), - id - ); - - res.redirect(`/?monat=${monat || 1}&jahr=${jahr || new Date().getFullYear()}`); -}); - -// DELETE /bewerbungen/:id – Löschen -app.delete('/bewerbungen/:id', (req, res) => { - db.prepare('DELETE FROM bewerbungen WHERE id=?').run(parseInt(req.params.id)); - const { monat, jahr } = req.body; - const now = new Date(); - res.redirect(`/?monat=${monat || now.getMonth() + 1}&jahr=${jahr || now.getFullYear()}`); -}); - -// GET /einstellungen -app.get('/einstellungen', (req, res) => { - res.render('einstellungen', { - settings: getSettings(), - gespeichert: req.query.gespeichert === '1', - currentPage: 'einstellungen' +function dbAll(sql, params = []) { + return new Promise((resolve, reject) => { + db.all(sql, params, (err, results) => { + if (err) reject(err); + else resolve(results); + }); }); +} + +function dbRun(sql, params = []) { + return new Promise((resolve, reject) => { + db.run(sql, params, function(err) { + if (err) reject(err); + else resolve({ lastID: this.lastID, changes: this.changes }); + }); + }); +} + +// Recompute an application's current status from its latest timeline entry +async function syncCurrentStatus(bewerbungId) { + const latest = await dbGet( + 'SELECT status FROM status_verlauf WHERE bewerbung_id = ? ORDER BY date(datum) DESC, id DESC LIMIT 1', + [bewerbungId] + ); + await dbRun( + 'UPDATE bewerbungen SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', + [latest ? latest.status : '', bewerbungId] + ); +} + +// Attach the status timeline to each application (single query, grouped in JS) +async function attachVerlauf(applications) { + if (!applications.length) return applications; + const all = await dbAll('SELECT * FROM status_verlauf ORDER BY date(datum) ASC, id ASC'); + const byApp = {}; + all.forEach((v) => { (byApp[v.bewerbung_id] = byApp[v.bewerbung_id] || []).push(v); }); + applications.forEach((a) => { a.verlauf = byApp[a.id] || []; }); + return applications; +} + +// Initialize database - create tables and default settings in one operation +function initializeDatabase() { + return new Promise((resolve, reject) => { + db.serialize(() => { + db.run('PRAGMA foreign_keys = ON'); + + // Create tables + db.run(` + CREATE TABLE IF NOT EXISTS bewerbungen ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + datum DATE NOT NULL, + firma TEXT NOT NULL, + stelle TEXT NOT NULL, + art TEXT, + status TEXT, + notizen TEXT, + interne_notizen TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `, (err) => { + if (err) return reject(err); + + // Migration: add interne_notizen to pre-existing databases (ignore "duplicate column") + db.run('ALTER TABLE bewerbungen ADD COLUMN interne_notizen TEXT', () => { + + // Chronological status changes, each with an optional comment + db.run(` + CREATE TABLE IF NOT EXISTS status_verlauf ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + bewerbung_id INTEGER NOT NULL, + datum DATE NOT NULL, + status TEXT NOT NULL, + kommentar TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (bewerbung_id) REFERENCES bewerbungen(id) ON DELETE CASCADE + ) + `, (err) => { + if (err) return reject(err); + + db.run(` + CREATE TABLE IF NOT EXISTS settings ( + id INTEGER PRIMARY KEY CHECK (id = 1), + name TEXT, + adresse TEXT, + kundennummer TEXT + ) + `, (err) => { + if (err) return reject(err); + + // Insert default settings if not exists + db.get('SELECT COUNT(*) as count FROM settings WHERE id = 1', (err, result) => { + if (err) return reject(err); + + if (result && result.count === 0) { + db.run( + 'INSERT INTO settings (id, name, adresse, kundennummer) VALUES (1, ?, ?, ?)', + ['Max Mustermann', 'Musterstraße 1, 12345 Musterstadt', ''], + (err) => { + if (err) return reject(err); + resolve(); + } + ); + } else { + resolve(); + } + }); + }); + }); + }); + }); + }); + }); +} + +// Initialize and start server +initializeDatabase().then(() => { + console.log('Database initialized successfully'); + + // Routes + app.get('/', async (req, res) => { + try { + const { month, year } = req.query; + + let query = 'SELECT * FROM bewerbungen ORDER BY datum DESC, created_at DESC'; + const params = []; + + if (month && year) { + query = 'SELECT * FROM bewerbungen WHERE strftime("%m", datum) = ? AND strftime("%Y", datum) = ? ORDER BY datum DESC, created_at DESC'; + params.push(month.padStart(2, '0'), year); + } else if (year) { + query = 'SELECT * FROM bewerbungen WHERE strftime("%Y", datum) = ? ORDER BY datum DESC, created_at DESC'; + params.push(year); + } + + const applications = await dbAll(query, params); + await attachVerlauf(applications); + const settings = await dbGet('SELECT * FROM settings WHERE id = 1'); + + // Get statistics + const totalCount = await dbGet('SELECT COUNT(*) as count FROM bewerbungen'); + const byArt = await dbAll(` + SELECT art, COUNT(*) as count FROM bewerbungen + WHERE art IS NOT NULL AND art != '' + GROUP BY art ORDER BY count DESC + `); + const byStatus = await dbAll(` + SELECT status, COUNT(*) as count FROM bewerbungen + WHERE status IS NOT NULL AND status != '' + GROUP BY status ORDER BY count DESC + `); + + // Get available months/years for filter + const availableMonths = await dbAll(` + SELECT DISTINCT strftime("%Y-%m", datum) as yearmonth, + strftime("%m", datum) as month, + strftime("%Y", datum) as year + FROM bewerbungen ORDER BY datum DESC + `); + + res.render('index', { + applications, + settings, + statistics: { + total: totalCount ? totalCount.count : 0, + byArt, + byStatus + }, + availableMonths, + currentFilter: { month, year }, + artOptions: ART_OPTIONS, + statusOptions: STATUS_OPTIONS + }); + } catch (error) { + console.error('Error:', error); + res.status(500).send('Serverfehler'); + } + }); + + // Get single application + app.get('/api/bewerbungen/:id', async (req, res) => { + try { + const { id } = req.params; + const application = await dbGet('SELECT * FROM bewerbungen WHERE id = ?', [id]); + + if (!application) { + return res.status(404).json({ error: 'Bewerbung nicht gefunden' }); + } + + res.json(application); + } catch (error) { + console.error('Error getting application:', error); + res.status(500).json({ error: 'Serverfehler' }); + } + }); + + // Get settings + app.get('/api/settings', async (req, res) => { + try { + const settings = await dbGet('SELECT * FROM settings WHERE id = 1'); + res.json(settings); + } catch (error) { + console.error('Error getting settings:', error); + res.status(500).json({ error: 'Serverfehler' }); + } + }); + + // Save settings + app.post('/api/settings', async (req, res) => { + try { + const { name, adresse, kundennummer } = req.body; + + await dbRun( + 'UPDATE settings SET name = ?, adresse = ?, kundennummer = ? WHERE id = 1', + [sanitizeInput(name), sanitizeInput(adresse), sanitizeInput(kundennummer)] + ); + + res.json({ success: true }); + } catch (error) { + console.error('Error saving settings:', error); + res.status(500).json({ error: 'Serverfehler' }); + } + }); + + // Create application + app.post('/api/bewerbungen', async (req, res) => { + try { + const { datum, firma, stelle, art, status, notizen, interne_notizen, kommentar } = req.body; + + const result = await dbRun( + 'INSERT INTO bewerbungen (datum, firma, stelle, art, status, notizen, interne_notizen) VALUES (?, ?, ?, ?, ?, ?, ?)', + [datum, sanitizeInput(firma), sanitizeInput(stelle), + sanitizeInput(art), sanitizeInput(status), sanitizeInput(notizen), sanitizeInput(interne_notizen)] + ); + + // Record the initial status as the first timeline entry + if (status && status.trim()) { + await dbRun( + 'INSERT INTO status_verlauf (bewerbung_id, datum, status, kommentar) VALUES (?, ?, ?, ?)', + [result.lastID, datum, sanitizeInput(status), sanitizeInput(kommentar || '')] + ); + } + + const newApplication = await dbGet('SELECT * FROM bewerbungen WHERE id = ?', [result.lastID]); + + res.json({ success: true, application: newApplication }); + } catch (error) { + console.error('Error creating application:', error); + res.status(500).json({ error: 'Serverfehler' }); + } + }); + + // Update application + app.put('/api/bewerbungen/:id', async (req, res) => { + try { + const { id } = req.params; + const { datum, firma, stelle, art, status, notizen } = req.body; + + await dbRun( + 'UPDATE bewerbungen SET datum = ?, firma = ?, stelle = ?, art = ?, status = ?, notizen = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', + [datum, sanitizeInput(firma), sanitizeInput(stelle), + sanitizeInput(art), sanitizeInput(status), sanitizeInput(notizen), id] + ); + + const updatedApplication = await dbGet('SELECT * FROM bewerbungen WHERE id = ?', [id]); + + res.json({ success: true, application: updatedApplication }); + } catch (error) { + console.error('Error updating application:', error); + res.status(500).json({ error: 'Serverfehler' }); + } + }); + + // Delete application + app.delete('/api/bewerbungen/:id', async (req, res) => { + try { + const { id } = req.params; + await dbRun('DELETE FROM status_verlauf WHERE bewerbung_id = ?', [id]); + await dbRun('DELETE FROM bewerbungen WHERE id = ?', [id]); + + res.json({ success: true }); + } catch (error) { + console.error('Error deleting application:', error); + res.status(500).json({ error: 'Serverfehler' }); + } + }); + + // Applications for PDF export (optionally filtered), including the status timeline + app.get('/api/export', async (req, res) => { + try { + const { month, year } = req.query; + + let query = 'SELECT * FROM bewerbungen ORDER BY datum DESC'; + const params = []; + + if (month && year) { + query = 'SELECT * FROM bewerbungen WHERE strftime("%m", datum) = ? AND strftime("%Y", datum) = ? ORDER BY datum DESC'; + params.push(month.padStart(2, '0'), year); + } else if (year) { + query = 'SELECT * FROM bewerbungen WHERE strftime("%Y", datum) = ? ORDER BY datum DESC'; + params.push(year); + } + + const applications = await dbAll(query, params); + await attachVerlauf(applications); + // Internal notes must never reach the PDF/export + applications.forEach((a) => { delete a.interne_notizen; }); + + res.json(applications); + } catch (error) { + console.error('Error exporting applications:', error); + res.status(500).json({ error: 'Serverfehler' }); + } + }); + + // ----- Dedicated edit page + status-timeline management ----- + + // Edit page for a single application + app.get('/bewerbung/:id', async (req, res) => { + try { + const { id } = req.params; + const application = await dbGet('SELECT * FROM bewerbungen WHERE id = ?', [id]); + if (!application) return res.status(404).send('Bewerbung nicht gefunden'); + + const verlauf = await dbAll( + 'SELECT * FROM status_verlauf WHERE bewerbung_id = ? ORDER BY date(datum) ASC, id ASC', + [id] + ); + + res.render('bewerbung', { + application, + verlauf, + artOptions: ART_OPTIONS, + statusOptions: STATUS_OPTIONS, + hideSettings: true + }); + } catch (error) { + console.error('Error loading edit page:', error); + res.status(500).send('Serverfehler'); + } + }); + + // Update application core data (status is managed via the timeline) + app.post('/bewerbung/:id', async (req, res) => { + try { + const { id } = req.params; + const { datum, firma, stelle, art, notizen, interne_notizen } = req.body; + + await dbRun( + 'UPDATE bewerbungen SET datum = ?, firma = ?, stelle = ?, art = ?, notizen = ?, interne_notizen = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', + [datum, sanitizeInput(firma), sanitizeInput(stelle), sanitizeInput(art), sanitizeInput(notizen), sanitizeInput(interne_notizen), id] + ); + + res.redirect('/bewerbung/' + id); + } catch (error) { + console.error('Error updating application:', error); + res.status(500).send('Serverfehler'); + } + }); + + // Add a timeline entry (status change with date + comment) + app.post('/bewerbung/:id/verlauf', async (req, res) => { + try { + const { id } = req.params; + const { datum, status, kommentar } = req.body; + + if (datum && status && status.trim()) { + await dbRun( + 'INSERT INTO status_verlauf (bewerbung_id, datum, status, kommentar) VALUES (?, ?, ?, ?)', + [id, datum, sanitizeInput(status), sanitizeInput(kommentar || '')] + ); + await syncCurrentStatus(id); + } + + res.redirect('/bewerbung/' + id); + } catch (error) { + console.error('Error adding timeline entry:', error); + res.status(500).send('Serverfehler'); + } + }); + + // Update a timeline entry + app.post('/bewerbung/:id/verlauf/:eintragId', async (req, res) => { + try { + const { id, eintragId } = req.params; + const { datum, status, kommentar } = req.body; + + if (datum && status && status.trim()) { + await dbRun( + 'UPDATE status_verlauf SET datum = ?, status = ?, kommentar = ? WHERE id = ? AND bewerbung_id = ?', + [datum, sanitizeInput(status), sanitizeInput(kommentar || ''), eintragId, id] + ); + await syncCurrentStatus(id); + } + + res.redirect('/bewerbung/' + id); + } catch (error) { + console.error('Error updating timeline entry:', error); + res.status(500).send('Serverfehler'); + } + }); + + // Delete a timeline entry + app.post('/bewerbung/:id/verlauf/:eintragId/delete', async (req, res) => { + try { + const { id, eintragId } = req.params; + await dbRun('DELETE FROM status_verlauf WHERE id = ? AND bewerbung_id = ?', [eintragId, id]); + await syncCurrentStatus(id); + res.redirect('/bewerbung/' + id); + } catch (error) { + console.error('Error deleting timeline entry:', error); + res.status(500).send('Serverfehler'); + } + }); + + // Start server + app.listen(PORT, () => { + console.log(`Server läuft auf http://localhost:${PORT}`); + }); + + // Handle 404 + app.use((req, res) => { + res.status(404).send('Seite nicht gefunden'); + }); +}).catch((err) => { + console.error('Failed to initialize database:', err); + process.exit(1); }); -// POST /einstellungen -app.post('/einstellungen', (req, res) => { - const { name, adresse, kundennummer } = req.body; - db.prepare(` - INSERT OR REPLACE INTO settings (id, name, adresse, kundennummer) - VALUES (1, ?, ?, ?) - `).run(sanitize(name), sanitize(adresse), sanitize(kundennummer)); - res.redirect('/einstellungen?gespeichert=1'); +// Close database on exit +process.on('SIGINT', () => { + db.close(); + process.exit(); }); -// GET /api/pdf-daten – JSON für clientseitige PDF-Generierung -app.get('/api/pdf-daten', (req, res) => { - const monat = Math.min(12, Math.max(1, parseInt(req.query.monat) || 1)); - const jahr = parseInt(req.query.jahr) || new Date().getFullYear(); - const ms = String(monat).padStart(2, '0'); - - const bewerbungen = db.prepare(` - SELECT * FROM bewerbungen - WHERE datum BETWEEN ? AND ? - ORDER BY datum ASC, id ASC - `).all(`${jahr}-${ms}-01`, `${jahr}-${ms}-31`); - - res.json({ bewerbungen, settings: getSettings(), monat, jahr }); -}); - -// ── Start ───────────────────────────────────────────────────────────────────── - -app.listen(PORT, () => { - console.log(`\n✓ Bewerbungs-Tracker läuft auf → http://localhost:${PORT}\n`); +process.on('SIGTERM', () => { + db.close(); + process.exit(); }); diff --git a/views/bewerbung.ejs b/views/bewerbung.ejs new file mode 100644 index 0000000..8127482 --- /dev/null +++ b/views/bewerbung.ejs @@ -0,0 +1,218 @@ + + + + <%- include('partials/head') %> + + + <%- include('partials/header', { hideSettings: true }) %> + + <% + const statusColor = { + 'Gesendet': 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900 dark:text-indigo-200', + 'Eingangsbestätigung': 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200', + 'Vorstellungsgespräch': 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200', + 'Einstellung': 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200', + 'Absage': 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200', + 'Keine Rückmeldung': 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300' + }; + const aktuellerStatus = (application.status && application.status.trim()) ? application.status.trim() : 'Ohne Status'; + const today = new Date().toISOString().split('T')[0]; + %> + +
+ + + + + + Zurück zur Übersicht + + + +
+

+ <%= application.firma %> <%= application.stelle %> +

+ + <%= aktuellerStatus %> + +
+ + +
+

Bewerbungsdaten

+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ +
+
+
+ + +
+

Status-Verlauf

+

+ Jede Statusänderung wird mit Datum dokumentiert. Der jüngste Eintrag bestimmt den aktuellen Status. +

+ + + <% if (verlauf.length > 0) { %> +
    + <% verlauf.forEach(eintrag => { %> +
  1. + +
    +
    +
    + + +
    +
    + + +
    +
    +
    + + +
    +
    + +
    +
    +
    + +
    +
  2. + <% }); %> +
+ <% } else { %> +

Noch keine Statusänderungen dokumentiert.

+ <% } %> + + +
+

Statusänderung hinzufügen

+
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ +
+
+
+
+
+ + <%- include('partials/footer') %> + + + + diff --git a/views/einstellungen.ejs b/views/einstellungen.ejs deleted file mode 100644 index 4792d39..0000000 --- a/views/einstellungen.ejs +++ /dev/null @@ -1,62 +0,0 @@ -<%- include('partials/header') %> - -
- - - - <% if (gespeichert) { %> -
- - - - Einstellungen wurden gespeichert. -
- <% } %> - -
-
- -
- - -

Erscheint als Absender und in der Unterschriftszeile des PDFs.

-
- -
- - -

Mehrzeilig – erscheint im Briefkopf des PDFs.

-
- -
- - -

Ihre Kundennummer beim Jobcenter / der Agentur für Arbeit.

-
- -
- -
-
-
- -
- PDF-Hinweis - Das exportierte PDF enthält Ihren Briefkopf mit den obigen Daten, einen Zusammenfassungssatz, - eine vollständige Bewerbungstabelle sowie eine Bestätigungs- und Unterschriftszeile. - Starten Sie den Export über „PDF exportieren" auf der Übersichtsseite. -
- -
- -<%- include('partials/footer') %> diff --git a/views/index.ejs b/views/index.ejs index 9fa4491..170254f 100644 --- a/views/index.ejs +++ b/views/index.ejs @@ -1,298 +1,609 @@ -<%- include('partials/header') %> + + + + <%- include('partials/head') %> + + + <%- include('partials/header') %> -<%# ── Embed server data for client-side use ──────────────────────────────── %> - - - -<%# ── Page header ────────────────────────────────────────────────────────── %> - - -<%# ── Error banner ───────────────────────────────────────────────────────── %> -<% if (fehler === 'pflichtfelder') { %> -
- - - - Bitte alle Pflichtfelder ausfüllen: Datum, Firma und Stelle sind erforderlich. -
-<% } %> - -<%# ── Stats grid ─────────────────────────────────────────────────────────── %> -
-
-
Gesamt
-
<%= stats.gesamt %>
-
-
-
Positiv
-
<%= stats.positiv %>
-
-
-
Absagen
-
<%= stats.absagen %>
-
-
-
Ausstehend
-
<%= stats.ausstehend %>
-
-
- -<%# ── Action bar ─────────────────────────────────────────────────────────── %> -
- <%= bewerbungen.length %> Eintr<%= bewerbungen.length === 1 ? 'ag' : 'äge' %> - -
- -<%# ── Data table ─────────────────────────────────────────────────────────── %> -
- <% if (bewerbungen.length === 0) { %> -
-
- - - -
-

Keine Bewerbungen für <%= monatName %> <%= jahr %>

-

Klicken Sie auf „Neue Bewerbung", um Ihren ersten Eintrag hinzuzufügen.

-
- <% } else { %> -
- - - - - - - - - - - - - - <% bewerbungen.forEach(b => { %> - - - - - - - - - - <% }) %> - -
DatumFirmaStelleArtStatusAktionen
- <%= b.datum ? b.datum.split('-').reverse().join('.') : '' %> - <%= b.firma %><%= b.stelle %> - <% if (b.art) { %> - <%= b.art %> - <% } %> - - <% if (b.status) { %> - <%= b.status %> - <% } %> - -
- - +
+ +
+

Filter

+
+
+ +
-
-
- <% } %> -
- -<%# ══════════════════════════════════════════════════════════════════════════ %> -<%# Modal: Neue / Bewerbung bearbeiten %> -<%# ══════════════════════════════════════════════════════════════════════════ %> - + +
+ + + + + + + + + + + + <% if (applications.length > 0) { %> + <% + // Status-Reihenfolge synchron zu public/js/main.js halten + const statusOrder = ['Gesendet', 'Eingangsbestätigung', 'Vorstellungsgespräch', + 'Einstellung', 'Absage', 'Keine Rückmeldung']; + const statusDot = { + 'Gesendet': 'bg-indigo-500', + 'Eingangsbestätigung': 'bg-blue-500', + 'Vorstellungsgespräch': 'bg-yellow-500', + 'Einstellung': 'bg-green-500', + 'Absage': 'bg-red-500', + 'Keine Rückmeldung': 'bg-gray-400', + 'Ohne Status': 'bg-gray-300' + }; + const groups = {}; + applications.forEach(a => { + const key = (a.status && a.status.trim()) ? a.status.trim() : 'Ohne Status'; + (groups[key] = groups[key] || []).push(a); + }); + const orderedKeys = [ + ...statusOrder.filter(s => groups[s]), + ...Object.keys(groups).filter(k => !statusOrder.includes(k)) + ]; + %> + <% orderedKeys.forEach(statusKey => { %> + + + + + + <% groups[statusKey].forEach(app => { %> + <% + const hatNotizen = app.notizen && app.notizen.trim().length > 0; + const hatInterne = app.interne_notizen && app.interne_notizen.trim().length > 0; + const hatVerlauf = app.verlauf && app.verlauf.length > 0; + const hatDetails = hatNotizen || hatInterne || hatVerlauf; + const padB = hatDetails ? 'pb-2' : 'pb-4'; + %> + + + + + + + + + + <% if (hatDetails) { %> + + + + <% } %> + + <% }); %> + <% }); %> + <% } else { %> + + + + + + <% } %> +
DatumFirmaStelleArtStatusAktionen
+
+ + <%= statusKey %> + <%= groups[statusKey].length %> +
+
+ <%= new Date(app.datum).toLocaleDateString('de-DE') %> + + <%= app.firma %> + + <%= app.stelle %> + + <%= app.art || '-' %> + + + <%= app.status || '-' %> + + + + + + + + +
+ <% if (hatVerlauf) { %> +
+
+ + + + Status-Verlauf +
+
    + <% app.verlauf.forEach(v => { %> +
  1. + <%= new Date(v.datum).toLocaleDateString('de-DE') %> + <%= v.status %> + <% if (v.kommentar && v.kommentar.trim()) { %> + – <%= v.kommentar %> + <% } %> +
  2. + <% }); %> +
+
+ <% } %> + <% if (hatNotizen) { %> +
+
+ + + + Notizen +
+

<%= app.notizen %>

+
+ <% } %> + <% if (hatInterne) { %> +
+
+ + + + Interne Notizen · nicht im PDF +
+

<%= app.interne_notizen %>

+
+ <% } %> +
+

Keine Bewerbungen gefunden.

+

Klicken Sie auf "Bewerbung hinzufügen", um Ihre erste Bewerbung einzutragen.

+
+
+ -<%# ══════════════════════════════════════════════════════════════════════════ %> -<%# Modal: Löschen bestätigen %> -<%# ══════════════════════════════════════════════════════════════════════════ %> -