Add AI application assistant: Indeed import + Ollama document generation
- Browser extension (Chromium MV3) injecting a "send to tracker" button next to the Indeed job description; scrapes job info and posts it to a new /api/indeed-import endpoint (CORS-enabled), configurable tracker URL via popup. - New "Entwurf" status. Imports create a draft and trigger background AI generation of tailored Anschreiben + Lebenslauf (PDF attachments) via the Ollama Cloud API, grounded strictly in user-provided base documents. - Vorlagen page to manage base documents; attachments UI, generation status polling, regenerate and download routes on the application page. - Schema: ort/stellenbeschreibung/quelle_url/generierung_* columns, plus basis_dokumente and anhaenge tables (with migrations). - Config via .env (OLLAMA_API_KEY/OLLAMA_MODEL/OLLAMA_HOST); dependency-free .env loader. Dockerfile copies lib/, .dockerignore added. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,11 @@
|
|||||||
|
node_modules
|
||||||
|
data
|
||||||
|
extension
|
||||||
|
indeed_example.txt
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
*.log
|
||||||
|
.git
|
||||||
|
.idea
|
||||||
|
.vscode
|
||||||
|
npm-debug.log*
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Kopiere diese Datei nach .env und trage deinen Schlüssel ein.
|
||||||
|
# .env wird NICHT eingecheckt (siehe .gitignore).
|
||||||
|
|
||||||
|
# Ollama Cloud API-Schlüssel – https://ollama.com/settings/keys
|
||||||
|
OLLAMA_API_KEY=
|
||||||
|
|
||||||
|
# Optional – Cloud-Modell (Standard: gpt-oss:120b)
|
||||||
|
# OLLAMA_MODEL=gpt-oss:120b
|
||||||
|
|
||||||
|
# Optional – anderer Host (z. B. lokale Ollama-Instanz: http://localhost:11434)
|
||||||
|
# OLLAMA_HOST=https://ollama.com
|
||||||
@@ -28,6 +28,7 @@ COPY --from=builder /app/node_modules ./node_modules
|
|||||||
# Application source
|
# Application source
|
||||||
COPY package.json ./
|
COPY package.json ./
|
||||||
COPY server.js ./
|
COPY server.js ./
|
||||||
|
COPY lib ./lib
|
||||||
COPY views ./views
|
COPY views ./views
|
||||||
COPY public ./public
|
COPY public ./public
|
||||||
|
|
||||||
|
|||||||
@@ -10,8 +10,47 @@ Ein professioneller Job Application Tracker für Jobcenter Grundsicherung Monats
|
|||||||
- **Filterfunktion**: Filterung nach Monat und Jahr
|
- **Filterfunktion**: Filterung nach Monat und Jahr
|
||||||
- **Statistiken**: Übersicht über Gesamtbewerbungen, nach Art und Status
|
- **Statistiken**: Übersicht über Gesamtbewerbungen, nach Art und Status
|
||||||
- **PDF-Export**: Professionelle PDF-Generierung für Jobcenter-Berichte
|
- **PDF-Export**: Professionelle PDF-Generierung für Jobcenter-Berichte
|
||||||
|
- **KI-Bewerbungsassistent**: Indeed-Stellen per Browser-Erweiterung importieren,
|
||||||
|
Status „Entwurf", und automatisch zugeschnittene Bewerbungsunterlagen (Anschreiben
|
||||||
|
& Lebenslauf als PDF) auf Basis eigener Vorlagen erstellen lassen
|
||||||
- **Responsive Design**: Optimiert für Desktop und Mobile Geräte
|
- **Responsive Design**: Optimiert für Desktop und Mobile Geräte
|
||||||
|
|
||||||
|
## KI-Bewerbungsassistent (Indeed → Entwurf mit Unterlagen)
|
||||||
|
|
||||||
|
Ablauf:
|
||||||
|
|
||||||
|
1. **Vorlagen hinterlegen** – Unter **Vorlagen** (Kopfzeile) das eigene
|
||||||
|
Standard-Anschreiben und den Lebenslauf als Text einfügen. Diese Basis-Unterlagen
|
||||||
|
sind die Faktengrundlage; die KI erfindet nichts hinzu.
|
||||||
|
2. **Stelle importieren** – Mit der Browser-Erweiterung (`extension/`) auf Indeed den
|
||||||
|
Button **„An Bewerbungs-Tracker senden"** klicken.
|
||||||
|
3. **Automatische Verarbeitung** – Der Tracker legt die Bewerbung mit Status
|
||||||
|
**Entwurf** an und generiert im Hintergrund über die **Ollama Cloud API**
|
||||||
|
zugeschnittene Bewerbungsunterlagen, die als PDF-Anhänge an der Bewerbung hängen.
|
||||||
|
|
||||||
|
### Konfiguration
|
||||||
|
|
||||||
|
Für die KI-Generierung wird ein Ollama-Cloud-API-Schlüssel benötigt
|
||||||
|
([ollama.com/settings/keys](https://ollama.com/settings/keys)). Am einfachsten über
|
||||||
|
eine (nicht eingecheckte) `.env`-Datei – kopiere `.env.example` nach `.env`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
# in .env eintragen:
|
||||||
|
# OLLAMA_API_KEY=...
|
||||||
|
# OLLAMA_MODEL=gpt-oss:120b # optional, Standard
|
||||||
|
# OLLAMA_HOST=https://ollama.com # optional; z. B. http://localhost:11434 für lokale Ollama
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
Alternativ als Umgebungsvariable: `export OLLAMA_API_KEY=...`.
|
||||||
|
|
||||||
|
Ohne Schlüssel wird die Bewerbung trotzdem als Entwurf angelegt; die Generierung
|
||||||
|
schlägt dann kontrolliert mit einem Hinweis fehl und kann später per
|
||||||
|
**„Neu generieren"** wiederholt werden.
|
||||||
|
|
||||||
|
Details zur Erweiterung: siehe [`extension/README.md`](extension/README.md).
|
||||||
|
|
||||||
## Technologien
|
## Technologien
|
||||||
|
|
||||||
- **Backend**: Node.js + Express.js
|
- **Backend**: Node.js + Express.js
|
||||||
@@ -152,6 +191,19 @@ Bewerbung aktualisieren
|
|||||||
### DELETE /api/bewerbungen/:id
|
### DELETE /api/bewerbungen/:id
|
||||||
Bewerbung löschen
|
Bewerbung löschen
|
||||||
|
|
||||||
|
### POST /api/indeed-import
|
||||||
|
Stelle importieren (von der Browser-Erweiterung genutzt): legt einen Entwurf an und
|
||||||
|
startet die KI-Generierung der Unterlagen. CORS ist für diesen Endpunkt aktiviert.
|
||||||
|
|
||||||
|
### GET /api/bewerbungen/:id/generierung
|
||||||
|
Generierungsstatus und aktuelle Anhänge einer Bewerbung abfragen (Polling).
|
||||||
|
|
||||||
|
### GET /api/basis-dokumente
|
||||||
|
Hinterlegte Basis-Unterlagen (Vorlagen) abrufen
|
||||||
|
|
||||||
|
### GET /anhaenge/:id/download
|
||||||
|
Generierten Anhang (PDF) herunterladen
|
||||||
|
|
||||||
### GET /api/bewerbungen/filter
|
### GET /api/bewerbungen/filter
|
||||||
Bewerbungen mit Filter abrufen
|
Bewerbungen mit Filter abrufen
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# Bewerbungs-Tracker – Indeed Import (Browser-Erweiterung)
|
||||||
|
|
||||||
|
Chromium-Erweiterung (Manifest V3), die auf Indeed einen Button **direkt neben der
|
||||||
|
Stellenbeschreibung** einfügt. Ein Klick sendet alle Stelleninformationen an den
|
||||||
|
Bewerbungs-Tracker. Dort wird die Stelle als **Entwurf** angelegt und die KI
|
||||||
|
erstellt automatisch zugeschnittene Bewerbungsunterlagen (auf Basis der hinterlegten
|
||||||
|
Vorlagen).
|
||||||
|
|
||||||
|
## Installation (Entwicklermodus)
|
||||||
|
|
||||||
|
1. Bewerbungs-Tracker starten (Standard: `http://localhost:3000`).
|
||||||
|
2. In Chrome/Edge/Brave `chrome://extensions` öffnen.
|
||||||
|
3. **Entwicklermodus** oben rechts aktivieren.
|
||||||
|
4. **Entpackte Erweiterung laden** klicken und diesen `extension/`-Ordner auswählen.
|
||||||
|
|
||||||
|
## Konfiguration
|
||||||
|
|
||||||
|
Auf das Erweiterungs-Symbol klicken und die **Tracker-Adresse** setzen
|
||||||
|
(z. B. `http://localhost:3000` oder die Adresse deiner Deployment-Instanz).
|
||||||
|
Mit **Verbindung testen** prüfen, ob der Tracker erreichbar ist.
|
||||||
|
|
||||||
|
## Verwendung
|
||||||
|
|
||||||
|
1. Auf `indeed.com` eine Stelle öffnen.
|
||||||
|
2. Neben der Überschrift **„Vollständige Stellenbeschreibung"** erscheint der grüne
|
||||||
|
Button **„An Bewerbungs-Tracker senden"**.
|
||||||
|
3. Klicken → die Stelle wird als Entwurf angelegt, die Unterlagen werden im
|
||||||
|
Hintergrund generiert. Über den Link „Entwurf öffnen" gelangst du direkt zur
|
||||||
|
Bewerbung im Tracker.
|
||||||
|
|
||||||
|
## Voraussetzungen im Tracker
|
||||||
|
|
||||||
|
- Unter **Vorlagen** mindestens ein Basis-Dokument (z. B. Anschreiben, Lebenslauf)
|
||||||
|
hinterlegen — diese dienen der KI als Faktengrundlage.
|
||||||
|
- `OLLAMA_API_KEY` (Ollama Cloud) gesetzt – z. B. in der `.env`-Datei des Trackers –,
|
||||||
|
damit die KI-Generierung läuft.
|
||||||
|
|
||||||
|
## Ausgelesene Felder
|
||||||
|
|
||||||
|
| Feld | Quelle (Indeed DOM) |
|
||||||
|
|------|---------------------|
|
||||||
|
| Stelle | `[data-testid="jobsearch-JobInfoHeader-title"]` |
|
||||||
|
| Firma | `[data-testid="inlineHeader-companyName"]` (im `jobsearch-CompanyInfoContainer`) |
|
||||||
|
| Ort | `[data-testid="jobsearch-JobInfoHeader-companyLocation"]` |
|
||||||
|
| Gehalt/Art | `#salaryInfoAndJobType` |
|
||||||
|
| Stellenbeschreibung | `#jobDescriptionText` |
|
||||||
|
| Quelle-URL | aus `vjk`/`jk`-Parameter bzw. aktueller URL |
|
||||||
|
|
||||||
|
Indeed ist eine Single-Page-App; die Erweiterung erkennt Wechsel der geöffneten
|
||||||
|
Stelle per `MutationObserver` und hält den Button aktuell.
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
// Service worker: performs the cross-origin POST to the Bewerbungs-Tracker.
|
||||||
|
// Routing the request through the background worker (instead of the content
|
||||||
|
// script) keeps it independent of the Indeed page's Content-Security-Policy.
|
||||||
|
|
||||||
|
const DEFAULT_TRACKER_URL = 'http://localhost:3000';
|
||||||
|
|
||||||
|
function getTrackerUrl() {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
chrome.storage.sync.get({ trackerUrl: DEFAULT_TRACKER_URL }, (items) => {
|
||||||
|
let url = (items.trackerUrl || DEFAULT_TRACKER_URL).trim();
|
||||||
|
url = url.replace(/\/+$/, ''); // strip trailing slashes
|
||||||
|
resolve(url);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||||
|
if (message && message.type === 'IMPORT_JOB') {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const base = await getTrackerUrl();
|
||||||
|
const res = await fetch(base + '/api/indeed-import', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(message.payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
let data = {};
|
||||||
|
try { data = await res.json(); } catch (_) { /* non-JSON response */ }
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
sendResponse({
|
||||||
|
ok: false,
|
||||||
|
error: (data && data.error) || `Server antwortete mit ${res.status}`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sendResponse({
|
||||||
|
ok: true,
|
||||||
|
id: data.id,
|
||||||
|
message: data.message,
|
||||||
|
openUrl: data.url ? base + data.url : null,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
sendResponse({
|
||||||
|
ok: false,
|
||||||
|
error:
|
||||||
|
'Konnte den Bewerbungs-Tracker nicht erreichen. Läuft er, und ist die ' +
|
||||||
|
'Adresse in den Erweiterungs-Einstellungen korrekt? (' + String(err.message) + ')',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return true; // keep the message channel open for the async response
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
/* Button injected next to the Indeed job description */
|
||||||
|
.bt-import-wrap {
|
||||||
|
margin: 12px 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bt-import-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
align-self: flex-start;
|
||||||
|
background: #16a34a;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15);
|
||||||
|
transition: background 0.15s ease, opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bt-import-btn:hover { background: #15803d; }
|
||||||
|
.bt-import-btn:disabled { opacity: 0.7; cursor: default; }
|
||||||
|
|
||||||
|
.bt-import-btn svg { width: 18px; height: 18px; flex: 0 0 auto; }
|
||||||
|
|
||||||
|
.bt-import-status {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.bt-import-status a { color: #2563eb; text-decoration: underline; }
|
||||||
|
.bt-import-status.bt-ok { color: #16a34a; }
|
||||||
|
.bt-import-status.bt-err { color: #dc2626; }
|
||||||
|
|
||||||
|
/* Spinner */
|
||||||
|
.bt-spin {
|
||||||
|
width: 16px; height: 16px;
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.5);
|
||||||
|
border-top-color: #fff;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: bt-rotate 0.7s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes bt-rotate { to { transform: rotate(360deg); } }
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
// Content script: injects an "An Bewerbungs-Tracker senden" button next to the
|
||||||
|
// Indeed job description, scrapes the job details and forwards them to the
|
||||||
|
// tracker's import API (via the background service worker).
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const BTN_ID = 'bt-import-container';
|
||||||
|
|
||||||
|
// ----- Scraping ---------------------------------------------------------
|
||||||
|
|
||||||
|
function cleanText(el) {
|
||||||
|
if (!el) return '';
|
||||||
|
return (el.innerText || el.textContent || '').replace(/\s+\n/g, '\n').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// The visible detail pane (right side on search pages, whole page on /viewjob).
|
||||||
|
function getDetailRoot() {
|
||||||
|
return (
|
||||||
|
document.querySelector('.jobsearch-JobComponent') ||
|
||||||
|
document.querySelector('#jobsearch-ViewjobPaneWrapper') ||
|
||||||
|
document.querySelector('.jobsearch-RightPane') ||
|
||||||
|
document
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTitle(root) {
|
||||||
|
const el =
|
||||||
|
root.querySelector('[data-testid="jobsearch-JobInfoHeader-title"]') ||
|
||||||
|
root.querySelector('.jobsearch-JobInfoHeader-title');
|
||||||
|
if (!el) return '';
|
||||||
|
// Drop the trailing "- job post" / "- Stellenanzeige" label Indeed appends.
|
||||||
|
let text = cleanText(el);
|
||||||
|
text = text.replace(/\s*-\s*(job post|Stellenanzeige)\s*$/i, '').trim();
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCompany(root) {
|
||||||
|
const container =
|
||||||
|
root.querySelector('[data-testid="jobsearch-CompanyInfoContainer"]') || root;
|
||||||
|
const el =
|
||||||
|
container.querySelector('[data-testid="inlineHeader-companyName"]') ||
|
||||||
|
container.querySelector('[data-company-name="true"]') ||
|
||||||
|
container.querySelector('[data-testid="company-name"]');
|
||||||
|
return cleanText(el);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLocation(root) {
|
||||||
|
const el =
|
||||||
|
root.querySelector('[data-testid="jobsearch-JobInfoHeader-companyLocation"]') ||
|
||||||
|
root.querySelector('[data-testid="inlineHeader-companyLocation"]');
|
||||||
|
return cleanText(el);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSalary(root) {
|
||||||
|
const el =
|
||||||
|
root.querySelector('#salaryInfoAndJobType') ||
|
||||||
|
root.querySelector('[data-testid="jobsearch-OtherJobDetailsContainer"]');
|
||||||
|
return cleanText(el);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDescription(root) {
|
||||||
|
const el = root.querySelector('#jobDescriptionText');
|
||||||
|
return cleanText(el);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a stable job URL. On the search page the selected job key lives in the
|
||||||
|
// `vjk` query param; a clean /viewjob URL is nicer than the search URL.
|
||||||
|
function getSourceUrl() {
|
||||||
|
try {
|
||||||
|
const u = new URL(window.location.href);
|
||||||
|
const vjk = u.searchParams.get('vjk') || u.searchParams.get('jk');
|
||||||
|
if (vjk) return `${u.origin}/viewjob?jk=${vjk}`;
|
||||||
|
} catch (_) { /* ignore */ }
|
||||||
|
return window.location.href;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrapeJob() {
|
||||||
|
const root = getDetailRoot();
|
||||||
|
return {
|
||||||
|
firma: getCompany(root),
|
||||||
|
stelle: getTitle(root),
|
||||||
|
ort: getLocation(root),
|
||||||
|
gehalt: getSalary(root),
|
||||||
|
stellenbeschreibung: getDescription(root),
|
||||||
|
quelle_url: getSourceUrl(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- Button UI --------------------------------------------------------
|
||||||
|
|
||||||
|
const ICON_SEND =
|
||||||
|
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ' +
|
||||||
|
'stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13">' +
|
||||||
|
'</line><polygon points="22 2 15 22 11 13 2 9 22 2"></polygon></svg>';
|
||||||
|
|
||||||
|
function buildContainer() {
|
||||||
|
const wrap = document.createElement('div');
|
||||||
|
wrap.className = 'bt-import-wrap';
|
||||||
|
wrap.id = BTN_ID;
|
||||||
|
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.type = 'button';
|
||||||
|
btn.className = 'bt-import-btn';
|
||||||
|
btn.innerHTML = ICON_SEND + '<span>An Bewerbungs-Tracker senden</span>';
|
||||||
|
|
||||||
|
const status = document.createElement('div');
|
||||||
|
status.className = 'bt-import-status';
|
||||||
|
|
||||||
|
btn.addEventListener('click', () => onClick(btn, status));
|
||||||
|
|
||||||
|
wrap.appendChild(btn);
|
||||||
|
wrap.appendChild(status);
|
||||||
|
return wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStatus(status, html, cls) {
|
||||||
|
status.className = 'bt-import-status' + (cls ? ' ' + cls : '');
|
||||||
|
status.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onClick(btn, status) {
|
||||||
|
const job = scrapeJob();
|
||||||
|
|
||||||
|
if (!job.firma || !job.stelle) {
|
||||||
|
setStatus(status, 'Konnte Firma oder Stelle nicht auslesen. Bitte eine Stelle öffnen.', 'bt-err');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.disabled = true;
|
||||||
|
const label = btn.querySelector('span');
|
||||||
|
const originalLabel = label ? label.textContent : '';
|
||||||
|
btn.innerHTML = '<span class="bt-spin"></span><span>Wird gesendet…</span>';
|
||||||
|
setStatus(status, '', '');
|
||||||
|
|
||||||
|
chrome.runtime.sendMessage({ type: 'IMPORT_JOB', payload: job }, (resp) => {
|
||||||
|
btn.innerHTML = ICON_SEND + '<span>' + (originalLabel || 'An Bewerbungs-Tracker senden') + '</span>';
|
||||||
|
|
||||||
|
if (chrome.runtime.lastError) {
|
||||||
|
btn.disabled = false;
|
||||||
|
setStatus(status, 'Fehler: ' + chrome.runtime.lastError.message, 'bt-err');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!resp || !resp.ok) {
|
||||||
|
btn.disabled = false;
|
||||||
|
setStatus(status, (resp && resp.error) || 'Unbekannter Fehler.', 'bt-err');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const link = resp.openUrl
|
||||||
|
? ` <a href="${resp.openUrl}" target="_blank" rel="noopener">Entwurf öffnen</a>`
|
||||||
|
: '';
|
||||||
|
setStatus(
|
||||||
|
status,
|
||||||
|
'✓ ' + (resp.message || 'Als Entwurf angelegt. Unterlagen werden erstellt.') + link,
|
||||||
|
'bt-ok'
|
||||||
|
);
|
||||||
|
// Re-enable after a moment so the user can send again if needed.
|
||||||
|
setTimeout(() => { btn.disabled = false; }, 1500);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- Injection + SPA handling -----------------------------------------
|
||||||
|
|
||||||
|
// Insert the button just above the job description text, so it sits right
|
||||||
|
// next to the "Vollständige Stellenbeschreibung" heading.
|
||||||
|
function findAnchor() {
|
||||||
|
const heading = document.querySelector('#jobDescriptionTitleHeading');
|
||||||
|
if (heading) return { node: heading, position: 'after' };
|
||||||
|
const desc = document.querySelector('#jobDescriptionText');
|
||||||
|
if (desc) return { node: desc, position: 'before' };
|
||||||
|
const titleContainer = document.querySelector('.jobsearch-JobInfoHeader-title-container');
|
||||||
|
if (titleContainer) return { node: titleContainer, position: 'after' };
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function inject() {
|
||||||
|
const anchor = findAnchor();
|
||||||
|
if (!anchor) return;
|
||||||
|
|
||||||
|
const existing = document.getElementById(BTN_ID);
|
||||||
|
// Re-inject if missing or detached from the current anchor's parent.
|
||||||
|
if (existing && existing.parentNode === anchor.node.parentNode) return;
|
||||||
|
if (existing) existing.remove();
|
||||||
|
|
||||||
|
const container = buildContainer();
|
||||||
|
if (anchor.position === 'after') {
|
||||||
|
anchor.node.parentNode.insertBefore(container, anchor.node.nextSibling);
|
||||||
|
} else {
|
||||||
|
anchor.node.parentNode.insertBefore(container, anchor.node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Indeed is a single-page app: the detail pane swaps without a full reload.
|
||||||
|
// Observe DOM changes and (debounced) re-inject / keep the button in place.
|
||||||
|
let debounce = null;
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
if (debounce) clearTimeout(debounce);
|
||||||
|
debounce = setTimeout(inject, 300);
|
||||||
|
});
|
||||||
|
|
||||||
|
function start() {
|
||||||
|
inject();
|
||||||
|
observer.observe(document.body, { childList: true, subtree: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.body) {
|
||||||
|
start();
|
||||||
|
} else {
|
||||||
|
document.addEventListener('DOMContentLoaded', start);
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"manifest_version": 3,
|
||||||
|
"name": "Bewerbungs-Tracker – Indeed Import",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Fügt auf Indeed einen Button neben der Stellenbeschreibung ein und sendet die Stelle an den Bewerbungs-Tracker, der automatisch zugeschnittene Bewerbungsunterlagen erstellt.",
|
||||||
|
"permissions": ["storage"],
|
||||||
|
"host_permissions": [
|
||||||
|
"*://*.indeed.com/*",
|
||||||
|
"http://localhost/*",
|
||||||
|
"http://127.0.0.1/*",
|
||||||
|
"http://*/*",
|
||||||
|
"https://*/*"
|
||||||
|
],
|
||||||
|
"content_scripts": [
|
||||||
|
{
|
||||||
|
"matches": ["*://*.indeed.com/*"],
|
||||||
|
"js": ["content.js"],
|
||||||
|
"css": ["content.css"],
|
||||||
|
"run_at": "document_idle"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"background": {
|
||||||
|
"service_worker": "background.js"
|
||||||
|
},
|
||||||
|
"action": {
|
||||||
|
"default_popup": "popup.html",
|
||||||
|
"default_title": "Bewerbungs-Tracker"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
width: 320px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 16px;
|
||||||
|
color: #1f2937;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
h1 { font-size: 15px; margin: 0 0 4px; }
|
||||||
|
p.hint { font-size: 12px; color: #6b7280; margin: 0 0 12px; }
|
||||||
|
label { display: block; font-size: 12px; font-weight: 600; margin-bottom: 4px; }
|
||||||
|
input {
|
||||||
|
width: 100%; box-sizing: border-box;
|
||||||
|
padding: 8px 10px; font-size: 13px;
|
||||||
|
border: 1px solid #d1d5db; border-radius: 6px;
|
||||||
|
}
|
||||||
|
.row { display: flex; gap: 8px; margin-top: 12px; }
|
||||||
|
button {
|
||||||
|
flex: 1; padding: 8px 10px; font-size: 13px; font-weight: 600;
|
||||||
|
border: none; border-radius: 6px; cursor: pointer;
|
||||||
|
}
|
||||||
|
.save { background: #2563eb; color: #fff; }
|
||||||
|
.save:hover { background: #1d4ed8; }
|
||||||
|
.test { background: #e5e7eb; color: #1f2937; }
|
||||||
|
.test:hover { background: #d1d5db; }
|
||||||
|
#status { font-size: 12px; margin-top: 10px; min-height: 16px; }
|
||||||
|
.ok { color: #16a34a; }
|
||||||
|
.err { color: #dc2626; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Bewerbungs-Tracker</h1>
|
||||||
|
<p class="hint">Adresse deines Bewerbungs-Trackers. Von hier werden importierte Indeed-Stellen verarbeitet.</p>
|
||||||
|
|
||||||
|
<label for="trackerUrl">Tracker-Adresse</label>
|
||||||
|
<input type="url" id="trackerUrl" placeholder="http://localhost:3000" />
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<button class="save" id="saveBtn">Speichern</button>
|
||||||
|
<button class="test" id="testBtn">Verbindung testen</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="status"></div>
|
||||||
|
|
||||||
|
<script src="popup.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
const DEFAULT_TRACKER_URL = 'http://localhost:3000';
|
||||||
|
|
||||||
|
const input = document.getElementById('trackerUrl');
|
||||||
|
const statusEl = document.getElementById('status');
|
||||||
|
|
||||||
|
function normalize(url) {
|
||||||
|
return (url || '').trim().replace(/\/+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStatus(text, cls) {
|
||||||
|
statusEl.textContent = text;
|
||||||
|
statusEl.className = cls || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load stored value
|
||||||
|
chrome.storage.sync.get({ trackerUrl: DEFAULT_TRACKER_URL }, (items) => {
|
||||||
|
input.value = items.trackerUrl || DEFAULT_TRACKER_URL;
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('saveBtn').addEventListener('click', () => {
|
||||||
|
const url = normalize(input.value) || DEFAULT_TRACKER_URL;
|
||||||
|
chrome.storage.sync.set({ trackerUrl: url }, () => {
|
||||||
|
input.value = url;
|
||||||
|
setStatus('Gespeichert.', 'ok');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('testBtn').addEventListener('click', async () => {
|
||||||
|
const url = normalize(input.value) || DEFAULT_TRACKER_URL;
|
||||||
|
setStatus('Teste Verbindung…', '');
|
||||||
|
try {
|
||||||
|
const res = await fetch(url + '/api/settings', { method: 'GET' });
|
||||||
|
if (res.ok) {
|
||||||
|
setStatus('Verbindung erfolgreich ✓', 'ok');
|
||||||
|
} else {
|
||||||
|
setStatus('Erreichbar, aber unerwartete Antwort (' + res.status + ').', 'err');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setStatus('Nicht erreichbar: ' + err.message, 'err');
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
// Document generation: tailors application documents to a job description with
|
||||||
|
// the help of an LLM (Ollama Cloud), then renders them to PDF.
|
||||||
|
//
|
||||||
|
// The AI *rewrites* the user's own, previously provided base documents
|
||||||
|
// (Basis-Dokumente) so the result stays grounded in real facts — it must not
|
||||||
|
// invent experience the applicant doesn't have.
|
||||||
|
|
||||||
|
const { jsPDF } = require('jspdf');
|
||||||
|
|
||||||
|
// Ollama Cloud API (https://ollama.com). Override host/model via env if needed
|
||||||
|
// (e.g. point OLLAMA_HOST at a local Ollama instance).
|
||||||
|
const OLLAMA_HOST = (process.env.OLLAMA_HOST || 'https://ollama.com').replace(/\/+$/, '');
|
||||||
|
const OLLAMA_MODEL = process.env.OLLAMA_MODEL || 'gpt-oss:120b';
|
||||||
|
const OLLAMA_TIMEOUT_MS = Number(process.env.OLLAMA_TIMEOUT_MS || 180000);
|
||||||
|
|
||||||
|
// ----- Ollama call ---------------------------------------------------------
|
||||||
|
|
||||||
|
// Returns { anschreiben: string, lebenslauf: string }.
|
||||||
|
// Throws if no API key is configured or the API call fails.
|
||||||
|
async function generateTailoredTexts({ job, basisDokumente, settings }) {
|
||||||
|
const apiKey = process.env.OLLAMA_API_KEY;
|
||||||
|
if (!apiKey) {
|
||||||
|
throw new Error(
|
||||||
|
'OLLAMA_API_KEY ist nicht gesetzt. Bitte den API-Schlüssel als ' +
|
||||||
|
'Umgebungsvariable (z. B. in einer .env-Datei) hinterlegen, damit ' +
|
||||||
|
'Bewerbungsunterlagen generiert werden können.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!basisDokumente || basisDokumente.length === 0) {
|
||||||
|
throw new Error(
|
||||||
|
'Es sind keine Basis-Unterlagen hinterlegt. Bitte zuerst unter "Vorlagen" ' +
|
||||||
|
'mindestens ein Basis-Dokument (z. B. Anschreiben und Lebenslauf) bereitstellen.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bewerber = [
|
||||||
|
settings && settings.name ? `Name: ${settings.name}` : null,
|
||||||
|
settings && settings.adresse ? `Adresse: ${settings.adresse}` : null,
|
||||||
|
].filter(Boolean).join('\n') || 'Keine Angaben';
|
||||||
|
|
||||||
|
const basisText = basisDokumente
|
||||||
|
.map((d, i) => `### Basis-Dokument ${i + 1} — Typ: ${d.typ || 'Sonstiges'} (${d.name || 'ohne Titel'})\n${d.inhalt}`)
|
||||||
|
.join('\n\n');
|
||||||
|
|
||||||
|
const stelleText = [
|
||||||
|
job.stelle ? `Stellenbezeichnung: ${job.stelle}` : null,
|
||||||
|
job.firma ? `Unternehmen: ${job.firma}` : null,
|
||||||
|
job.ort ? `Ort: ${job.ort}` : null,
|
||||||
|
job.gehalt ? `Gehalt/Konditionen: ${job.gehalt}` : null,
|
||||||
|
job.quelle_url ? `Quelle: ${job.quelle_url}` : null,
|
||||||
|
'',
|
||||||
|
'Stellenbeschreibung:',
|
||||||
|
job.stellenbeschreibung || '(keine Beschreibung übermittelt)',
|
||||||
|
].filter((l) => l !== null).join('\n');
|
||||||
|
|
||||||
|
const system =
|
||||||
|
'Du bist ein erfahrener Bewerbungscoach und schreibst professionelle, ' +
|
||||||
|
'deutschsprachige Bewerbungsunterlagen. Du passt die BEREITGESTELLTEN ' +
|
||||||
|
'Basis-Unterlagen des Bewerbers auf eine konkrete Stellenausschreibung an. ' +
|
||||||
|
'Wichtigste Regel: Du erfindest KEINE Fakten, Qualifikationen, Abschlüsse ' +
|
||||||
|
'oder Berufserfahrungen. Verwende ausschließlich Informationen, die in den ' +
|
||||||
|
'Basis-Unterlagen des Bewerbers stehen. Du darfst umformulieren, ' +
|
||||||
|
'gewichten, relevante Punkte hervorheben und auf die Stelle zuschneiden — ' +
|
||||||
|
'aber nichts hinzudichten. Schreibe natürlich, überzeugend und ohne Floskeln.';
|
||||||
|
|
||||||
|
const userPrompt =
|
||||||
|
`# Bewerberdaten\n${bewerber}\n\n` +
|
||||||
|
`# Basis-Unterlagen des Bewerbers (Faktengrundlage — NUR diese Fakten verwenden)\n${basisText}\n\n` +
|
||||||
|
`# Zielstelle\n${stelleText}\n\n` +
|
||||||
|
`# Aufgabe\n` +
|
||||||
|
`Erstelle zwei zugeschnittene Dokumente:\n` +
|
||||||
|
`1. "anschreiben": Ein vollständiges, individuelles Anschreiben für genau diese Stelle. ` +
|
||||||
|
`Beginne mit einer passenden Anrede und schließe mit einer Grußformel und dem Namen des Bewerbers. ` +
|
||||||
|
`Betone die zur Stellenbeschreibung passenden Stärken aus den Basis-Unterlagen. ` +
|
||||||
|
`Keine Platzhalter wie [Firma] — nutze die echten Angaben. Kein Briefkopf/Adressblock, nur der Brieftext.\n` +
|
||||||
|
`2. "lebenslauf": Eine auf die Stelle zugeschnittene, gut strukturierte Fassung des Lebenslaufs ` +
|
||||||
|
`(relevante Stationen und Kompetenzen zuerst), rein auf Basis der vorhandenen Angaben. ` +
|
||||||
|
`Falls keine Lebenslauf-Informationen in den Basis-Unterlagen vorhanden sind, gib einen leeren String zurück.\n` +
|
||||||
|
`Antworte ausschließlich im geforderten JSON-Format.`;
|
||||||
|
|
||||||
|
// Structured output: ask Ollama to return exactly this JSON shape.
|
||||||
|
const format = {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
anschreiben: { type: 'string' },
|
||||||
|
lebenslauf: { type: 'string' },
|
||||||
|
},
|
||||||
|
required: ['anschreiben', 'lebenslauf'],
|
||||||
|
};
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), OLLAMA_TIMEOUT_MS);
|
||||||
|
|
||||||
|
let res;
|
||||||
|
try {
|
||||||
|
res = await fetch(`${OLLAMA_HOST}/api/chat`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${apiKey}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: OLLAMA_MODEL,
|
||||||
|
stream: false,
|
||||||
|
format,
|
||||||
|
options: { temperature: 0.4 },
|
||||||
|
messages: [
|
||||||
|
{ role: 'system', content: system },
|
||||||
|
{ role: 'user', content: userPrompt },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (err.name === 'AbortError') {
|
||||||
|
throw new Error(`Zeitüberschreitung bei der KI-Anfrage (> ${Math.round(OLLAMA_TIMEOUT_MS / 1000)}s).`);
|
||||||
|
}
|
||||||
|
throw new Error(`Verbindung zur Ollama-API fehlgeschlagen: ${err.message}`);
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.text().catch(() => '');
|
||||||
|
throw new Error(`Ollama-API antwortete mit ${res.status}: ${body.slice(0, 300)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
const text = ((data && data.message && data.message.content) || '').trim();
|
||||||
|
if (!text) {
|
||||||
|
throw new Error('Die KI hat keine Antwort geliefert (leerer Inhalt).');
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(text);
|
||||||
|
} catch (e) {
|
||||||
|
// Fallback: try to extract the first JSON object; otherwise treat the
|
||||||
|
// whole answer as the cover letter so nothing is lost.
|
||||||
|
const match = text.match(/\{[\s\S]*\}/);
|
||||||
|
if (match) {
|
||||||
|
try { parsed = JSON.parse(match[0]); } catch (_) { /* ignore */ }
|
||||||
|
}
|
||||||
|
if (!parsed) parsed = { anschreiben: text, lebenslauf: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
anschreiben: (parsed.anschreiben || '').trim(),
|
||||||
|
lebenslauf: (parsed.lebenslauf || '').trim(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- PDF rendering -------------------------------------------------------
|
||||||
|
|
||||||
|
// Renders a text document to a PDF Buffer with a simple, clean A4 layout.
|
||||||
|
function textToPdf({ title, headerLines = [], bodyText = '' }) {
|
||||||
|
const doc = new jsPDF({ unit: 'mm', format: 'a4' });
|
||||||
|
const pageWidth = doc.internal.pageSize.getWidth();
|
||||||
|
const pageHeight = doc.internal.pageSize.getHeight();
|
||||||
|
const marginX = 20;
|
||||||
|
const marginTop = 20;
|
||||||
|
const marginBottom = 20;
|
||||||
|
const maxWidth = pageWidth - marginX * 2;
|
||||||
|
let y = marginTop;
|
||||||
|
|
||||||
|
function ensureSpace(lineHeight) {
|
||||||
|
if (y + lineHeight > pageHeight - marginBottom) {
|
||||||
|
doc.addPage();
|
||||||
|
y = marginTop;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeBlock(text, { fontSize = 11, fontStyle = 'normal', gapAfter = 4, lineHeight } = {}) {
|
||||||
|
doc.setFont('helvetica', fontStyle);
|
||||||
|
doc.setFontSize(fontSize);
|
||||||
|
const lh = lineHeight || fontSize * 0.5; // mm per line
|
||||||
|
// Preserve intentional blank lines from the source text.
|
||||||
|
const paragraphs = String(text).split('\n');
|
||||||
|
paragraphs.forEach((para) => {
|
||||||
|
if (para.trim() === '') {
|
||||||
|
ensureSpace(lh);
|
||||||
|
y += lh;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const lines = doc.splitTextToSize(para, maxWidth);
|
||||||
|
lines.forEach((line) => {
|
||||||
|
ensureSpace(lh);
|
||||||
|
doc.text(line, marginX, y);
|
||||||
|
y += lh;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
y += gapAfter;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (title) {
|
||||||
|
writeBlock(title, { fontSize: 16, fontStyle: 'bold', gapAfter: 6 });
|
||||||
|
}
|
||||||
|
if (headerLines.length) {
|
||||||
|
writeBlock(headerLines.join('\n'), { fontSize: 10, fontStyle: 'normal', gapAfter: 8 });
|
||||||
|
}
|
||||||
|
if (bodyText) {
|
||||||
|
writeBlock(bodyText, { fontSize: 11, fontStyle: 'normal', gapAfter: 4, lineHeight: 6 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const arrayBuffer = doc.output('arraybuffer');
|
||||||
|
return Buffer.from(arrayBuffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the list of attachment documents (PDF buffers) for a job.
|
||||||
|
// Returns [{ name, filename, mime, buffer }].
|
||||||
|
async function generateApplicationDocuments({ job, basisDokumente, settings }) {
|
||||||
|
const { anschreiben, lebenslauf } = await generateTailoredTexts({ job, basisDokumente, settings });
|
||||||
|
|
||||||
|
const today = new Date().toLocaleDateString('de-DE');
|
||||||
|
const bewerberName = (settings && settings.name) || '';
|
||||||
|
const bewerberAdresse = (settings && settings.adresse) || '';
|
||||||
|
const safe = (s) => String(s || '').replace(/[^a-zA-Z0-9äöüÄÖÜß _-]/g, '').replace(/\s+/g, '_').slice(0, 60) || 'Bewerbung';
|
||||||
|
const suffix = safe(job.firma || job.stelle || 'Bewerbung');
|
||||||
|
|
||||||
|
const documents = [];
|
||||||
|
|
||||||
|
if (anschreiben) {
|
||||||
|
const headerLines = [
|
||||||
|
bewerberName,
|
||||||
|
bewerberAdresse,
|
||||||
|
'',
|
||||||
|
job.firma || '',
|
||||||
|
job.ort || '',
|
||||||
|
'',
|
||||||
|
today,
|
||||||
|
'',
|
||||||
|
`Bewerbung als ${job.stelle || ''}`.trim(),
|
||||||
|
].filter((l) => l !== null);
|
||||||
|
documents.push({
|
||||||
|
name: `Anschreiben – ${job.stelle || job.firma || ''}`.trim(),
|
||||||
|
filename: `Anschreiben_${suffix}.pdf`,
|
||||||
|
mime: 'application/pdf',
|
||||||
|
buffer: textToPdf({ title: '', headerLines, bodyText: anschreiben }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lebenslauf) {
|
||||||
|
const headerLines = [bewerberName, bewerberAdresse].filter(Boolean);
|
||||||
|
documents.push({
|
||||||
|
name: `Lebenslauf – ${job.stelle || job.firma || ''}`.trim(),
|
||||||
|
filename: `Lebenslauf_${suffix}.pdf`,
|
||||||
|
mime: 'application/pdf',
|
||||||
|
buffer: textToPdf({ title: 'Lebenslauf', headerLines, bodyText: lebenslauf }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (documents.length === 0) {
|
||||||
|
throw new Error('Die KI hat keine verwertbaren Unterlagen erzeugt.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return documents;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { generateApplicationDocuments, generateTailoredTexts, textToPdf };
|
||||||
+2
-1
@@ -353,10 +353,11 @@ function generatePdfDocument(settings, applications, month, year) {
|
|||||||
yPos += 6;
|
yPos += 6;
|
||||||
|
|
||||||
// ---- Group entries by status (order kept in sync with views/index.ejs) ----
|
// ---- Group entries by status (order kept in sync with views/index.ejs) ----
|
||||||
const statusOrder = ['Gesendet', 'Eingangsbestätigung', 'Vorstellungsgespräch',
|
const statusOrder = ['Entwurf', 'Gesendet', 'Eingangsbestätigung', 'Vorstellungsgespräch',
|
||||||
'Einstellung', 'Absage', 'Keine Rückmeldung'];
|
'Einstellung', 'Absage', 'Keine Rückmeldung'];
|
||||||
// Accent colour per status (RGB) – reused by the chart and the list headings
|
// Accent colour per status (RGB) – reused by the chart and the list headings
|
||||||
const statusColors = {
|
const statusColors = {
|
||||||
|
'Entwurf': [168, 85, 247],
|
||||||
'Gesendet': [59, 130, 246],
|
'Gesendet': [59, 130, 246],
|
||||||
'Eingangsbestätigung': [14, 165, 233],
|
'Eingangsbestätigung': [14, 165, 233],
|
||||||
'Vorstellungsgespräch': [245, 158, 11],
|
'Vorstellungsgespräch': [245, 158, 11],
|
||||||
|
|||||||
@@ -3,6 +3,31 @@ const sqlite3 = require('sqlite3').verbose();
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
|
|
||||||
|
// Minimal, dependency-free .env loader: load KEY=VALUE lines from a local
|
||||||
|
// (git-ignored) .env file into process.env without overwriting existing vars.
|
||||||
|
(function loadEnv() {
|
||||||
|
try {
|
||||||
|
const envPath = path.join(__dirname, '.env');
|
||||||
|
if (!fs.existsSync(envPath)) return;
|
||||||
|
for (const raw of fs.readFileSync(envPath, 'utf8').split('\n')) {
|
||||||
|
const line = raw.trim();
|
||||||
|
if (!line || line.startsWith('#')) continue;
|
||||||
|
const eq = line.indexOf('=');
|
||||||
|
if (eq === -1) continue;
|
||||||
|
const key = line.slice(0, eq).trim();
|
||||||
|
let val = line.slice(eq + 1).trim();
|
||||||
|
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
|
||||||
|
val = val.slice(1, -1);
|
||||||
|
}
|
||||||
|
if (key && !(key in process.env)) process.env[key] = val;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Konnte .env nicht laden:', e.message);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
const { generateApplicationDocuments } = require('./lib/documents');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3000;
|
const PORT = process.env.PORT || 3000;
|
||||||
|
|
||||||
@@ -13,15 +38,27 @@ const ART_OPTIONS = [
|
|||||||
'Arbeitsagentur', 'Sonstiges'
|
'Arbeitsagentur', 'Sonstiges'
|
||||||
];
|
];
|
||||||
const STATUS_OPTIONS = [
|
const STATUS_OPTIONS = [
|
||||||
'Gesendet', 'Eingangsbestätigung', 'Vorstellungsgespräch',
|
'Entwurf', 'Gesendet', 'Eingangsbestätigung', 'Vorstellungsgespräch',
|
||||||
'Absage', 'Einstellung', 'Keine Rückmeldung'
|
'Absage', 'Einstellung', 'Keine Rückmeldung'
|
||||||
];
|
];
|
||||||
|
// Base document types the user can provide as a foundation for AI tailoring
|
||||||
|
const BASIS_TYP_OPTIONS = ['Anschreiben', 'Lebenslauf', 'Profil/Kurzprofil', 'Sonstiges'];
|
||||||
|
|
||||||
// Middleware
|
// Middleware
|
||||||
app.use(express.json());
|
app.use(express.json({ limit: '2mb' }));
|
||||||
app.use(express.urlencoded({ extended: true }));
|
app.use(express.urlencoded({ extended: true, limit: '2mb' }));
|
||||||
app.use(express.static(path.join(__dirname, 'public')));
|
app.use(express.static(path.join(__dirname, 'public')));
|
||||||
|
|
||||||
|
// Allow the browser extension (running on indeed.com) to call the import API.
|
||||||
|
// Kept narrow: only the extension-facing endpoints need cross-origin access.
|
||||||
|
app.use('/api/indeed-import', (req, res, next) => {
|
||||||
|
res.header('Access-Control-Allow-Origin', '*');
|
||||||
|
res.header('Access-Control-Allow-Methods', 'POST, OPTIONS');
|
||||||
|
res.header('Access-Control-Allow-Headers', 'Content-Type');
|
||||||
|
if (req.method === 'OPTIONS') return res.sendStatus(204);
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
// Set EJS as template engine
|
// Set EJS as template engine
|
||||||
app.set('view engine', 'ejs');
|
app.set('view engine', 'ejs');
|
||||||
app.set('views', path.join(__dirname, 'views'));
|
app.set('views', path.join(__dirname, 'views'));
|
||||||
@@ -32,6 +69,12 @@ if (!fs.existsSync(dataDir)) {
|
|||||||
fs.mkdirSync(dataDir, { recursive: true });
|
fs.mkdirSync(dataDir, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Directory for generated attachment files (application documents)
|
||||||
|
const anhaengeDir = path.join(dataDir, 'anhaenge');
|
||||||
|
if (!fs.existsSync(anhaengeDir)) {
|
||||||
|
fs.mkdirSync(anhaengeDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
// Database setup
|
// Database setup
|
||||||
const dbPath = path.join(dataDir, 'bewerbungen.db');
|
const dbPath = path.join(dataDir, 'bewerbungen.db');
|
||||||
const db = new sqlite3.Database(dbPath);
|
const db = new sqlite3.Database(dbPath);
|
||||||
@@ -96,6 +139,51 @@ async function attachVerlauf(applications) {
|
|||||||
return applications;
|
return applications;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Run the AI document generation for one application (async, fire-and-forget).
|
||||||
|
// Loads the base documents + user settings, asks the LLM to tailor them to the
|
||||||
|
// job, writes the resulting PDFs to disk and links them as attachments.
|
||||||
|
async function runGeneration(bewerbungId) {
|
||||||
|
try {
|
||||||
|
const bewerbung = await dbGet('SELECT * FROM bewerbungen WHERE id = ?', [bewerbungId]);
|
||||||
|
if (!bewerbung) return;
|
||||||
|
const basisDokumente = await dbAll('SELECT * FROM basis_dokumente ORDER BY id ASC');
|
||||||
|
const settings = await dbGet('SELECT * FROM settings WHERE id = 1');
|
||||||
|
|
||||||
|
const documents = await generateApplicationDocuments({
|
||||||
|
job: {
|
||||||
|
firma: bewerbung.firma,
|
||||||
|
stelle: bewerbung.stelle,
|
||||||
|
ort: bewerbung.ort,
|
||||||
|
quelle_url: bewerbung.quelle_url,
|
||||||
|
stellenbeschreibung: bewerbung.stellenbeschreibung,
|
||||||
|
},
|
||||||
|
basisDokumente,
|
||||||
|
settings,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const doc of documents) {
|
||||||
|
const stored = `${bewerbungId}_${Date.now()}_${doc.filename}`;
|
||||||
|
fs.writeFileSync(path.join(anhaengeDir, stored), doc.buffer);
|
||||||
|
await dbRun(
|
||||||
|
'INSERT INTO anhaenge (bewerbung_id, name, dateiname, mime, pfad) VALUES (?, ?, ?, ?, ?)',
|
||||||
|
[bewerbungId, doc.name, doc.filename, doc.mime, stored]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await dbRun(
|
||||||
|
"UPDATE bewerbungen SET generierung_status = 'fertig', generierung_fehler = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
|
||||||
|
[bewerbungId]
|
||||||
|
);
|
||||||
|
console.log(`Bewerbungsunterlagen für #${bewerbungId} generiert (${documents.length} Dokument(e)).`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Generierung für #${bewerbungId} fehlgeschlagen:`, error.message);
|
||||||
|
await dbRun(
|
||||||
|
"UPDATE bewerbungen SET generierung_status = 'fehler', generierung_fehler = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
|
||||||
|
[String(error.message || 'Unbekannter Fehler'), bewerbungId]
|
||||||
|
).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize database - create tables and default settings in one operation
|
// Initialize database - create tables and default settings in one operation
|
||||||
function initializeDatabase() {
|
function initializeDatabase() {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
@@ -119,8 +207,13 @@ function initializeDatabase() {
|
|||||||
`, (err) => {
|
`, (err) => {
|
||||||
if (err) return reject(err);
|
if (err) return reject(err);
|
||||||
|
|
||||||
// Migration: add interne_notizen to pre-existing databases (ignore "duplicate column")
|
// Migration: add columns to pre-existing databases (ignore "duplicate column")
|
||||||
db.run('ALTER TABLE bewerbungen ADD COLUMN interne_notizen TEXT', () => {
|
db.run('ALTER TABLE bewerbungen ADD COLUMN interne_notizen TEXT', () => {
|
||||||
|
db.run('ALTER TABLE bewerbungen ADD COLUMN ort TEXT', () => {
|
||||||
|
db.run('ALTER TABLE bewerbungen ADD COLUMN stellenbeschreibung TEXT', () => {
|
||||||
|
db.run('ALTER TABLE bewerbungen ADD COLUMN quelle_url TEXT', () => {
|
||||||
|
db.run('ALTER TABLE bewerbungen ADD COLUMN generierung_status TEXT', () => {
|
||||||
|
db.run('ALTER TABLE bewerbungen ADD COLUMN generierung_fehler TEXT', () => {
|
||||||
|
|
||||||
// Chronological status changes, each with an optional comment
|
// Chronological status changes, each with an optional comment
|
||||||
db.run(`
|
db.run(`
|
||||||
@@ -136,6 +229,33 @@ function initializeDatabase() {
|
|||||||
`, (err) => {
|
`, (err) => {
|
||||||
if (err) return reject(err);
|
if (err) return reject(err);
|
||||||
|
|
||||||
|
// Base documents (Basis-Unterlagen) — the foundation the AI tailors from
|
||||||
|
db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS basis_dokumente (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
typ TEXT,
|
||||||
|
name TEXT,
|
||||||
|
inhalt TEXT NOT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
`, (err) => {
|
||||||
|
if (err) return reject(err);
|
||||||
|
|
||||||
|
// Generated attachment files linked to an application
|
||||||
|
db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS anhaenge (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
bewerbung_id INTEGER NOT NULL,
|
||||||
|
name TEXT,
|
||||||
|
dateiname TEXT NOT NULL,
|
||||||
|
mime TEXT,
|
||||||
|
pfad TEXT NOT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (bewerbung_id) REFERENCES bewerbungen(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
`, (err) => {
|
||||||
|
if (err) return reject(err);
|
||||||
|
|
||||||
db.run(`
|
db.run(`
|
||||||
CREATE TABLE IF NOT EXISTS settings (
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
@@ -164,6 +284,13 @@ function initializeDatabase() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -280,6 +407,88 @@ initializeDatabase().then(() => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ----- Indeed import (called by the browser extension) -----
|
||||||
|
app.post('/api/indeed-import', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { firma, stelle, ort, gehalt, stellenbeschreibung, quelle_url } = req.body || {};
|
||||||
|
|
||||||
|
if (!firma || !stelle) {
|
||||||
|
return res.status(400).json({ error: 'Firma und Stelle sind erforderlich.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const datum = new Date().toISOString().split('T')[0];
|
||||||
|
// Keep the extra details (location, salary, source) visible in the notes too.
|
||||||
|
const notizParts = [
|
||||||
|
ort ? `Ort: ${ort}` : null,
|
||||||
|
gehalt ? `Gehalt: ${gehalt}` : null,
|
||||||
|
quelle_url ? `Quelle: ${quelle_url}` : null,
|
||||||
|
].filter(Boolean);
|
||||||
|
const notizen = notizParts.join('\n');
|
||||||
|
|
||||||
|
// Stored raw: every view renders these through EJS `<%= %>` (auto-escaped),
|
||||||
|
// so this is XSS-safe — and it keeps the text clean for the AI and the PDFs
|
||||||
|
// (no HTML entities leaking into the generated documents).
|
||||||
|
const result = await dbRun(
|
||||||
|
`INSERT INTO bewerbungen
|
||||||
|
(datum, firma, stelle, art, status, notizen, ort, stellenbeschreibung, quelle_url, generierung_status)
|
||||||
|
VALUES (?, ?, ?, 'Indeed', 'Entwurf', ?, ?, ?, ?, 'ausstehend')`,
|
||||||
|
[datum, firma, stelle, notizen, ort || '', stellenbeschreibung || '', quelle_url || '']
|
||||||
|
);
|
||||||
|
|
||||||
|
// Record the initial "Entwurf" status in the timeline
|
||||||
|
await dbRun(
|
||||||
|
'INSERT INTO status_verlauf (bewerbung_id, datum, status, kommentar) VALUES (?, ?, ?, ?)',
|
||||||
|
[result.lastID, datum, 'Entwurf', 'Automatisch über Indeed importiert']
|
||||||
|
);
|
||||||
|
|
||||||
|
// Kick off AI document generation in the background — respond immediately.
|
||||||
|
runGeneration(result.lastID);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
id: result.lastID,
|
||||||
|
url: `/bewerbung/${result.lastID}`,
|
||||||
|
message: 'Bewerbung als Entwurf angelegt. Die Unterlagen werden nun generiert.',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error importing job:', error);
|
||||||
|
res.status(500).json({ error: 'Serverfehler beim Import.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Poll generation status + current attachments for one application
|
||||||
|
app.get('/api/bewerbungen/:id/generierung', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const bewerbung = await dbGet(
|
||||||
|
'SELECT id, generierung_status, generierung_fehler FROM bewerbungen WHERE id = ?', [id]
|
||||||
|
);
|
||||||
|
if (!bewerbung) return res.status(404).json({ error: 'Bewerbung nicht gefunden' });
|
||||||
|
const anhaenge = await dbAll(
|
||||||
|
'SELECT id, name, dateiname, mime FROM anhaenge WHERE bewerbung_id = ? ORDER BY id ASC', [id]
|
||||||
|
);
|
||||||
|
res.json({
|
||||||
|
status: bewerbung.generierung_status,
|
||||||
|
fehler: bewerbung.generierung_fehler,
|
||||||
|
anhaenge,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching generation status:', error);
|
||||||
|
res.status(500).json({ error: 'Serverfehler' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ----- Base documents (Basis-Unterlagen / Vorlagen) -----
|
||||||
|
app.get('/api/basis-dokumente', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const docs = await dbAll('SELECT * FROM basis_dokumente ORDER BY id ASC');
|
||||||
|
res.json(docs);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error listing base documents:', error);
|
||||||
|
res.status(500).json({ error: 'Serverfehler' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Create application
|
// Create application
|
||||||
app.post('/api/bewerbungen', async (req, res) => {
|
app.post('/api/bewerbungen', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -385,9 +594,17 @@ initializeDatabase().then(() => {
|
|||||||
[id]
|
[id]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const anhaenge = await dbAll(
|
||||||
|
'SELECT id, name, dateiname, mime, created_at FROM anhaenge WHERE bewerbung_id = ? ORDER BY id ASC',
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
const basisCountRow = await dbGet('SELECT COUNT(*) as count FROM basis_dokumente');
|
||||||
|
|
||||||
res.render('bewerbung', {
|
res.render('bewerbung', {
|
||||||
application,
|
application,
|
||||||
verlauf,
|
verlauf,
|
||||||
|
anhaenge,
|
||||||
|
basisCount: basisCountRow ? basisCountRow.count : 0,
|
||||||
artOptions: ART_OPTIONS,
|
artOptions: ART_OPTIONS,
|
||||||
statusOptions: STATUS_OPTIONS,
|
statusOptions: STATUS_OPTIONS,
|
||||||
hideSettings: true
|
hideSettings: true
|
||||||
@@ -471,6 +688,119 @@ initializeDatabase().then(() => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ----- Vorlagen (Basis-Unterlagen) management page -----
|
||||||
|
app.get('/vorlagen', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const basisDokumente = await dbAll('SELECT * FROM basis_dokumente ORDER BY id ASC');
|
||||||
|
res.render('vorlagen', {
|
||||||
|
basisDokumente,
|
||||||
|
basisTypOptions: BASIS_TYP_OPTIONS,
|
||||||
|
hasApiKey: Boolean(process.env.OLLAMA_API_KEY),
|
||||||
|
hideSettings: true,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading vorlagen:', error);
|
||||||
|
res.status(500).send('Serverfehler');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add a base document
|
||||||
|
app.post('/vorlagen', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { typ, name, inhalt } = req.body;
|
||||||
|
if (inhalt && inhalt.trim()) {
|
||||||
|
await dbRun(
|
||||||
|
'INSERT INTO basis_dokumente (typ, name, inhalt) VALUES (?, ?, ?)',
|
||||||
|
[sanitizeInput(typ || 'Sonstiges'), sanitizeInput(name || ''), inhalt]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
res.redirect('/vorlagen');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error adding base document:', error);
|
||||||
|
res.status(500).send('Serverfehler');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update a base document
|
||||||
|
app.post('/vorlagen/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { typ, name, inhalt } = req.body;
|
||||||
|
await dbRun(
|
||||||
|
'UPDATE basis_dokumente SET typ = ?, name = ?, inhalt = ? WHERE id = ?',
|
||||||
|
[sanitizeInput(typ || 'Sonstiges'), sanitizeInput(name || ''), inhalt || '', id]
|
||||||
|
);
|
||||||
|
res.redirect('/vorlagen');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating base document:', error);
|
||||||
|
res.status(500).send('Serverfehler');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete a base document
|
||||||
|
app.post('/vorlagen/:id/delete', async (req, res) => {
|
||||||
|
try {
|
||||||
|
await dbRun('DELETE FROM basis_dokumente WHERE id = ?', [req.params.id]);
|
||||||
|
res.redirect('/vorlagen');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting base document:', error);
|
||||||
|
res.status(500).send('Serverfehler');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Download a generated attachment
|
||||||
|
app.get('/anhaenge/:id/download', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const anhang = await dbGet('SELECT * FROM anhaenge WHERE id = ?', [req.params.id]);
|
||||||
|
if (!anhang) return res.status(404).send('Anhang nicht gefunden');
|
||||||
|
const filePath = path.join(anhaengeDir, anhang.pfad);
|
||||||
|
if (!fs.existsSync(filePath)) return res.status(404).send('Datei nicht gefunden');
|
||||||
|
res.download(filePath, anhang.dateiname);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error downloading attachment:', error);
|
||||||
|
res.status(500).send('Serverfehler');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete a generated attachment
|
||||||
|
app.post('/bewerbung/:id/anhaenge/:anhangId/delete', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id, anhangId } = req.params;
|
||||||
|
const anhang = await dbGet('SELECT * FROM anhaenge WHERE id = ? AND bewerbung_id = ?', [anhangId, id]);
|
||||||
|
if (anhang) {
|
||||||
|
const filePath = path.join(anhaengeDir, anhang.pfad);
|
||||||
|
fs.promises.unlink(filePath).catch(() => {});
|
||||||
|
await dbRun('DELETE FROM anhaenge WHERE id = ?', [anhangId]);
|
||||||
|
}
|
||||||
|
res.redirect('/bewerbung/' + id);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting attachment:', error);
|
||||||
|
res.status(500).send('Serverfehler');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Re-run the AI generation for an application (removes old generated files first)
|
||||||
|
app.post('/bewerbung/:id/regenerate', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const bewerbung = await dbGet('SELECT id FROM bewerbungen WHERE id = ?', [id]);
|
||||||
|
if (!bewerbung) return res.status(404).send('Bewerbung nicht gefunden');
|
||||||
|
|
||||||
|
const alte = await dbAll('SELECT * FROM anhaenge WHERE bewerbung_id = ?', [id]);
|
||||||
|
for (const a of alte) {
|
||||||
|
fs.promises.unlink(path.join(anhaengeDir, a.pfad)).catch(() => {});
|
||||||
|
}
|
||||||
|
await dbRun('DELETE FROM anhaenge WHERE bewerbung_id = ?', [id]);
|
||||||
|
await dbRun("UPDATE bewerbungen SET generierung_status = 'ausstehend', generierung_fehler = NULL WHERE id = ?", [id]);
|
||||||
|
|
||||||
|
runGeneration(id);
|
||||||
|
res.redirect('/bewerbung/' + id);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error regenerating documents:', error);
|
||||||
|
res.status(500).send('Serverfehler');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Start server
|
// Start server
|
||||||
app.listen(PORT, () => {
|
app.listen(PORT, () => {
|
||||||
console.log(`Server läuft auf http://localhost:${PORT}`);
|
console.log(`Server läuft auf http://localhost:${PORT}`);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
<%
|
<%
|
||||||
const statusColor = {
|
const statusColor = {
|
||||||
|
'Entwurf': 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200',
|
||||||
'Gesendet': 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900 dark:text-indigo-200',
|
'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',
|
'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',
|
'Vorstellungsgespräch': 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
|
||||||
@@ -93,6 +94,113 @@
|
|||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Bewerbungsunterlagen (KI-generiert) -->
|
||||||
|
<% const genStatus = application.generierung_status; %>
|
||||||
|
<% if (genStatus || (anhaenge && anhaenge.length) || (application.stellenbeschreibung && application.stellenbeschreibung.trim())) { %>
|
||||||
|
<section id="unterlagen" class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-8"
|
||||||
|
data-status="<%= genStatus || '' %>">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3 mb-4">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-800 dark:text-white">Bewerbungsunterlagen</h2>
|
||||||
|
<form action="/bewerbung/<%= application.id %>/regenerate" method="POST"
|
||||||
|
onsubmit="return confirm('Unterlagen neu generieren? Vorhandene generierte Dateien werden ersetzt.');">
|
||||||
|
<button type="submit"
|
||||||
|
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm bg-blue-600 hover:bg-blue-700 text-white rounded-md transition-colors">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
||||||
|
</svg>
|
||||||
|
Neu generieren
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Generation status banner -->
|
||||||
|
<div id="genBanner">
|
||||||
|
<% if (genStatus === 'ausstehend') { %>
|
||||||
|
<div class="flex items-center gap-3 rounded-lg bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-800 px-4 py-3 mb-4">
|
||||||
|
<svg class="w-5 h-5 text-blue-600 dark:text-blue-400 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||||
|
</svg>
|
||||||
|
<span class="text-sm text-blue-800 dark:text-blue-200">Die KI erstellt gerade individuelle Bewerbungsunterlagen … Diese Seite aktualisiert sich automatisch.</span>
|
||||||
|
</div>
|
||||||
|
<% } else if (genStatus === 'fehler') { %>
|
||||||
|
<div class="rounded-lg bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 px-4 py-3 mb-4">
|
||||||
|
<div class="flex items-center gap-2 mb-1">
|
||||||
|
<svg class="w-5 h-5 text-red-600 dark:text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
|
||||||
|
</svg>
|
||||||
|
<span class="text-sm font-semibold text-red-800 dark:text-red-200">Generierung fehlgeschlagen</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-red-700 dark:text-red-300 ml-7"><%= application.generierung_fehler || 'Unbekannter Fehler.' %></p>
|
||||||
|
</div>
|
||||||
|
<% } else if (genStatus === 'fertig') { %>
|
||||||
|
<div class="flex items-center gap-2 rounded-lg bg-green-50 dark:bg-green-900/30 border border-green-200 dark:border-green-800 px-4 py-3 mb-4">
|
||||||
|
<svg class="w-5 h-5 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||||
|
</svg>
|
||||||
|
<span class="text-sm text-green-800 dark:text-green-200">Unterlagen wurden erstellt. Bitte vor dem Versand prüfen und ggf. anpassen.</span>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Attachments -->
|
||||||
|
<div id="anhaengeListe">
|
||||||
|
<% if (anhaenge && anhaenge.length) { %>
|
||||||
|
<ul class="space-y-2">
|
||||||
|
<% anhaenge.forEach(a => { %>
|
||||||
|
<li class="flex items-center justify-between gap-3 rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-700/40 px-4 py-3">
|
||||||
|
<div class="flex items-center gap-3 min-w-0">
|
||||||
|
<svg class="w-6 h-6 text-red-500 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"></path>
|
||||||
|
</svg>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="text-sm font-medium text-gray-800 dark:text-gray-100 truncate"><%= a.name || a.dateiname %></p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 truncate"><%= a.dateiname %></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3 shrink-0">
|
||||||
|
<a href="/anhaenge/<%= a.id %>/download"
|
||||||
|
class="inline-flex items-center gap-1 text-sm text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 hover:underline">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
|
||||||
|
</svg>
|
||||||
|
Download
|
||||||
|
</a>
|
||||||
|
<form action="/bewerbung/<%= application.id %>/anhaenge/<%= a.id %>/delete" method="POST"
|
||||||
|
onsubmit="return confirm('Diesen Anhang löschen?');">
|
||||||
|
<button type="submit" class="text-red-500 hover:text-red-700 dark:text-red-400" aria-label="Anhang löschen">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<% }); %>
|
||||||
|
</ul>
|
||||||
|
<% } else if (genStatus !== 'ausstehend') { %>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">Noch keine generierten Unterlagen vorhanden.</p>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Job description from the import -->
|
||||||
|
<% if (application.stellenbeschreibung && application.stellenbeschreibung.trim()) { %>
|
||||||
|
<details class="mt-6 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||||
|
<summary class="cursor-pointer select-none px-4 py-3 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Importierte Stellenbeschreibung anzeigen
|
||||||
|
</summary>
|
||||||
|
<div class="px-4 pb-4">
|
||||||
|
<% if (application.quelle_url) { %>
|
||||||
|
<a href="<%= application.quelle_url %>" target="_blank" rel="noopener"
|
||||||
|
class="inline-block mb-3 text-sm text-blue-600 dark:text-blue-400 hover:underline break-all"><%= application.quelle_url %></a>
|
||||||
|
<% } %>
|
||||||
|
<p class="whitespace-pre-wrap break-words text-sm leading-relaxed text-gray-700 dark:text-gray-300"><%= application.stellenbeschreibung %></p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
<% } %>
|
||||||
|
</section>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
<!-- Status timeline -->
|
<!-- Status timeline -->
|
||||||
<section class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
|
<section class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
|
||||||
<h2 class="text-lg font-semibold text-gray-800 dark:text-white mb-1">Status-Verlauf</h2>
|
<h2 class="text-lg font-semibold text-gray-800 dark:text-white mb-1">Status-Verlauf</h2>
|
||||||
@@ -214,6 +322,24 @@
|
|||||||
});
|
});
|
||||||
const cy = document.getElementById('currentYear');
|
const cy = document.getElementById('currentYear');
|
||||||
if (cy) cy.textContent = new Date().getFullYear();
|
if (cy) cy.textContent = new Date().getFullYear();
|
||||||
|
|
||||||
|
// While documents are being generated, poll for completion and
|
||||||
|
// reload the page once the status changes.
|
||||||
|
const unterlagen = document.getElementById('unterlagen');
|
||||||
|
if (unterlagen && unterlagen.dataset.status === 'ausstehend') {
|
||||||
|
const appId = <%= application.id %>;
|
||||||
|
const poll = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/bewerbungen/' + appId + '/generierung', { cache: 'no-store' });
|
||||||
|
if (!res.ok) return;
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.status && data.status !== 'ausstehend') {
|
||||||
|
clearInterval(poll);
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
} catch (e) { /* keep polling */ }
|
||||||
|
}, 4000);
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
+3
-1
@@ -144,9 +144,10 @@
|
|||||||
<% if (applications.length > 0) { %>
|
<% if (applications.length > 0) { %>
|
||||||
<%
|
<%
|
||||||
// Status-Reihenfolge synchron zu public/js/main.js halten
|
// Status-Reihenfolge synchron zu public/js/main.js halten
|
||||||
const statusOrder = ['Gesendet', 'Eingangsbestätigung', 'Vorstellungsgespräch',
|
const statusOrder = ['Entwurf', 'Gesendet', 'Eingangsbestätigung', 'Vorstellungsgespräch',
|
||||||
'Einstellung', 'Absage', 'Keine Rückmeldung'];
|
'Einstellung', 'Absage', 'Keine Rückmeldung'];
|
||||||
const statusDot = {
|
const statusDot = {
|
||||||
|
'Entwurf': 'bg-purple-500',
|
||||||
'Gesendet': 'bg-indigo-500',
|
'Gesendet': 'bg-indigo-500',
|
||||||
'Eingangsbestätigung': 'bg-blue-500',
|
'Eingangsbestätigung': 'bg-blue-500',
|
||||||
'Vorstellungsgespräch': 'bg-yellow-500',
|
'Vorstellungsgespräch': 'bg-yellow-500',
|
||||||
@@ -204,6 +205,7 @@
|
|||||||
<%= app.status === 'Einstellung' ? 'bg-green-100 text-green-800 dark:bg-green-800 dark:text-green-100' :
|
<%= app.status === 'Einstellung' ? 'bg-green-100 text-green-800 dark:bg-green-800 dark:text-green-100' :
|
||||||
app.status === 'Absage' ? 'bg-red-100 text-red-800 dark:bg-red-800 dark:text-red-100' :
|
app.status === 'Absage' ? 'bg-red-100 text-red-800 dark:bg-red-800 dark:text-red-100' :
|
||||||
app.status === 'Vorstellungsgespräch' ? 'bg-yellow-100 text-yellow-800 dark:bg-yellow-800 dark:text-yellow-100' :
|
app.status === 'Vorstellungsgespräch' ? 'bg-yellow-100 text-yellow-800 dark:bg-yellow-800 dark:text-yellow-100' :
|
||||||
|
app.status === 'Entwurf' ? 'bg-purple-100 text-purple-800 dark:bg-purple-800 dark:text-purple-100' :
|
||||||
'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300' %>">
|
'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300' %>">
|
||||||
<%= app.status || '-' %>
|
<%= app.status || '-' %>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -9,6 +9,16 @@
|
|||||||
</a>
|
</a>
|
||||||
|
|
||||||
<div class="flex items-center space-x-4">
|
<div class="flex items-center space-x-4">
|
||||||
|
<!-- Vorlagen (base documents) link -->
|
||||||
|
<a href="/vorlagen"
|
||||||
|
class="flex items-center gap-1.5 px-3 py-2 rounded-md bg-white/20 hover:bg-white/30 transition-colors text-white text-sm font-medium"
|
||||||
|
title="Basis-Unterlagen für die KI-Generierung verwalten">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
||||||
|
</svg>
|
||||||
|
<span class="hidden sm:inline">Vorlagen</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
<!-- Dark mode toggle -->
|
<!-- Dark mode toggle -->
|
||||||
<button
|
<button
|
||||||
id="darkModeToggle"
|
id="darkModeToggle"
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<%- include('partials/head') %>
|
||||||
|
</head>
|
||||||
|
<body class="min-h-screen transition-colors duration-300 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100" id="body">
|
||||||
|
<%- include('partials/header', { hideSettings: true }) %>
|
||||||
|
|
||||||
|
<main class="container mx-auto px-4 py-8 max-w-3xl">
|
||||||
|
<!-- Back link -->
|
||||||
|
<a href="/" class="inline-flex items-center gap-2 text-sm text-blue-600 dark:text-blue-400 hover:underline mb-6">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"></path>
|
||||||
|
</svg>
|
||||||
|
Zurück zur Übersicht
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<h1 class="text-2xl font-bold text-gray-800 dark:text-white mb-2">Basis-Unterlagen (Vorlagen)</h1>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mb-6">
|
||||||
|
Diese Unterlagen dienen der KI als Faktengrundlage. Beim Import einer Stelle über die Browser-Erweiterung
|
||||||
|
werden daraus automatisch zugeschnittene Bewerbungsunterlagen erstellt – es werden ausschließlich die
|
||||||
|
hier hinterlegten Informationen verwendet, es wird nichts hinzuerfunden. Füge z. B. dein
|
||||||
|
Standard-Anschreiben und deinen Lebenslauf als Text ein.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- API key warning -->
|
||||||
|
<% if (!hasApiKey) { %>
|
||||||
|
<div class="rounded-lg bg-amber-50 dark:bg-amber-900/30 border border-amber-200 dark:border-amber-800 px-4 py-3 mb-6">
|
||||||
|
<div class="flex items-start gap-2">
|
||||||
|
<svg class="w-5 h-5 text-amber-600 dark:text-amber-400 shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
|
||||||
|
</svg>
|
||||||
|
<div class="text-sm text-amber-800 dark:text-amber-200">
|
||||||
|
<p class="font-semibold">Kein API-Schlüssel konfiguriert</p>
|
||||||
|
<p>Setze die Umgebungsvariable <code class="px-1 rounded bg-amber-100 dark:bg-amber-800">OLLAMA_API_KEY</code>
|
||||||
|
(z. B. in einer <code class="px-1 rounded bg-amber-100 dark:bg-amber-800">.env</code>-Datei),
|
||||||
|
damit Bewerbungsunterlagen automatisch generiert werden können.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
<!-- Existing base documents -->
|
||||||
|
<section class="space-y-4 mb-8">
|
||||||
|
<% if (basisDokumente.length === 0) { %>
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 text-center text-gray-500 dark:text-gray-400">
|
||||||
|
Noch keine Basis-Unterlagen hinterlegt. Füge unten dein erstes Dokument hinzu.
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
<% basisDokumente.forEach(doc => { %>
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
|
||||||
|
<form action="/vorlagen/<%= doc.id %>" method="POST" class="space-y-3">
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Typ</label>
|
||||||
|
<select name="typ" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-800 dark:text-white">
|
||||||
|
<% basisTypOptions.forEach(opt => { %>
|
||||||
|
<option value="<%= opt %>" <%= doc.typ === opt ? 'selected' : '' %>><%= opt %></option>
|
||||||
|
<% }); %>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="sm:col-span-2">
|
||||||
|
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Bezeichnung</label>
|
||||||
|
<input type="text" name="name" value="<%= doc.name || '' %>"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-800 dark:text-white"
|
||||||
|
placeholder="z. B. Standard-Anschreiben">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Inhalt (Text)</label>
|
||||||
|
<textarea name="inhalt" rows="8"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-800 dark:text-white leading-relaxed font-mono text-sm"><%= doc.inhalt %></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<button type="submit"
|
||||||
|
class="px-3 py-1.5 text-sm bg-blue-600 hover:bg-blue-700 text-white rounded-md transition-colors">
|
||||||
|
Speichern
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<form action="/vorlagen/<%= doc.id %>/delete" method="POST" class="mt-1 text-right"
|
||||||
|
onsubmit="return confirm('Dieses Basis-Dokument löschen?');">
|
||||||
|
<button type="submit"
|
||||||
|
class="text-xs text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300 hover:underline">
|
||||||
|
Dokument löschen
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<% }); %>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Add new base document -->
|
||||||
|
<section class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-800 dark:text-white mb-4">Basis-Dokument hinzufügen</h2>
|
||||||
|
<form action="/vorlagen" method="POST" class="space-y-3">
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Typ</label>
|
||||||
|
<select name="typ" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-800 dark:text-white">
|
||||||
|
<% basisTypOptions.forEach(opt => { %>
|
||||||
|
<option value="<%= opt %>"><%= opt %></option>
|
||||||
|
<% }); %>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="sm:col-span-2">
|
||||||
|
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Bezeichnung</label>
|
||||||
|
<input type="text" name="name"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-800 dark:text-white"
|
||||||
|
placeholder="z. B. Standard-Anschreiben">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Inhalt (Text)</label>
|
||||||
|
<textarea name="inhalt" rows="10" required
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-800 dark:text-white leading-relaxed font-mono text-sm"
|
||||||
|
placeholder="Füge hier den vollständigen Text deines Anschreibens oder Lebenslaufs ein…"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<button type="submit" class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-md transition-colors">
|
||||||
|
Hinzufügen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<%- include('partials/footer') %>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
const root = document.documentElement;
|
||||||
|
const dm = localStorage.getItem('darkMode');
|
||||||
|
if (dm === 'enabled' || (!dm && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||||
|
root.classList.add('dark');
|
||||||
|
}
|
||||||
|
const toggle = document.getElementById('darkModeToggle');
|
||||||
|
const sun = document.getElementById('sunIcon');
|
||||||
|
const moon = document.getElementById('moonIcon');
|
||||||
|
function sync() {
|
||||||
|
const d = root.classList.contains('dark');
|
||||||
|
if (sun) sun.classList.toggle('hidden', d);
|
||||||
|
if (moon) moon.classList.toggle('hidden', !d);
|
||||||
|
}
|
||||||
|
sync();
|
||||||
|
if (toggle) toggle.addEventListener('click', () => {
|
||||||
|
root.classList.toggle('dark');
|
||||||
|
localStorage.setItem('darkMode', root.classList.contains('dark') ? 'enabled' : 'disabled');
|
||||||
|
sync();
|
||||||
|
});
|
||||||
|
const cy = document.getElementById('currentYear');
|
||||||
|
if (cy) cy.textContent = new Date().getFullYear();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user