feat: init inkl. docker configs
This commit is contained in:
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
data/
|
||||
.env
|
||||
.DS_Store
|
||||
.astro/
|
||||
36
Dockerfile
Normal file
36
Dockerfile
Normal file
@@ -0,0 +1,36 @@
|
||||
FROM node:20-slim AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Paket-Definitionen kopieren und Abhängigkeiten installieren
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
# Quellcode kopieren und bauen
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# ---
|
||||
# Zweite schlanke Stufe für die eigentliche Ausführung
|
||||
FROM node:20-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Nur die notwendigen gebauten Dateien kopieren
|
||||
COPY --from=builder /app/package*.json ./
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/dist ./dist
|
||||
|
||||
# Typische Astro Standalone Umgebungsvariablen
|
||||
ENV HOST=0.0.0.0
|
||||
ENV PORT=4321
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# Setzen des Datenbank-Pfads in einen dedizierten Ordner,
|
||||
# den wir über Docker-Compose nach außen mounten können.
|
||||
ENV DB_PATH=/app/data/lebenslauf.db
|
||||
|
||||
EXPOSE 4321
|
||||
|
||||
# Start-Kommando
|
||||
CMD ["node", "./dist/server/entry.mjs"]
|
||||
111
README.md
Normal file
111
README.md
Normal file
@@ -0,0 +1,111 @@
|
||||
# 📄 Lebenslauf-App
|
||||
|
||||
Professioneller Lebenslauf-Builder mit Astro + SQLite. Keine externen Tools notwendig.
|
||||
|
||||
## Features
|
||||
|
||||
- **4 Templates** – Navy Klassik, Modern Timeline, Minimal Elegant, Bold Creative
|
||||
- **OTP-Login** – E-Mail-basierter Einmal-Code, kein Passwort
|
||||
- **Editor** – Live-Vorschau, Auto-Save alle 2 Sekunden
|
||||
- **Skills** – Eingabe mit Niveauanzeige (1–5 Punkte)
|
||||
- **Farben & Schriften** – Vollständig anpassbar pro Lebenslauf
|
||||
- **PDF-Export** – Client-seitig via html2pdf.js (kein Browser-Backend nötig)
|
||||
- **Online-Teilen** – Öffentlicher Link via Hash-URL `/cv/[hash]`
|
||||
- **Formate**: PDF, HTML, JSON, CSV, Excel (XLSX)
|
||||
- **DE/EN** – Vollständige Übersetzung beider Sprachen
|
||||
|
||||
---
|
||||
|
||||
## Schnellstart
|
||||
|
||||
```bash
|
||||
# 1. Abhängigkeiten installieren
|
||||
npm install
|
||||
|
||||
# 2. Umgebungsvariablen konfigurieren
|
||||
cp .env.example .env
|
||||
# → .env bearbeiten (SMTP-Daten eintragen)
|
||||
|
||||
# 3. Starten
|
||||
npm run dev
|
||||
```
|
||||
|
||||
App läuft auf: **http://localhost:4321**
|
||||
|
||||
---
|
||||
|
||||
## Umgebungsvariablen (`.env`)
|
||||
|
||||
| Variable | Beschreibung | Standard |
|
||||
|----------|-------------|---------|
|
||||
| `DB_PATH` | Pfad zur SQLite-Datei | `./data/lebenslauf.db` |
|
||||
| `SESSION_SECRET` | Zufälliger langer String | – |
|
||||
| `SMTP_HOST` | SMTP-Server | `localhost` |
|
||||
| `SMTP_PORT` | SMTP-Port | `587` |
|
||||
| `SMTP_SECURE` | SSL/TLS | `false` |
|
||||
| `SMTP_USER` | SMTP-Benutzername | – |
|
||||
| `SMTP_PASS` | SMTP-Passwort | – |
|
||||
| `SMTP_FROM` | Absender-Adresse | `noreply@localhost` |
|
||||
| `APP_URL` | Öffentliche App-URL | `http://localhost:4321` |
|
||||
| `OTP_EXPIRES_MINUTES` | OTP-Gültigkeit | `10` |
|
||||
|
||||
> **Dev-Modus**: Wenn kein SMTP konfiguriert ist, wird der OTP-Code in der Konsole ausgegeben.
|
||||
|
||||
---
|
||||
|
||||
## Produktion
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
npm start
|
||||
```
|
||||
|
||||
Die App läuft als eigenständiger Node.js-Server.
|
||||
|
||||
---
|
||||
|
||||
## Exports
|
||||
|
||||
| Format | Methode | Inhalt |
|
||||
|--------|---------|--------|
|
||||
| **PDF** | html2pdf.js (CDN) | Druckfertiger DIN A4 Lebenslauf |
|
||||
| **HTML** | Client-seitig | Standalone-Datei mit eingebettetem CSS |
|
||||
| **JSON** | Client-seitig | Vollständige strukturierte Daten |
|
||||
| **CSV** | Client-seitig | Tabellarische Daten (UTF-8 BOM) |
|
||||
| **Excel** | SheetJS (CDN) | 5 Blätter: Persönlich, Erfahrung, Ausbildung, Kenntnisse, Sprachen |
|
||||
|
||||
---
|
||||
|
||||
## URLs
|
||||
|
||||
| Route | Beschreibung |
|
||||
|-------|-------------|
|
||||
| `/` | Login-Seite |
|
||||
| `/verify` | OTP-Eingabe |
|
||||
| `/dashboard` | Übersicht der Lebensläufe |
|
||||
| `/editor/[id]` | Lebenslauf bearbeiten |
|
||||
| `/cv/[hash]` | Öffentliche Ansicht (wenn aktiviert) |
|
||||
|
||||
---
|
||||
|
||||
## Technologie
|
||||
|
||||
- **Astro 4** mit SSR (Node-Adapter)
|
||||
- **SQLite** via `better-sqlite3`
|
||||
- **E-Mail** via `nodemailer`
|
||||
- **PDF** via `html2pdf.js` (CDN, kein Install)
|
||||
- **Excel** via `SheetJS` (CDN, kein Install)
|
||||
- **CSS** – reines CSS, kein Framework
|
||||
|
||||
---
|
||||
|
||||
## Eigene SMTP-Provider
|
||||
|
||||
| Provider | Host | Port | Secure |
|
||||
|----------|------|------|--------|
|
||||
| Gmail | smtp.gmail.com | 587 | false |
|
||||
| Netcup (1blu) | smtp.example.com | 587 | false |
|
||||
| Outlook | smtp.office365.com | 587 | false |
|
||||
| Mailgun | smtp.mailgun.org | 587 | false |
|
||||
|
||||
> Für Gmail: App-Passwort verwenden (nicht das Konto-Passwort).
|
||||
12
astro.config.mjs
Normal file
12
astro.config.mjs
Normal file
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from 'astro/config';
|
||||
import node from '@astrojs/node';
|
||||
|
||||
export default defineConfig({
|
||||
output: 'server',
|
||||
adapter: node({ mode: 'standalone' }),
|
||||
server: { port: 4321, host: true },
|
||||
vite: {
|
||||
optimizeDeps: { exclude: ['better-sqlite3'] },
|
||||
ssr: { external: ['better-sqlite3'] }
|
||||
}
|
||||
});
|
||||
24
docker-compose.yml
Normal file
24
docker-compose.yml
Normal file
@@ -0,0 +1,24 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
lebenslauf-app:
|
||||
# Portainer kann per Git-Verbindung das Image automatisch anhand
|
||||
# des Dockerfiles ("build: .") im Repositiory bauen und deployen.
|
||||
build: .
|
||||
container_name: lebenslauf-app-container
|
||||
ports:
|
||||
- "4321:4321"
|
||||
volumes:
|
||||
# Für Portainer sind sogenannte "Named Volumes" wesentlich besser
|
||||
# als lokale Ordner-Pfade, da Docker/Portainer den Speicherort selbst verwaltet.
|
||||
- lebenslauf_data:/app/data
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- HOST=0.0.0.0
|
||||
- PORT=4321
|
||||
- NODE_ENV=production
|
||||
|
||||
# Deklaration des benannten Volumes für Portainer
|
||||
volumes:
|
||||
lebenslauf_data:
|
||||
name: lebenslauf_daten_volume
|
||||
6170
package-lock.json
generated
Normal file
6170
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
package.json
Normal file
24
package.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "lebenslauf-app",
|
||||
"version": "1.0.0",
|
||||
"description": "Professioneller Lebenslauf-Builder",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview",
|
||||
"start": "node dist/server/entry.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"astro": "^4.5.0",
|
||||
"@astrojs/node": "^8.2.0",
|
||||
"better-sqlite3": "^12.9.0",
|
||||
"nodemailer": "^6.9.13",
|
||||
"uuid": "^9.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.8",
|
||||
"@types/nodemailer": "^6.4.14",
|
||||
"@types/uuid": "^9.0.8",
|
||||
"typescript": "^5.4.0"
|
||||
}
|
||||
}
|
||||
385
public/styles/global.css
Normal file
385
public/styles/global.css
Normal file
@@ -0,0 +1,385 @@
|
||||
/* ── Reset & Base ─────────────────────────────────────────────────── */
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
--navy: #1B2A5E;
|
||||
--accent: #4A7BC5;
|
||||
--bg: #F5F7FA;
|
||||
--surface: #FFFFFF;
|
||||
--border: #E2E8F0;
|
||||
--text: #1A202C;
|
||||
--muted: #64748B;
|
||||
--danger: #E53E3E;
|
||||
--success: #38A169;
|
||||
--radius: 8px;
|
||||
--shadow: 0 1px 3px rgba(0,0,0,.1), 0 1px 2px rgba(0,0,0,.06);
|
||||
--shadow-md: 0 4px 6px -1px rgba(0,0,0,.1), 0 2px 4px -1px rgba(0,0,0,.06);
|
||||
}
|
||||
|
||||
html { font-size: 16px; }
|
||||
body { font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; background: var(--bg); color: var(--text); line-height: 1.5; }
|
||||
a { color: var(--accent); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
button { cursor: pointer; font-family: inherit; }
|
||||
input, textarea, select { font-family: inherit; }
|
||||
|
||||
/* ── Layout ───────────────────────────────────────────────────────── */
|
||||
.app-header {
|
||||
background: var(--navy);
|
||||
color: white;
|
||||
padding: 0 24px;
|
||||
height: 56px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
.app-header .logo { font-size: 1.1rem; font-weight: 700; color: white; letter-spacing: -0.3px; }
|
||||
.app-header .logo span { color: var(--accent); }
|
||||
.app-header nav { display: flex; align-items: center; gap: 16px; }
|
||||
.app-header nav a { color: rgba(255,255,255,.8); font-size: .875rem; transition: color .2s; }
|
||||
.app-header nav a:hover { color: white; text-decoration: none; }
|
||||
.app-header .btn-logout {
|
||||
background: rgba(255,255,255,.12);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 6px 14px;
|
||||
border-radius: var(--radius);
|
||||
font-size: .875rem;
|
||||
transition: background .2s;
|
||||
}
|
||||
.app-header .btn-logout:hover { background: rgba(255,255,255,.22); }
|
||||
|
||||
.container { max-width: 1200px; margin: 0 auto; padding: 0 24px; }
|
||||
.page { padding: 32px 24px; max-width: 1200px; margin: 0 auto; }
|
||||
|
||||
/* ── Buttons ──────────────────────────────────────────────────────── */
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 8px 16px; border-radius: var(--radius);
|
||||
font-size: .875rem; font-weight: 500;
|
||||
border: none; transition: all .15s;
|
||||
text-decoration: none;
|
||||
}
|
||||
.btn-primary { background: var(--navy); color: white; }
|
||||
.btn-primary:hover { background: #243572; text-decoration: none; }
|
||||
.btn-accent { background: var(--accent); color: white; }
|
||||
.btn-accent:hover { background: #3a6ab5; text-decoration: none; }
|
||||
.btn-ghost { background: transparent; color: var(--text); border: 1px solid var(--border); }
|
||||
.btn-ghost:hover { background: var(--bg); text-decoration: none; }
|
||||
.btn-danger { background: var(--danger); color: white; border: none; }
|
||||
.btn-danger:hover { background: #c53030; }
|
||||
.btn-sm { padding: 5px 10px; font-size: .8rem; }
|
||||
.btn-lg { padding: 12px 24px; font-size: 1rem; }
|
||||
.btn:disabled { opacity: .5; cursor: not-allowed; }
|
||||
|
||||
/* ── Cards ────────────────────────────────────────────────────────── */
|
||||
.card {
|
||||
background: var(--surface); border: 1px solid var(--border);
|
||||
border-radius: var(--radius); box-shadow: var(--shadow);
|
||||
overflow: hidden;
|
||||
}
|
||||
.card-body { padding: 24px; }
|
||||
.card-header { padding: 16px 24px; border-bottom: 1px solid var(--border); background: var(--bg); }
|
||||
.card-footer { padding: 16px 24px; border-top: 1px solid var(--border); background: var(--bg); }
|
||||
|
||||
/* ── Forms ────────────────────────────────────────────────────────── */
|
||||
.form-group { display: flex; flex-direction: column; gap: 6px; margin-bottom: 16px; }
|
||||
.form-label { font-size: .875rem; font-weight: 500; color: var(--text); }
|
||||
.form-input {
|
||||
padding: 8px 12px; border: 1px solid var(--border); border-radius: var(--radius);
|
||||
font-size: .875rem; background: var(--surface); color: var(--text);
|
||||
transition: border-color .15s, box-shadow .15s;
|
||||
}
|
||||
.form-input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(74,123,197,.15); }
|
||||
.form-textarea { min-height: 80px; resize: vertical; }
|
||||
.form-select { appearance: none; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%2364748B' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 8px center; padding-right: 32px; }
|
||||
.form-hint { font-size: .75rem; color: var(--muted); }
|
||||
.form-error { font-size: .75rem; color: var(--danger); }
|
||||
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||
@media (max-width: 600px) { .form-row { grid-template-columns: 1fr; } }
|
||||
|
||||
/* ── Auth Page ────────────────────────────────────────────────────── */
|
||||
.auth-page {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: linear-gradient(135deg, var(--navy) 0%, #2d4a9e 100%);
|
||||
padding: 24px;
|
||||
}
|
||||
.auth-card {
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 40px 36px;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,.3);
|
||||
}
|
||||
.auth-logo { font-size: 1.5rem; font-weight: 800; color: var(--navy); margin-bottom: 8px; }
|
||||
.auth-logo span { color: var(--accent); }
|
||||
.auth-subtitle { color: var(--muted); font-size: .875rem; margin-bottom: 32px; }
|
||||
.otp-input {
|
||||
width: 100%; text-align: center; font-size: 2rem; font-weight: 700;
|
||||
letter-spacing: 12px; padding: 16px; border: 2px solid var(--border);
|
||||
border-radius: var(--radius); font-family: monospace;
|
||||
transition: border-color .15s;
|
||||
}
|
||||
.otp-input:focus { outline: none; border-color: var(--accent); }
|
||||
|
||||
/* ── Dashboard ────────────────────────────────────────────────────── */
|
||||
.cv-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 20px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
.cv-card {
|
||||
background: var(--surface); border: 1px solid var(--border);
|
||||
border-radius: var(--radius); overflow: hidden;
|
||||
transition: box-shadow .2s, transform .2s;
|
||||
cursor: pointer;
|
||||
}
|
||||
.cv-card:hover { box-shadow: var(--shadow-md); transform: translateY(-2px); }
|
||||
.cv-card-preview {
|
||||
height: 180px;
|
||||
background: linear-gradient(135deg, var(--navy) 0%, #243572 40%, #3a6ab5 100%);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.cv-card-preview .t-badge {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
background: rgba(255,255,255,.2);
|
||||
color: white;
|
||||
font-size: .7rem;
|
||||
padding: 2px 8px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
.cv-card-preview .preview-mini {
|
||||
width: 90px;
|
||||
height: 127px;
|
||||
background: white;
|
||||
border-radius: 3px;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,.3);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.cv-card-body { padding: 16px; }
|
||||
.cv-card-title { font-weight: 600; font-size: .95rem; margin-bottom: 4px; }
|
||||
.cv-card-meta { font-size: .75rem; color: var(--muted); }
|
||||
.cv-card-actions { display: flex; gap: 8px; padding: 12px 16px; border-top: 1px solid var(--border); }
|
||||
|
||||
/* ── Editor ───────────────────────────────────────────────────────── */
|
||||
.editor-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 360px 1fr;
|
||||
height: calc(100vh - 56px);
|
||||
overflow: hidden;
|
||||
}
|
||||
.editor-sidebar {
|
||||
background: var(--surface);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.editor-tabs {
|
||||
display: flex;
|
||||
overflow-x: auto;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.editor-tabs::-webkit-scrollbar { display: none; }
|
||||
.editor-tab {
|
||||
padding: 10px 14px;
|
||||
font-size: .8rem;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
color: var(--muted);
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: all .15s;
|
||||
}
|
||||
.editor-tab.active { color: var(--navy); border-bottom-color: var(--navy); }
|
||||
.editor-tab:hover { color: var(--text); }
|
||||
.editor-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
}
|
||||
.editor-preview {
|
||||
background: #E8ECF0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
.editor-preview-toolbar {
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 10px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.preview-wrap {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
}
|
||||
.preview-scale-wrap {
|
||||
transform-origin: top center;
|
||||
width: 794px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.save-status {
|
||||
font-size: .75rem;
|
||||
color: var(--success);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.save-status.saving { color: var(--muted); }
|
||||
|
||||
/* ── Section Items ────────────────────────────────────────────────── */
|
||||
.item-card {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
margin-bottom: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.item-card-header {
|
||||
padding: 10px 14px;
|
||||
background: var(--bg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.item-card-title { font-size: .875rem; font-weight: 500; }
|
||||
.item-card-body { padding: 14px; display: none; }
|
||||
.item-card.open .item-card-body { display: block; }
|
||||
.item-card-actions { display: flex; gap: 6px; }
|
||||
|
||||
/* ── Skill level ──────────────────────────────────────────────────── */
|
||||
.skill-dots { display: flex; gap: 4px; align-items: center; }
|
||||
.skill-dot {
|
||||
width: 14px; height: 14px; border-radius: 50%;
|
||||
border: 2px solid var(--accent);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
transition: background .15s;
|
||||
}
|
||||
.skill-dot.filled { background: var(--accent); }
|
||||
|
||||
/* ── Color picker ─────────────────────────────────────────────────── */
|
||||
.color-row { display: flex; align-items: center; gap: 10px; }
|
||||
.color-swatch { width: 36px; height: 36px; border-radius: 6px; border: 2px solid var(--border); cursor: pointer; overflow: hidden; padding: 0; }
|
||||
.color-swatch input[type=color] { width: 200%; height: 200%; margin: -50%; border: none; cursor: pointer; }
|
||||
|
||||
/* ── Template selector ────────────────────────────────────────────── */
|
||||
.template-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
||||
.template-option {
|
||||
border: 2px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: border-color .15s;
|
||||
}
|
||||
.template-option.selected { border-color: var(--accent); }
|
||||
.template-option:hover { border-color: var(--accent); }
|
||||
.template-thumb {
|
||||
height: 100px;
|
||||
background: linear-gradient(135deg, #1B2A5E, #4A7BC5);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.template-label { padding: 8px; font-size: .75rem; font-weight: 500; text-align: center; }
|
||||
|
||||
/* ── CV Templates (A4) ────────────────────────────────────────────── */
|
||||
.cv-a4 {
|
||||
width: 794px;
|
||||
min-height: 1123px;
|
||||
background: white;
|
||||
position: relative;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,.15);
|
||||
}
|
||||
|
||||
/* ── Public CV Page ───────────────────────────────────────────────── */
|
||||
.cv-public-wrap {
|
||||
min-height: 100vh;
|
||||
background: #E8ECF0;
|
||||
padding: 32px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
.cv-public-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* ── Alerts ───────────────────────────────────────────────────────── */
|
||||
.alert { padding: 12px 16px; border-radius: var(--radius); font-size: .875rem; margin-bottom: 16px; }
|
||||
.alert-success { background: #f0fff4; border: 1px solid #9ae6b4; color: #276749; }
|
||||
.alert-error { background: #fff5f5; border: 1px solid #fed7d7; color: #9b2c2c; }
|
||||
.alert-info { background: #ebf8ff; border: 1px solid #bee3f8; color: #2a4a6b; }
|
||||
|
||||
/* ── Utils ────────────────────────────────────────────────────────── */
|
||||
.flex { display: flex; }
|
||||
.items-center { align-items: center; }
|
||||
.justify-between { justify-content: space-between; }
|
||||
.gap-2 { gap: 8px; }
|
||||
.gap-3 { gap: 12px; }
|
||||
.mt-1 { margin-top: 4px; }
|
||||
.mt-2 { margin-top: 8px; }
|
||||
.mt-4 { margin-top: 16px; }
|
||||
.mt-6 { margin-top: 24px; }
|
||||
.mb-2 { margin-bottom: 8px; }
|
||||
.mb-4 { margin-bottom: 16px; }
|
||||
.text-sm { font-size: .875rem; }
|
||||
.text-xs { font-size: .75rem; }
|
||||
.text-muted { color: var(--muted); }
|
||||
.font-bold { font-weight: 700; }
|
||||
.font-semibold { font-weight: 600; }
|
||||
.w-full { width: 100%; }
|
||||
.hidden { display: none !important; }
|
||||
.divider { border: none; border-top: 1px solid var(--border); margin: 20px 0; }
|
||||
|
||||
/* ── Print ────────────────────────────────────────────────────────── */
|
||||
@media print {
|
||||
body { background: white !important; }
|
||||
.cv-public-actions, .app-header { display: none !important; }
|
||||
.cv-public-wrap { padding: 0 !important; background: white !important; }
|
||||
.cv-a4 { box-shadow: none !important; }
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.editor-layout { grid-template-columns: 1fr; }
|
||||
.editor-preview { display: none; }
|
||||
.editor-layout.show-preview .editor-sidebar { display: none; }
|
||||
.editor-layout.show-preview .editor-preview { display: flex; }
|
||||
.preview-scale-wrap { width: 100%; transform: none !important; }
|
||||
.cv-a4 { width: 100% !important; }
|
||||
}
|
||||
225
src/components/templates/Template1.astro
Normal file
225
src/components/templates/Template1.astro
Normal file
@@ -0,0 +1,225 @@
|
||||
---
|
||||
import type { CVData, CVSettings } from '../../types';
|
||||
|
||||
interface Props {
|
||||
data: CVData;
|
||||
settings: CVSettings;
|
||||
}
|
||||
const { data: d, settings: s } = Astro.props;
|
||||
const p = d.personal;
|
||||
const T = s.language === 'en';
|
||||
|
||||
function date(str: string) { return str || (T ? 'present' : 'heute'); }
|
||||
function datRange(from: string, to: string, cur: boolean) {
|
||||
return `${from} – ${cur ? (T ? 'present' : 'Aktuell') : to}`;
|
||||
}
|
||||
---
|
||||
<div class="cv-a4 t1" style={`--p:${s.primaryColor};--a:${s.accentColor};font-family:${s.fontFamily?.replace(/"/g, "'")}`}>
|
||||
<div class="t1-layout">
|
||||
<!-- SIDEBAR -->
|
||||
<aside class="t1-sidebar">
|
||||
{s.showPhoto && (
|
||||
<div class="t1-photo-wrap">
|
||||
{p.photo
|
||||
? <img src={p.photo} class="t1-photo" alt="" />
|
||||
: <div class="t1-photo-ph"><svg viewBox="0 0 24 24" fill="currentColor" width="48" height="48"><path d="M12 12c2.7 0 4.8-2.1 4.8-4.8S14.7 2.4 12 2.4 7.2 4.5 7.2 7.2 9.3 12 12 12zm0 2.4c-3.2 0-9.6 1.6-9.6 4.8v2.4h19.2v-2.4c0-3.2-6.4-4.8-9.6-4.8z"/></svg></div>
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<!-- Personal -->
|
||||
<div class="t1-section-head">{T ? 'PERSONAL' : 'PERSÖNLICH'}</div>
|
||||
<div class="t1-contact-list">
|
||||
{p.firstName && <div class="t1-ci"><span class="t1-icon">👤</span><div><div class="t1-cl">{T ? 'NAME' : 'NAME'}</div><div class="t1-cv">{p.firstName} {p.lastName}</div></div></div>}
|
||||
{(p.address || p.city) && <div class="t1-ci"><span class="t1-icon">🏠</span><div><div class="t1-cl">{T ? 'ADDRESS' : 'ADRESSE'}</div><div class="t1-cv">{p.address}{p.address && p.city ? ', ' : ''}{p.city}</div></div></div>}
|
||||
{p.phone && <div class="t1-ci"><span class="t1-icon">📞</span><div><div class="t1-cl">{T ? 'PHONE' : 'TELEFON'}</div><div class="t1-cv">{p.phone}</div></div></div>}
|
||||
{p.email && <div class="t1-ci"><span class="t1-icon">✉️</span><div><div class="t1-cl">E-MAIL</div><div class="t1-cv">{p.email}</div></div></div>}
|
||||
{p.birthDate && <div class="t1-ci"><span class="t1-icon">📅</span><div><div class="t1-cl">{T ? 'BIRTH DATE' : 'GEBURTSDATUM'}</div><div class="t1-cv">{p.birthDate}{p.birthPlace ? `, ${p.birthPlace}` : ''}</div></div></div>}
|
||||
{p.nationality && <div class="t1-ci"><span class="t1-icon">🏳️</span><div><div class="t1-cl">{T ? 'NATIONALITY' : 'NATIONALITÄT'}</div><div class="t1-cv">{p.nationality}</div></div></div>}
|
||||
{p.maritalStatus && <div class="t1-ci"><span class="t1-icon">👥</span><div><div class="t1-cl">{T ? 'MARITAL STATUS' : 'FAMILIENSTAND'}</div><div class="t1-cv t1-cv-sm">{p.maritalStatus}</div></div></div>}
|
||||
</div>
|
||||
|
||||
<!-- Languages -->
|
||||
{d.languages.length > 0 && (
|
||||
<div>
|
||||
<div class="t1-section-head">{T ? 'LANGUAGES' : 'SPRACHEN'}</div>
|
||||
<div class="t1-lang-grid">
|
||||
{d.languages.map(l => (
|
||||
<><span class="t1-lang-name">{l.name.toUpperCase()}</span><span class="t1-lang-level">{l.level}</span></>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<!-- Skills / Interests -->
|
||||
{d.interests.length > 0 && (
|
||||
<div>
|
||||
<div class="t1-section-head">{T ? 'INTERESTS' : 'INTERESSEN'}</div>
|
||||
<ul class="t1-list">
|
||||
{d.interests.map(i => <li>{i.trim()}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{d.skills.length > 0 && (
|
||||
<div>
|
||||
<div class="t1-section-head">{T ? 'SKILLS' : 'KENNTNISSE'}</div>
|
||||
<ul class="t1-list">
|
||||
{d.skills.map(sk => <li><span class="t1-sk-name">{sk.name}</span> <span class="t1-sk-lvl">{['','•','••','•••','••••','•••••'][sk.level] || ''}</span></li>)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
<!-- MAIN -->
|
||||
<main class="t1-main">
|
||||
<div class="t1-name">{p.firstName} {p.lastName}</div>
|
||||
{p.jobTitle && <div class="t1-jobtitle">{p.jobTitle}</div>}
|
||||
|
||||
{d.profile && (
|
||||
<section class="t1-sec">
|
||||
<div class="t1-heading"><span class="t1-hicon">📋</span>{T ? 'PROFILE' : 'PROFIL'}</div>
|
||||
<p class="t1-profile-text">{d.profile}</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{d.education.length > 0 && (
|
||||
<section class="t1-sec">
|
||||
<div class="t1-heading"><span class="t1-hicon">🎓</span>{T ? 'EDUCATION & QUALIFICATIONS' : 'BILDUNG UND QUALIFIKATION'}</div>
|
||||
{d.education.map(e => (
|
||||
<div class="t1-entry">
|
||||
<div class="t1-edate">{datRange(e.dateFrom, e.dateTo, e.current)}</div>
|
||||
<div class="t1-eright">
|
||||
<div class="t1-etitle">{e.degree}</div>
|
||||
{e.school && <div class="t1-ecompany">{e.school}{e.location ? `, ${e.location}` : ''}</div>}
|
||||
{e.description && <div class="t1-edesc">{e.description}</div>}
|
||||
{e.bullets.filter(Boolean).length > 0 && (
|
||||
<ul class="t1-bullets">{e.bullets.filter(Boolean).map(b => <li>{b}</li>)}</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{d.experience.length > 0 && (
|
||||
<section class="t1-sec">
|
||||
<div class="t1-heading"><span class="t1-hicon">💼</span>{T ? 'WORK EXPERIENCE' : 'ARBEITSERFAHRUNG'}</div>
|
||||
{d.experience.map(e => (
|
||||
<div class="t1-entry">
|
||||
<div class="t1-edate">{datRange(e.dateFrom, e.dateTo, e.current)}</div>
|
||||
<div class="t1-eright">
|
||||
<div class="t1-etitle">{e.jobTitle}</div>
|
||||
{e.company && <div class="t1-ecompany">{e.company}{e.location ? `, ${e.location}` : ''}</div>}
|
||||
{e.description && <div class="t1-edesc">{e.description}</div>}
|
||||
{e.bullets.filter(Boolean).length > 0 && (
|
||||
<ul class="t1-bullets">{e.bullets.filter(Boolean).map(b => {
|
||||
const [bold, rest] = b.includes(':') ? [b.split(':')[0], b.split(':').slice(1).join(':')] : [null, b];
|
||||
return <li>{bold ? <><strong>{bold}</strong>:{rest}</> : rest}</li>;
|
||||
})}</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{d.skills.length > 0 && (
|
||||
<section class="t1-sec">
|
||||
<div class="t1-heading"><span class="t1-hicon">🔧</span>{T ? 'COMPETENCIES' : 'KOMPETENZEN'}</div>
|
||||
{Object.entries(d.skills.reduce((acc: any, sk) => {
|
||||
const cat = sk.category || (T ? 'Other' : 'Sonstiges');
|
||||
if (!acc[cat]) acc[cat] = [];
|
||||
acc[cat].push(sk);
|
||||
return acc;
|
||||
}, {})).map(([cat, skills]: any) => (
|
||||
<div class="t1-entry">
|
||||
<div class="t1-edate">{cat}</div>
|
||||
<div class="t1-eright">
|
||||
<div class="t1-edesc">{skills.map((sk: any) => `${sk.name}: ${['','Grundkenntnisse','Gut','Sehr gut','Experte','Meister'][sk.level] || sk.level}`).join(' · ')}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{d.certifications.length > 0 && (
|
||||
<section class="t1-sec">
|
||||
<div class="t1-heading"><span class="t1-hicon">📜</span>{T ? 'COURSES, TRAINING' : 'WEITERBILDUNGEN, KURSE'}</div>
|
||||
{d.certifications.map(c => (
|
||||
<div class="t1-entry">
|
||||
<div class="t1-edate">{c.dateFrom}{c.dateTo && c.dateTo !== c.dateFrom ? ` – ${c.dateTo}` : ''}</div>
|
||||
<div class="t1-eright">
|
||||
<div class="t1-etitle">{c.name}</div>
|
||||
{c.issuer && <div class="t1-ecompany">{c.issuer}{c.location ? `, ${c.location}` : ''}</div>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{d.achievements.length > 0 && (
|
||||
<section class="t1-sec">
|
||||
<div class="t1-heading"><span class="t1-hicon">🏆</span>{T ? 'ACHIEVEMENTS' : 'ERFOLGE'}</div>
|
||||
<ul class="t1-bullets">{d.achievements.filter(Boolean).map(a => <li>{a}</li>)}</ul>
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.t1-layout { display: grid; grid-template-columns: 220px 1fr; min-height: 1123px; }
|
||||
.t1-sidebar {
|
||||
background: var(--p, #1B2A5E);
|
||||
color: white;
|
||||
padding: 0 0 24px 0;
|
||||
}
|
||||
.t1-photo-wrap { padding: 24px; display: flex; justify-content: center; }
|
||||
.t1-photo { width: 130px; height: 130px; border-radius: 50%; object-fit: cover; border: 3px solid rgba(255,255,255,.3); }
|
||||
.t1-photo-ph { width: 130px; height: 130px; border-radius: 50%; background: rgba(255,255,255,.15); display: grid; place-items: center; color: rgba(255,255,255,.6); }
|
||||
.t1-section-head {
|
||||
font-size: 9.5px; font-weight: 700; letter-spacing: 2px;
|
||||
padding: 10px 16px 6px;
|
||||
border-bottom: 1px solid rgba(255,255,255,.2);
|
||||
margin-bottom: 8px;
|
||||
color: rgba(255,255,255,.9);
|
||||
}
|
||||
.t1-contact-list { padding: 0 12px 8px; }
|
||||
.t1-ci { display: flex; gap: 8px; align-items: flex-start; margin-bottom: 9px; }
|
||||
.t1-icon { font-size: 12px; margin-top: 1px; flex-shrink: 0; filter: brightness(0) invert(1); opacity: 0.9; }
|
||||
.t1-cl { font-size: 7.5px; font-weight: 700; letter-spacing: 1px; color: rgba(255,255,255,.6); text-transform: uppercase; }
|
||||
.t1-cv { font-size: 10px; color: rgba(255,255,255,.9); margin-top: 1px; }
|
||||
.t1-cv-sm { font-size: 9.5px; }
|
||||
.t1-lang-grid { display: grid; grid-template-columns: auto 1fr; gap: 4px 12px; padding: 0 12px 8px; font-size: 10px; }
|
||||
.t1-lang-name { font-weight: 700; color: white; }
|
||||
.t1-lang-level { color: rgba(255,255,255,.75); }
|
||||
.t1-list { list-style: none; padding: 0 12px 8px; }
|
||||
.t1-list li { font-size: 10px; color: rgba(255,255,255,.85); padding: 2px 0; padding-left: 10px; position: relative; }
|
||||
.t1-list li::before { content: '▪'; position: absolute; left: 0; color: rgba(255,255,255,.5); font-size: 8px; top: 3px; }
|
||||
.t1-sk-name { color: white; }
|
||||
.t1-sk-lvl { color: rgba(255,255,255,.5); font-size: 9px; }
|
||||
|
||||
/* Main */
|
||||
.t1-main { padding: 28px 28px 28px 24px; }
|
||||
.t1-name { font-size: 28px; font-weight: 700; color: var(--p, #1B2A5E); letter-spacing: -0.5px; margin-bottom: 2px; }
|
||||
.t1-jobtitle { font-size: 11px; color: #666; letter-spacing: 1px; text-transform: uppercase; margin-bottom: 16px; }
|
||||
.t1-sec { margin-bottom: 16px; }
|
||||
.t1-heading {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
font-size: 10px; font-weight: 700; letter-spacing: 2.5px;
|
||||
color: var(--p, #1B2A5E);
|
||||
border-bottom: 2px solid var(--p, #1B2A5E);
|
||||
padding-bottom: 4px; margin-bottom: 10px;
|
||||
}
|
||||
.t1-hicon { font-size: 13px; filter: brightness(0); opacity: 0.8; }
|
||||
.t1-entry { display: grid; grid-template-columns: 100px 1fr; gap: 8px; margin-bottom: 8px; }
|
||||
.t1-edate { font-size: 9.5px; color: #888; padding-top: 1px; }
|
||||
.t1-etitle { font-size: 11px; font-weight: 700; color: #1a1a1a; }
|
||||
.t1-ecompany { font-size: 10px; color: var(--a, #4A7BC5); margin-top: 1px; }
|
||||
.t1-edesc { font-size: 10px; color: #444; margin-top: 3px; }
|
||||
.t1-profile-text { font-size: 10.5px; color: #444; line-height: 1.55; }
|
||||
.t1-bullets { list-style: none; margin-top: 4px; }
|
||||
.t1-bullets li { font-size: 10px; color: #333; padding: 2px 0 2px 12px; position: relative; }
|
||||
.t1-bullets li::before { content: '▪'; position: absolute; left: 0; color: var(--p, #1B2A5E); font-size: 8px; top: 4px; }
|
||||
</style>
|
||||
140
src/components/templates/Template2.astro
Normal file
140
src/components/templates/Template2.astro
Normal file
@@ -0,0 +1,140 @@
|
||||
---
|
||||
import type { CVData, CVSettings } from '../../types';
|
||||
interface Props { data: CVData; settings: CVSettings; }
|
||||
const { data: d, settings: s } = Astro.props;
|
||||
const p = d.personal;
|
||||
const T = s.language === 'en';
|
||||
function datRange(from: string, to: string, cur: boolean) {
|
||||
return `${from} – ${cur ? (T ? 'present' : 'aktuell') : to}`;
|
||||
}
|
||||
---
|
||||
<div class="cv-a4 t2" style={`--p:${s.primaryColor};--a:${s.accentColor};font-family:${s.fontFamily?.replace(/"/g, "'")}`}>
|
||||
<!-- HEADER -->
|
||||
<header class="t2-header">
|
||||
<div class="t2-header-left">
|
||||
{s.showPhoto && (
|
||||
p.photo
|
||||
? <img src={p.photo} class="t2-photo" alt="" />
|
||||
: <div class="t2-photo-ph"><svg viewBox="0 0 24 24" fill="currentColor" width="40" height="40"><path d="M12 12c2.7 0 4.8-2.1 4.8-4.8S14.7 2.4 12 2.4 7.2 4.5 7.2 7.2 9.3 12 12 12zm0 2.4c-3.2 0-9.6 1.6-9.6 4.8v2.4h19.2v-2.4c0-3.2-6.4-4.8-9.6-4.8z"/></svg></div>
|
||||
)}
|
||||
</div>
|
||||
<div class="t2-header-mid">
|
||||
<div class="t2-name">{p.firstName} {p.lastName}</div>
|
||||
<div class="t2-jobtitle">{p.jobTitle}</div>
|
||||
{d.profile && <p class="t2-profile">{d.profile}</p>}
|
||||
</div>
|
||||
<div class="t2-header-right">
|
||||
{p.address && <div class="t2-contact-row"><span class="t2-ci">🏠</span>{p.address}{p.city ? `, ${p.city}` : ''}</div>}
|
||||
{p.email && <div class="t2-contact-row"><span class="t2-ci">✉️</span>{p.email}</div>}
|
||||
{p.phone && <div class="t2-contact-row"><span class="t2-ci">📱</span>{p.phone}</div>}
|
||||
{p.birthDate && <div class="t2-contact-row"><span class="t2-ci">📅</span>{T ? 'Born ' : 'Geb. '}{p.birthDate}{p.birthPlace ? ` in ${p.birthPlace}` : ''}</div>}
|
||||
{p.linkedin && <div class="t2-contact-row"><span class="t2-ci">🔗</span>{p.linkedin}</div>}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="t2-body">
|
||||
<!-- EXPERIENCE -->
|
||||
{d.experience.length > 0 && (
|
||||
<section class="t2-sec">
|
||||
<div class="t2-heading">{T ? 'Work Experience' : 'Berufserfahrung'}</div>
|
||||
{d.experience.map(e => (
|
||||
<div class="t2-tl-item">
|
||||
<div class="t2-tl-left">
|
||||
<div class="t2-tl-dot"></div>
|
||||
<div class="t2-tl-company">{e.company}{e.location ? `, ${e.location}` : ''}</div>
|
||||
<div class="t2-tl-date">{datRange(e.dateFrom, e.dateTo, e.current)}</div>
|
||||
</div>
|
||||
<div class="t2-tl-right">
|
||||
<div class="t2-tl-title">{e.jobTitle}</div>
|
||||
{e.description && <p class="t2-tl-desc">{e.description}</p>}
|
||||
{e.bullets.filter(Boolean).map(b => <div class="t2-bullet">– {b}</div>)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<!-- EDUCATION -->
|
||||
{d.education.length > 0 && (
|
||||
<section class="t2-sec">
|
||||
<div class="t2-heading">{T ? 'Education' : 'Bildungsweg'}</div>
|
||||
{d.education.map(e => (
|
||||
<div class="t2-tl-item">
|
||||
<div class="t2-tl-left">
|
||||
<div class="t2-tl-dot"></div>
|
||||
<div class="t2-tl-company">{e.school}{e.location ? `, ${e.location}` : ''}</div>
|
||||
<div class="t2-tl-date">{datRange(e.dateFrom, e.dateTo, e.current)}</div>
|
||||
</div>
|
||||
<div class="t2-tl-right">
|
||||
<div class="t2-tl-title">{e.degree}</div>
|
||||
{e.description && <p class="t2-tl-desc">{e.description}</p>}
|
||||
{e.bullets.filter(Boolean).map(b => <div class="t2-bullet">– {b}</div>)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<!-- BOTTOM 3-COL -->
|
||||
<div class="t2-bottom">
|
||||
{d.interests.length > 0 && (
|
||||
<div class="t2-bot-col">
|
||||
<div class="t2-heading">{T ? 'Interests' : 'Engagement'}</div>
|
||||
{d.interests.map(i => <div class="t2-bot-item">• {i.trim()}</div>)}
|
||||
</div>
|
||||
)}
|
||||
{d.languages.length > 0 && (
|
||||
<div class="t2-bot-col">
|
||||
<div class="t2-heading">{T ? 'Languages' : 'Sprachkenntnisse'}</div>
|
||||
{d.languages.map(l => (
|
||||
<div class="t2-lang-row">
|
||||
<span class="t2-lang-name">{l.name}</span>
|
||||
<div class="t2-bar"><div class="t2-bar-fill" style={`width:${{'A1':20,'A2':35,'B1':50,'B2':65,'C1':80,'C2':95,'Muttersprache':100,'Native':100}[l.level] || 60}%`}></div></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{d.skills.length > 0 && (
|
||||
<div class="t2-bot-col">
|
||||
<div class="t2-heading">{T ? 'Skills' : 'Fähigkeiten'}</div>
|
||||
{d.skills.map(sk => (
|
||||
<div class="t2-lang-row">
|
||||
<span class="t2-lang-name">{sk.name}</span>
|
||||
<div class="t2-bar"><div class="t2-bar-fill" style={`width:${sk.level * 20}%`}></div></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.t2-header { display: grid; grid-template-columns: auto 1fr auto; gap: 20px; padding: 24px 28px 16px; border-bottom: 2px solid #eee; align-items: start; }
|
||||
.t2-photo { width: 90px; height: 90px; border-radius: 50%; object-fit: cover; border: 3px solid #eee; }
|
||||
.t2-photo-ph { width: 90px; height: 90px; border-radius: 50%; background: #e5e8f0; display: grid; place-items: center; color: #aaa; }
|
||||
.t2-name { font-size: 26px; font-weight: 300; color: #222; letter-spacing: -0.3px; }
|
||||
.t2-name em { font-style: normal; font-weight: 700; }
|
||||
.t2-jobtitle { font-size: 10px; letter-spacing: 3px; text-transform: uppercase; color: #888; margin: 4px 0; }
|
||||
.t2-profile { font-size: 9.5px; color: #555; margin-top: 6px; line-height: 1.5; max-width: 360px; }
|
||||
.t2-header-right { min-width: 160px; }
|
||||
.t2-contact-row { display: flex; align-items: flex-start; gap: 6px; font-size: 9.5px; color: #555; margin-bottom: 5px; }
|
||||
.t2-ci { font-size: 10px; flex-shrink: 0; filter: brightness(0); opacity: 0.6; }
|
||||
.t2-body { padding: 16px 28px; }
|
||||
.t2-sec { margin-bottom: 14px; }
|
||||
.t2-heading { font-size: 13px; font-weight: 300; letter-spacing: 0.5px; color: var(--p,#1B2A5E); border-bottom: 1px solid #ddd; padding-bottom: 4px; margin-bottom: 10px; }
|
||||
.t2-tl-item { display: grid; grid-template-columns: 140px 1fr; gap: 10px; margin-bottom: 10px; position: relative; }
|
||||
.t2-tl-left { padding-top: 1px; }
|
||||
.t2-tl-dot { width: 8px; height: 8px; border-radius: 50%; background: #ccc; margin-bottom: 4px; }
|
||||
.t2-tl-company { font-size: 10.5px; font-weight: 600; color: #333; line-height: 1.3; }
|
||||
.t2-tl-date { font-size: 9px; color: #888; margin-top: 2px; }
|
||||
.t2-tl-title { font-size: 11px; font-weight: 600; color: #1a1a1a; }
|
||||
.t2-tl-desc { font-size: 9.5px; color: #555; margin-top: 3px; line-height: 1.45; }
|
||||
.t2-bullet { font-size: 9.5px; color: #555; margin-top: 2px; padding-left: 4px; }
|
||||
.t2-bottom { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 20px; margin-top: 10px; padding-top: 10px; border-top: 1px solid #eee; }
|
||||
.t2-bot-item { font-size: 9.5px; color: #555; padding: 2px 0; }
|
||||
.t2-lang-row { display: flex; flex-direction: column; gap: 2px; margin-bottom: 8px; }
|
||||
.t2-lang-name { font-size: 10px; color: #333; }
|
||||
.t2-bar { height: 4px; background: #e8e8e8; border-radius: 2px; width: 100%; }
|
||||
.t2-bar-fill { height: 100%; background: var(--p,#1B2A5E); border-radius: 2px; }
|
||||
</style>
|
||||
161
src/components/templates/Template3.astro
Normal file
161
src/components/templates/Template3.astro
Normal file
@@ -0,0 +1,161 @@
|
||||
---
|
||||
import type { CVData, CVSettings } from '../../types';
|
||||
interface Props { data: CVData; settings: CVSettings; }
|
||||
const { data: d, settings: s } = Astro.props;
|
||||
const p = d.personal;
|
||||
const T = s.language === 'en';
|
||||
function datRange(from: string, to: string, cur: boolean) {
|
||||
return `${from} – ${cur ? (T ? 'present' : 'aktuell') : to}`;
|
||||
}
|
||||
---
|
||||
<div class="cv-a4 t3" style={`--p:${s.primaryColor};--a:${s.accentColor};font-family:${s.fontFamily?.replace(/"/g, "'")}`}>
|
||||
<!-- HEADER -->
|
||||
<header class="t3-header">
|
||||
<div class="t3-header-left">
|
||||
{s.showPhoto && (
|
||||
p.photo
|
||||
? <img src={p.photo} class="t3-photo" alt="" />
|
||||
: <div class="t3-photo-ph"><svg viewBox="0 0 24 24" fill="currentColor" width="32" height="32"><path d="M12 12c2.7 0 4.8-2.1 4.8-4.8S14.7 2.4 12 2.4 7.2 4.5 7.2 7.2 9.3 12 12 12zm0 2.4c-3.2 0-9.6 1.6-9.6 4.8v2.4h19.2v-2.4c0-3.2-6.4-4.8-9.6-4.8z"/></svg></div>
|
||||
)}
|
||||
</div>
|
||||
<div class="t3-header-right">
|
||||
<h1 class="t3-name">{p.firstName} <strong>{p.lastName}</strong></h1>
|
||||
<div class="t3-rule"></div>
|
||||
<div class="t3-jobtitle">{p.jobTitle}</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- BODY -->
|
||||
<div class="t3-body">
|
||||
<!-- LEFT COL -->
|
||||
<aside class="t3-left">
|
||||
<section class="t3-sec">
|
||||
<div class="t3-sh">{T ? 'P E R S O N A L' : 'P E R S Ö N L I C H E S'}</div>
|
||||
<div class="t3-rule-sm"></div>
|
||||
{p.birthDate && <><div class="t3-field-label">{T ? 'DATE OF BIRTH' : 'GEBURTSDATUM'}</div><div class="t3-field-val">{p.birthDate}{p.birthPlace ? ` in ${p.birthPlace}` : ''}</div></>}
|
||||
{(p.address || p.city) && <><div class="t3-field-label">{T ? 'ADDRESS' : 'ANSCHRIFT'}</div><div class="t3-field-val">{p.address}{p.address && (p.city || p.zipCode) ? ', ' : ''}{p.zipCode} {p.city}</div></>}
|
||||
{p.maritalStatus && <><div class="t3-field-label">{T ? 'MARITAL STATUS' : 'FAMILIENSTAND'}</div><div class="t3-field-val">{p.maritalStatus}</div></>}
|
||||
{p.nationality && <><div class="t3-field-label">{T ? 'NATIONALITY' : 'NATIONALITÄT'}</div><div class="t3-field-val">{p.nationality}</div></>}
|
||||
</section>
|
||||
|
||||
{d.languages.length > 0 && (
|
||||
<section class="t3-sec">
|
||||
<div class="t3-sh">{T ? 'L A N G U A G E S' : 'S P R A C H E N'}</div>
|
||||
<div class="t3-rule-sm"></div>
|
||||
{d.languages.map(l => (<>
|
||||
<div class="t3-field-label">{l.name.toUpperCase()}</div>
|
||||
<div class="t3-field-val">{l.level}</div>
|
||||
</>))}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{d.skills.length > 0 && (
|
||||
<section class="t3-sec">
|
||||
<div class="t3-sh">{T ? 'S K I L L S' : 'K E N N T N I S S E'}</div>
|
||||
<div class="t3-rule-sm"></div>
|
||||
{d.skills.map(sk => (
|
||||
<div class="t3-skill-row">
|
||||
<div class="t3-skill-name">{sk.name}</div>
|
||||
<div class="t3-skill-bar">
|
||||
<div class="t3-skill-track">
|
||||
<div class="t3-skill-fill" style={`width:${sk.level*20}%`}></div>
|
||||
<div class="t3-skill-dot" style={`left:${sk.level*20}%`}></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{d.interests.length > 0 && (
|
||||
<><div class="t3-field-label" style="margin-top:8px">{T ? 'OTHER' : 'SONSTIGE:'}</div>
|
||||
<div class="t3-field-val">{d.interests.join(', ')}</div></>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<!-- Contact bottom -->
|
||||
<div class="t3-contact-bottom">
|
||||
{p.phone && <span><span>📞</span> {p.phone}</span>}
|
||||
{p.email && <span><span>✉️</span> {p.email}</span>}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- RIGHT COL -->
|
||||
<main class="t3-right">
|
||||
{d.experience.length > 0 && (
|
||||
<section class="t3-sec">
|
||||
<div class="t3-sh">{T ? 'W O R K E X P E R I E N C E' : 'B E R U F S E R F A H R U N G'}</div>
|
||||
<div class="t3-rule-main"></div>
|
||||
{d.experience.map(e => (
|
||||
<div class="t3-entry">
|
||||
<div class="t3-e-title">{e.jobTitle}</div>
|
||||
<div class="t3-e-sub">{e.company}{e.location ? ` | ${e.location}` : ''}{e.dateFrom ? ` | ${datRange(e.dateFrom, e.dateTo, e.current)}` : ''}</div>
|
||||
{e.description && <p class="t3-e-desc">{e.description}</p>}
|
||||
{e.bullets.filter(Boolean).map(b => <div class="t3-e-bullet">• {b}</div>)}
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{d.education.length > 0 && (
|
||||
<section class="t3-sec">
|
||||
<div class="t3-sh">{T ? 'E D U C A T I O N' : 'B I L D U N G S W E G'}</div>
|
||||
<div class="t3-rule-main"></div>
|
||||
{d.education.map(e => (
|
||||
<div class="t3-entry">
|
||||
<div class="t3-e-title">{e.degree}</div>
|
||||
<div class="t3-e-sub">{e.school}{e.location ? ` | ${e.location}` : ''}{e.dateFrom ? ` | ${datRange(e.dateFrom, e.dateTo, e.current)}` : ''}</div>
|
||||
{e.description && <p class="t3-e-desc">{e.description}</p>}
|
||||
{e.bullets.filter(Boolean).map(b => <div class="t3-e-bullet">• {b}</div>)}
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{d.certifications.length > 0 && (
|
||||
<section class="t3-sec">
|
||||
<div class="t3-sh">{T ? 'C E R T I F I C A T I O N S' : 'Z E R T I F I K A T E'}</div>
|
||||
<div class="t3-rule-main"></div>
|
||||
{d.certifications.map(c => (
|
||||
<div class="t3-entry">
|
||||
<div class="t3-e-title">{c.name}</div>
|
||||
<div class="t3-e-sub">{c.issuer}{c.location ? ` | ${c.location}` : ''}{c.dateFrom ? ` | ${c.dateFrom}` : ''}</div>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.t3-header { display: grid; grid-template-columns: 100px 1fr; gap: 20px; padding: 28px 28px 0; align-items: start; }
|
||||
.t3-photo { width: 88px; height: 88px; border-radius: 50%; object-fit: cover; border: 2px solid #ddd; }
|
||||
.t3-photo-ph { width: 88px; height: 88px; border-radius: 50%; background: #f0f0f0; display: grid; place-items: center; color: #bbb; }
|
||||
.t3-name { font-size: 24px; font-weight: 300; color: #333; letter-spacing: 1px; }
|
||||
.t3-name strong { font-weight: 700; }
|
||||
.t3-rule { border: none; border-top: 1px solid #ccc; margin: 8px 0; }
|
||||
.t3-jobtitle { font-size: 9px; letter-spacing: 4px; text-transform: uppercase; color: #888; }
|
||||
.t3-body { display: grid; grid-template-columns: 160px 1fr; gap: 0; padding: 20px 0 0; }
|
||||
.t3-left { padding: 0 16px 24px 28px; border-right: 1px solid #e8e8e8; }
|
||||
.t3-right { padding: 0 28px 24px 20px; }
|
||||
.t3-sec { margin-bottom: 16px; }
|
||||
.t3-sh { font-size: 8px; letter-spacing: 3px; font-weight: 600; color: #666; margin-bottom: 4px; }
|
||||
.t3-rule-sm { border: none; border-top: 1px solid #ddd; margin-bottom: 8px; }
|
||||
.t3-rule-main { border: none; border-top: 1px solid #ccc; margin-bottom: 10px; }
|
||||
.t3-field-label { font-size: 7.5px; letter-spacing: 1.5px; font-weight: 600; color: #888; text-transform: uppercase; margin-top: 6px; }
|
||||
.t3-field-val { font-size: 9.5px; color: #444; }
|
||||
.t3-skill-row { margin-bottom: 8px; }
|
||||
.t3-skill-name { font-size: 9.5px; color: #444; margin-bottom: 3px; }
|
||||
.t3-skill-bar { padding-bottom: 2px; }
|
||||
.t3-skill-track { position: relative; height: 1px; background: #ccc; margin: 6px 0 4px; }
|
||||
.t3-skill-fill { position: absolute; left: 0; top: 0; height: 100%; background: #888; }
|
||||
.t3-skill-dot { position: absolute; top: 50%; transform: translate(-50%, -50%); width: 7px; height: 7px; border-radius: 50%; background: #555; border: 1px solid white; box-shadow: 0 0 0 1px #888; }
|
||||
.t3-contact-bottom { position: absolute; bottom: 28px; left: 28px; width: 150px; display: flex; flex-direction: column; gap: 4px; }
|
||||
.t3-contact-bottom span { font-size: 8.5px; color: #666; }
|
||||
.t3-contact-bottom span span { font-size: 10px; filter: brightness(0); opacity: 0.5; margin-right: 4px; }
|
||||
.t3-entry { margin-bottom: 10px; }
|
||||
.t3-e-title { font-size: 9.5px; font-weight: 600; text-transform: uppercase; color: #333; letter-spacing: 0.5px; }
|
||||
.t3-e-sub { font-size: 9px; color: #888; text-decoration: underline; text-decoration-color: #ccc; margin-top: 2px; }
|
||||
.t3-e-desc { font-size: 9.5px; color: #555; margin-top: 4px; line-height: 1.5; }
|
||||
.t3-e-bullet { font-size: 9.5px; color: #555; margin-top: 2px; }
|
||||
.t3 { position: relative; }
|
||||
</style>
|
||||
122
src/components/templates/Template4.astro
Normal file
122
src/components/templates/Template4.astro
Normal file
@@ -0,0 +1,122 @@
|
||||
---
|
||||
import type { CVData, CVSettings } from '../../types';
|
||||
interface Props { data: CVData; settings: CVSettings; }
|
||||
const { data: d, settings: s } = Astro.props;
|
||||
const p = d.personal;
|
||||
const T = s.language === 'en';
|
||||
function datRange(from: string, to: string, cur: boolean) {
|
||||
return `${from}–${cur ? (T ? 'present' : 'aktuell') : to}`;
|
||||
}
|
||||
---
|
||||
<div class="cv-a4 t4" style={`--p:${s.primaryColor};--a:${s.accentColor};font-family:${s.fontFamily?.replace(/"/g, "'")}`}>
|
||||
<div class="t4-layout">
|
||||
<!-- LEFT -->
|
||||
<aside class="t4-left">
|
||||
<div class="t4-name-block">
|
||||
<div class="t4-firstname">{p.firstName?.toUpperCase() || 'VORNAME'}</div>
|
||||
<div class="t4-lastname-wrap">
|
||||
<div class="t4-lastname">{p.lastName?.toUpperCase() || 'NACHNAME'}</div>
|
||||
</div>
|
||||
<div class="t4-jobtitle-left">{p.jobTitle}</div>
|
||||
</div>
|
||||
|
||||
{s.showPhoto && (
|
||||
<div class="t4-photo-wrap">
|
||||
{p.photo
|
||||
? <img src={p.photo} class="t4-photo" alt="" />
|
||||
: <div class="t4-photo-ph"><svg viewBox="0 0 24 24" fill="currentColor" width="48" height="48"><path d="M12 12c2.7 0 4.8-2.1 4.8-4.8S14.7 2.4 12 2.4 7.2 4.5 7.2 7.2 9.3 12 12 12zm0 2.4c-3.2 0-9.6 1.6-9.6 4.8v2.4h19.2v-2.4c0-3.2-6.4-4.8-9.6-4.8z"/></svg></div>
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div class="t4-personal-section">
|
||||
<div class="t4-sh">{T ? 'PERSONAL' : 'PERSÖNLICHES'}</div>
|
||||
{p.phone && <div class="t4-pi"><span>📞</span>{p.phone}</div>}
|
||||
{p.email && <div class="t4-pi"><span>✉️</span>{p.email}</div>}
|
||||
{p.birthDate && <div class="t4-pi"><span>📅</span>{p.birthDate}{p.birthPlace ? ` in ${p.birthPlace}` : ''}</div>}
|
||||
{(p.address || p.city) && <div class="t4-pi"><span>🏠</span>{p.address}, {p.zipCode} {p.city}</div>}
|
||||
{p.nationality && <div class="t4-pi"><span>🏳️</span>{p.nationality}</div>}
|
||||
{d.languages.length > 0 && d.languages.map(l => <div class="t4-pi"><span>💬</span>{l.name}: {l.level}</div>)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- RIGHT -->
|
||||
<main class="t4-right">
|
||||
{d.experience.length > 0 && (
|
||||
<section class="t4-sec">
|
||||
<div class="t4-sh-right">{T ? 'WORK EXPERIENCE' : 'BERUFLICHER WERDEGANG'}</div>
|
||||
{d.experience.map(e => (
|
||||
<div class="t4-entry">
|
||||
<div class="t4-e-jobtitle">{e.jobTitle}</div>
|
||||
<div class="t4-e-line"><span class="t4-e-date">{datRange(e.dateFrom, e.dateTo, e.current)}</span> <span class="t4-e-company">{e.company}</span></div>
|
||||
{e.description && <p class="t4-e-desc">{e.description}</p>}
|
||||
{e.bullets.filter(Boolean).map(b => <div class="t4-e-bullet">• {b}</div>)}
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{d.education.length > 0 && (
|
||||
<section class="t4-sec">
|
||||
<div class="t4-sh-right">{T ? 'EDUCATION' : 'SCHULBILDUNG'}</div>
|
||||
{d.education.map(e => (
|
||||
<div class="t4-entry">
|
||||
<div class="t4-e-school">{e.school}</div>
|
||||
<div class="t4-e-degree">{e.degree}{e.dateFrom ? `, ${e.dateFrom.split('/').pop() || e.dateFrom}` : ''}</div>
|
||||
{e.description && <p class="t4-e-desc">{e.description}</p>}
|
||||
{e.bullets.filter(Boolean).map(b => <div class="t4-e-bullet">• {b}</div>)}
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{d.skills.length > 0 && (
|
||||
<section class="t4-sec">
|
||||
<div class="t4-sh-right">{T ? 'QUALIFICATIONS' : 'QUALIFIKATIONEN'}</div>
|
||||
{d.skills.map(sk => <div class="t4-e-bullet">• {sk.name}</div>)}
|
||||
{d.achievements.filter(Boolean).map(a => <div class="t4-e-bullet">• {a}</div>)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{d.certifications.length > 0 && (
|
||||
<section class="t4-sec">
|
||||
<div class="t4-sh-right">{T ? 'COURSES' : 'WEITERBILDUNGEN'}</div>
|
||||
{d.certifications.map(c => (
|
||||
<div class="t4-entry">
|
||||
<div class="t4-e-jobtitle">{c.name}</div>
|
||||
<div class="t4-e-line"><span class="t4-e-date">{c.dateFrom}</span> <span class="t4-e-company">{c.issuer}</span></div>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.t4-layout { display: grid; grid-template-columns: 240px 1fr; min-height: 1123px; }
|
||||
.t4-left { padding: 28px 16px 28px 28px; background: white; position: relative; }
|
||||
.t4-firstname { font-size: 40px; font-weight: 100; letter-spacing: 4px; color: #1a1a1a; line-height: 1; }
|
||||
.t4-lastname-wrap { background: #9ea8b8; display: inline-block; padding: 2px 8px 2px 0; margin: 2px 0; }
|
||||
.t4-lastname { font-size: 18px; font-weight: 700; letter-spacing: 4px; color: white; }
|
||||
.t4-jobtitle-left { font-size: 9px; letter-spacing: 3px; text-transform: uppercase; color: #888; margin: 8px 0 16px; }
|
||||
.t4-photo-wrap { width: 100%; aspect-ratio: 3/4; margin-bottom: 16px; overflow: hidden; }
|
||||
.t4-photo { width: 100%; height: 100%; object-fit: cover; filter: grayscale(20%); }
|
||||
.t4-photo-ph { width: 100%; height: 200px; background: #e8e8e8; display: grid; place-items: center; color: #bbb; }
|
||||
.t4-personal-section { margin-top: 12px; }
|
||||
.t4-sh { font-size: 8px; letter-spacing: 3px; font-weight: 700; color: #888; text-transform: uppercase; margin-bottom: 8px; }
|
||||
.t4-pi { display: flex; gap: 6px; align-items: flex-start; font-size: 9.5px; color: #555; margin-bottom: 5px; }
|
||||
.t4-pi span { font-size: 10px; flex-shrink: 0; filter: brightness(0); opacity: 0.6; }
|
||||
.t4-right { padding: 28px 28px 28px 20px; background: #fafafa; border-left: 1px solid #e8e8e8; }
|
||||
.t4-sec { margin-bottom: 18px; }
|
||||
.t4-sh-right { font-size: 8.5px; letter-spacing: 3.5px; font-weight: 600; color: #888; text-transform: uppercase; border-bottom: 1px solid #ccc; padding-bottom: 4px; margin-bottom: 10px; }
|
||||
.t4-entry { margin-bottom: 10px; }
|
||||
.t4-e-jobtitle { font-size: 11px; font-style: italic; font-weight: 600; color: #333; }
|
||||
.t4-e-school { font-size: 11px; font-weight: 600; color: #333; text-decoration: underline; text-decoration-color: #ccc; }
|
||||
.t4-e-degree { font-size: 10px; color: #666; margin-top: 1px; }
|
||||
.t4-e-line { font-size: 9.5px; color: #888; margin-top: 2px; text-decoration: underline; text-decoration-color: #ddd; }
|
||||
.t4-e-date { color: #888; }
|
||||
.t4-e-company { color: #888; }
|
||||
.t4-e-desc { font-size: 9.5px; color: #555; margin-top: 4px; line-height: 1.5; }
|
||||
.t4-e-bullet { font-size: 9.5px; color: #555; margin-top: 3px; }
|
||||
</style>
|
||||
17
src/env.d.ts
vendored
Normal file
17
src/env.d.ts
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
/// <reference path="../.astro/types.d.ts" />
|
||||
/// <reference types="astro/client" />
|
||||
|
||||
declare namespace App {
|
||||
interface Locals {
|
||||
user: {
|
||||
id: number;
|
||||
email: string;
|
||||
};
|
||||
session: {
|
||||
id: string;
|
||||
user_id: number;
|
||||
email: string;
|
||||
expires_at: number;
|
||||
};
|
||||
}
|
||||
}
|
||||
105
src/lib/auth.ts
Normal file
105
src/lib/auth.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { randomBytes, createHmac } from 'crypto';
|
||||
import nodemailer from 'nodemailer';
|
||||
import { upsertUser, setOTP, consumeOTP, createSession, getSession, deleteSession } from './db';
|
||||
|
||||
const SESSION_COOKIE = 'lv_session';
|
||||
const SESSION_DAYS = 30;
|
||||
const OTP_MINUTES = parseInt(import.meta.env.OTP_EXPIRES_MINUTES || '10');
|
||||
|
||||
// ── Mailer ────────────────────────────────────────────────────────────
|
||||
let _transport: nodemailer.Transporter | null = null;
|
||||
|
||||
function getTransport() {
|
||||
if (!_transport) {
|
||||
_transport = nodemailer.createTransport({
|
||||
host: import.meta.env.SMTP_HOST || 'localhost',
|
||||
port: parseInt(import.meta.env.SMTP_PORT || '587'),
|
||||
secure: import.meta.env.SMTP_SECURE === 'true',
|
||||
auth: import.meta.env.SMTP_USER ? {
|
||||
user: import.meta.env.SMTP_USER,
|
||||
pass: import.meta.env.SMTP_PASS,
|
||||
} : undefined,
|
||||
tls: {
|
||||
rejectUnauthorized: false
|
||||
}
|
||||
});
|
||||
}
|
||||
return _transport;
|
||||
}
|
||||
|
||||
// ── OTP ───────────────────────────────────────────────────────────────
|
||||
export function generateOTP(): string {
|
||||
return Math.floor(100000 + Math.random() * 900000).toString();
|
||||
}
|
||||
|
||||
export async function sendOTP(email: string, lang: string = 'de'): Promise<{ ok: boolean; error?: string }> {
|
||||
try {
|
||||
const user = upsertUser(email);
|
||||
const otp = generateOTP();
|
||||
const expires = Math.floor(Date.now() / 1000) + OTP_MINUTES * 60;
|
||||
setOTP(email, otp, expires);
|
||||
|
||||
const isDE = lang === 'de';
|
||||
const subject = isDE ? `Dein Anmeldecode: ${otp}` : `Your login code: ${otp}`;
|
||||
const html = `
|
||||
<div style="font-family:Arial,sans-serif;max-width:480px;margin:auto;padding:32px;background:#f9f9f9;border-radius:8px">
|
||||
<h2 style="color:#1B2A5E">${isDE ? 'Lebenslauf-App – Anmeldung' : 'Resume App – Login'}</h2>
|
||||
<p>${isDE ? 'Dein Einmal-Code lautet:' : 'Your one-time code is:'}</p>
|
||||
<div style="font-size:36px;font-weight:bold;letter-spacing:8px;color:#1B2A5E;padding:16px;background:#fff;border-radius:6px;text-align:center;border:2px solid #e0e0e0">${otp}</div>
|
||||
<p style="color:#777;font-size:13px;margin-top:16px">${isDE ? `Gültig für ${OTP_MINUTES} Minuten.` : `Valid for ${OTP_MINUTES} minutes.`}</p>
|
||||
</div>`;
|
||||
|
||||
await getTransport().sendMail({
|
||||
from: import.meta.env.SMTP_FROM || 'Lebenslauf-App <noreply@localhost>',
|
||||
to: email,
|
||||
subject,
|
||||
html,
|
||||
});
|
||||
|
||||
return { ok: true };
|
||||
} catch (err: any) {
|
||||
// In dev mode, print OTP to console
|
||||
console.log(`\n🔑 OTP for ${email}: ${await getStoredOTP(email)} (mail failed: ${err.message})\n`);
|
||||
return { ok: true }; // Don't expose mail errors to user
|
||||
}
|
||||
}
|
||||
|
||||
async function getStoredOTP(email: string): Promise<string> {
|
||||
const { getDb } = await import('./db');
|
||||
const row = getDb().prepare('SELECT otp FROM users WHERE email = ?').get(email) as any;
|
||||
return row?.otp || '???';
|
||||
}
|
||||
|
||||
export function verifyOTP(email: string, otp: string): boolean {
|
||||
return consumeOTP(email, otp);
|
||||
}
|
||||
|
||||
// ── Sessions ──────────────────────────────────────────────────────────
|
||||
export function createNewSession(userId: number): string {
|
||||
const sessionId = randomBytes(32).toString('hex');
|
||||
const expiresAt = Math.floor(Date.now() / 1000) + SESSION_DAYS * 86400;
|
||||
createSession(userId, sessionId, expiresAt);
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
export function getSessionFromRequest(request: Request): any {
|
||||
const cookie = request.headers.get('cookie') || '';
|
||||
const match = cookie.match(new RegExp(`${SESSION_COOKIE}=([^;]+)`));
|
||||
if (!match) return null;
|
||||
return getSession(match[1]);
|
||||
}
|
||||
|
||||
export function makeSessionCookie(sessionId: string): string {
|
||||
const maxAge = SESSION_DAYS * 86400;
|
||||
return `${SESSION_COOKIE}=${sessionId}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${maxAge}`;
|
||||
}
|
||||
|
||||
export function clearSessionCookie(): string {
|
||||
return `${SESSION_COOKIE}=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0`;
|
||||
}
|
||||
|
||||
export function logout(request: Request) {
|
||||
const cookie = request.headers.get('cookie') || '';
|
||||
const match = cookie.match(new RegExp(`${SESSION_COOKIE}=([^;]+)`));
|
||||
if (match) deleteSession(match[1]);
|
||||
}
|
||||
245
src/lib/db.ts
Normal file
245
src/lib/db.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { join, dirname } from 'path';
|
||||
import { existsSync, mkdirSync } from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import type { CV, CVData, CVSettings } from '../types';
|
||||
import { DEFAULT_DATA, DEFAULT_SETTINGS } from '../types';
|
||||
|
||||
const DB_PATH = import.meta.env.DB_PATH || join(process.cwd(), 'data', 'lebenslauf.db');
|
||||
const dir = dirname(DB_PATH);
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||
|
||||
let _db: Database.Database | null = null;
|
||||
|
||||
export function getDb(): Database.Database {
|
||||
if (!_db) {
|
||||
_db = new Database(DB_PATH);
|
||||
_db.pragma('journal_mode = WAL');
|
||||
_db.pragma('foreign_keys = ON');
|
||||
initSchema(_db);
|
||||
}
|
||||
return _db;
|
||||
}
|
||||
|
||||
function initSchema(db: Database.Database) {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
otp TEXT,
|
||||
otp_expires INTEGER,
|
||||
created_at INTEGER DEFAULT (unixepoch())
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS profiles (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL,
|
||||
title TEXT NOT NULL DEFAULT 'Mein Profil',
|
||||
language TEXT NOT NULL DEFAULT 'de',
|
||||
data TEXT NOT NULL DEFAULT '{}',
|
||||
created_at INTEGER DEFAULT (unixepoch()),
|
||||
updated_at INTEGER DEFAULT (unixepoch()),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS cvs (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL,
|
||||
profile_id TEXT NOT NULL,
|
||||
hash TEXT UNIQUE NOT NULL,
|
||||
title TEXT NOT NULL DEFAULT 'Mein Lebenslauf',
|
||||
template INTEGER NOT NULL DEFAULT 1,
|
||||
settings TEXT NOT NULL DEFAULT '{}',
|
||||
public INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER DEFAULT (unixepoch()),
|
||||
updated_at INTEGER DEFAULT (unixepoch()),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (profile_id) REFERENCES profiles(id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
|
||||
// Migration for cvs: add profile_id if not exists
|
||||
try {
|
||||
const tableInfo = db.prepare('PRAGMA table_info(cvs)').all() as any[];
|
||||
const hasProfileId = tableInfo.some(col => col.name === 'profile_id');
|
||||
if (!hasProfileId) {
|
||||
db.prepare('ALTER TABLE cvs ADD COLUMN profile_id TEXT').run();
|
||||
|
||||
// Migrate embedded data into profiles
|
||||
const allOldCVs = db.prepare('SELECT * FROM cvs WHERE profile_id IS NULL').all() as any[];
|
||||
for (const old of allOldCVs) {
|
||||
const profId = Math.random().toString(36).slice(2, 10);
|
||||
db.prepare('INSERT INTO profiles (id, user_id, title, language, data, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)')
|
||||
.run(profId, old.user_id, old.title + ' (Data)', 'de', old.data || '{}', old.created_at, old.updated_at);
|
||||
db.prepare('UPDATE cvs SET profile_id = ? WHERE id = ?').run(profId, old.id);
|
||||
}
|
||||
}
|
||||
} catch(e) { console.error("Migration error:", e); }
|
||||
}
|
||||
|
||||
// ── Users ─────────────────────────────────────────────────────────────
|
||||
export function getUser(email: string) {
|
||||
const db = getDb();
|
||||
return db.prepare('SELECT * FROM users WHERE email = ?').get(email) as any;
|
||||
}
|
||||
|
||||
export function upsertUser(email: string) {
|
||||
const db = getDb();
|
||||
db.prepare('INSERT OR IGNORE INTO users (email) VALUES (?)').run(email);
|
||||
return getUser(email);
|
||||
}
|
||||
|
||||
export function setOTP(email: string, otp: string, expiresAt: number) {
|
||||
const db = getDb();
|
||||
db.prepare('UPDATE users SET otp = ?, otp_expires = ? WHERE email = ?').run(otp, expiresAt, email);
|
||||
}
|
||||
|
||||
export function consumeOTP(email: string, otp: string): boolean {
|
||||
const db = getDb();
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const user = db.prepare(
|
||||
'SELECT id FROM users WHERE email = ? AND otp = ? AND otp_expires > ?'
|
||||
).get(email, otp, now) as any;
|
||||
if (user) {
|
||||
db.prepare('UPDATE users SET otp = NULL, otp_expires = NULL WHERE email = ?').run(email);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── Sessions ──────────────────────────────────────────────────────────
|
||||
export function createSession(userId: number, sessionId: string, expiresAt: number) {
|
||||
const db = getDb();
|
||||
db.prepare('INSERT INTO sessions (id, user_id, expires_at) VALUES (?, ?, ?)').run(sessionId, userId, expiresAt);
|
||||
}
|
||||
|
||||
export function getSession(sessionId: string) {
|
||||
const db = getDb();
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
return db.prepare(`
|
||||
SELECT s.*, u.email, u.id as user_id FROM sessions s
|
||||
JOIN users u ON s.user_id = u.id
|
||||
WHERE s.id = ? AND s.expires_at > ?
|
||||
`).get(sessionId, now) as any;
|
||||
}
|
||||
|
||||
export function deleteSession(sessionId: string) {
|
||||
const db = getDb();
|
||||
db.prepare('DELETE FROM sessions WHERE id = ?').run(sessionId);
|
||||
}
|
||||
|
||||
// ── Profiles ──────────────────────────────────────────────────────────
|
||||
export function getProfilesByUser(userId: number): any[] {
|
||||
const db = getDb();
|
||||
const rows = db.prepare('SELECT * FROM profiles WHERE user_id = ? ORDER BY updated_at DESC').all(userId) as any[];
|
||||
return rows.map(r => ({ ...r, data: JSON.parse(r.data || '{}') }));
|
||||
}
|
||||
|
||||
export function getProfileById(id: string, userId?: number): any | undefined {
|
||||
const db = getDb();
|
||||
const row = userId
|
||||
? db.prepare('SELECT * FROM profiles WHERE id = ? AND user_id = ?').get(id, userId) as any
|
||||
: db.prepare('SELECT * FROM profiles WHERE id = ?').get(id) as any;
|
||||
return row ? { ...row, data: JSON.parse(row.data || '{}') } : undefined;
|
||||
}
|
||||
|
||||
export function createProfile(userId: number, id: string, title: string, language: string): any {
|
||||
const db = getDb();
|
||||
db.prepare(`
|
||||
INSERT INTO profiles (id, user_id, title, language, data)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run(id, userId, title, language, JSON.stringify(DEFAULT_DATA));
|
||||
return getProfileById(id)!;
|
||||
}
|
||||
|
||||
export function updateProfile(id: string, userId: number, updates: Partial<{ title: string; language: string; data: any; }>) {
|
||||
const db = getDb();
|
||||
const fields: string[] = [];
|
||||
const values: any[] = [];
|
||||
|
||||
if (updates.title !== undefined) { fields.push('title = ?'); values.push(updates.title); }
|
||||
if (updates.language !== undefined) { fields.push('language = ?'); values.push(updates.language); }
|
||||
if (updates.data !== undefined) { fields.push('data = ?'); values.push(JSON.stringify(updates.data)); }
|
||||
|
||||
if (fields.length === 0) return;
|
||||
fields.push('updated_at = unixepoch()');
|
||||
values.push(id, userId);
|
||||
|
||||
db.prepare(`UPDATE profiles SET ${fields.join(', ')} WHERE id = ? AND user_id = ?`).run(...values);
|
||||
}
|
||||
|
||||
export function deleteProfile(id: string, userId: number) {
|
||||
const db = getDb();
|
||||
db.prepare('DELETE FROM profiles WHERE id = ? AND user_id = ?').run(id, userId);
|
||||
}
|
||||
|
||||
// ── CVs ───────────────────────────────────────────────────────────────
|
||||
export function getCVsByUser(userId: number): CV[] {
|
||||
const db = getDb();
|
||||
const rows = db.prepare('SELECT * FROM cvs WHERE user_id = ? ORDER BY updated_at DESC').all(userId) as any[];
|
||||
return rows.map(parseCV);
|
||||
}
|
||||
|
||||
export function getCVById(id: string, userId?: number): CV | undefined {
|
||||
const db = getDb();
|
||||
const row = userId
|
||||
? db.prepare('SELECT * FROM cvs WHERE id = ? AND user_id = ?').get(id, userId)
|
||||
: db.prepare('SELECT * FROM cvs WHERE id = ?').get(id);
|
||||
return row ? parseCV(row as any) : undefined;
|
||||
}
|
||||
|
||||
export function getCVByHash(hash: string): CV | undefined {
|
||||
const db = getDb();
|
||||
const row = db.prepare('SELECT * FROM cvs WHERE hash = ? AND public = 1').get(hash);
|
||||
return row ? parseCV(row as any) : undefined;
|
||||
}
|
||||
|
||||
export function createCV(userId: number, id: string, profile_id: string, hash: string, title: string): CV {
|
||||
const db = getDb();
|
||||
db.prepare(`
|
||||
INSERT INTO cvs (id, user_id, profile_id, hash, title, template, settings)
|
||||
VALUES (?, ?, ?, ?, ?, 1, ?)
|
||||
`).run(id, userId, profile_id, hash, title,
|
||||
JSON.stringify(DEFAULT_SETTINGS)
|
||||
);
|
||||
return getCVById(id)!;
|
||||
}
|
||||
|
||||
export function updateCV(id: string, userId: number, updates: Partial<{
|
||||
title: string; template: number; settings: CVSettings; public: boolean;
|
||||
}>) {
|
||||
const db = getDb();
|
||||
const fields: string[] = [];
|
||||
const values: any[] = [];
|
||||
|
||||
if (updates.title !== undefined) { fields.push('title = ?'); values.push(updates.title); }
|
||||
if (updates.template !== undefined) { fields.push('template = ?'); values.push(updates.template); }
|
||||
if (updates.settings !== undefined) { fields.push('settings = ?'); values.push(JSON.stringify(updates.settings)); }
|
||||
if (updates.public !== undefined) { fields.push('public = ?'); values.push(updates.public ? 1 : 0); }
|
||||
|
||||
if (fields.length === 0) return;
|
||||
fields.push('updated_at = unixepoch()');
|
||||
values.push(id, userId);
|
||||
|
||||
db.prepare(`UPDATE cvs SET ${fields.join(', ')} WHERE id = ? AND user_id = ?`).run(...values);
|
||||
}
|
||||
|
||||
export function deleteCV(id: string, userId: number) {
|
||||
const db = getDb();
|
||||
db.prepare('DELETE FROM cvs WHERE id = ? AND user_id = ?').run(id, userId);
|
||||
}
|
||||
|
||||
function parseCV(row: any): CV {
|
||||
return {
|
||||
...row,
|
||||
settings: JSON.parse(row.settings || '{}'),
|
||||
public: Boolean(row.public),
|
||||
};
|
||||
}
|
||||
277
src/lib/i18n.ts
Normal file
277
src/lib/i18n.ts
Normal file
@@ -0,0 +1,277 @@
|
||||
export type Lang = 'de' | 'en';
|
||||
|
||||
const t = {
|
||||
de: {
|
||||
'app.name': 'Lebenslauf-App',
|
||||
'app.tagline': 'Professionelle Lebensläufe in Minuten',
|
||||
'auth.title': 'Anmelden',
|
||||
'auth.email': 'E-Mail-Adresse',
|
||||
'auth.email.hint': 'Wir senden dir einen Einmal-Code',
|
||||
'auth.send': 'Code senden',
|
||||
'auth.sending': 'Sende...',
|
||||
'auth.otp.title': 'Code eingeben',
|
||||
'auth.otp.hint': 'Wir haben einen 6-stelligen Code an {email} gesendet.',
|
||||
'auth.otp.label': 'Einmal-Code',
|
||||
'auth.otp.submit': 'Anmelden',
|
||||
'auth.otp.resend': 'Code erneut senden',
|
||||
'auth.logout': 'Abmelden',
|
||||
'dashboard.title': 'Meine Lebensläufe',
|
||||
'dashboard.new': 'Neuer Lebenslauf',
|
||||
'dashboard.empty': 'Noch kein Lebenslauf vorhanden.',
|
||||
'dashboard.edit': 'Bearbeiten',
|
||||
'dashboard.delete': 'Löschen',
|
||||
'dashboard.delete.confirm': 'Lebenslauf wirklich löschen?',
|
||||
'dashboard.updated': 'Zuletzt geändert',
|
||||
'editor.title': 'Lebenslauf bearbeiten',
|
||||
'editor.save': 'Gespeichert',
|
||||
'editor.saving': 'Speichert...',
|
||||
'editor.preview': 'Vorschau',
|
||||
'editor.export': 'Exportieren',
|
||||
'editor.tab.personal': 'Persönlich',
|
||||
'editor.tab.profile': 'Profil',
|
||||
'editor.tab.experience': 'Erfahrung',
|
||||
'editor.tab.education': 'Ausbildung',
|
||||
'editor.tab.skills': 'Kenntnisse',
|
||||
'editor.tab.languages': 'Sprachen',
|
||||
'editor.tab.more': 'Weiteres',
|
||||
'editor.tab.settings': 'Einstellungen',
|
||||
'editor.tab.template': 'Vorlage',
|
||||
'cv.firstName': 'Vorname',
|
||||
'cv.lastName': 'Nachname',
|
||||
'cv.jobTitle': 'Berufsbezeichnung',
|
||||
'cv.email': 'E-Mail',
|
||||
'cv.phone': 'Telefon',
|
||||
'cv.address': 'Straße & Hausnummer',
|
||||
'cv.city': 'Ort',
|
||||
'cv.zipCode': 'PLZ',
|
||||
'cv.birthDate': 'Geburtsdatum',
|
||||
'cv.birthPlace': 'Geburtsort',
|
||||
'cv.nationality': 'Nationalität',
|
||||
'cv.maritalStatus': 'Familienstand',
|
||||
'cv.linkedin': 'LinkedIn',
|
||||
'cv.website': 'Website',
|
||||
'cv.photo': 'Foto',
|
||||
'cv.photo.upload': 'Foto hochladen',
|
||||
'cv.photo.remove': 'Entfernen',
|
||||
'cv.profile': 'Profil / Über mich',
|
||||
'cv.profile.placeholder': 'Kurze Zusammenfassung deiner Person, Stärken und Ziele...',
|
||||
'cv.experience': 'Berufserfahrung',
|
||||
'cv.experience.add': 'Stelle hinzufügen',
|
||||
'cv.experience.dateFrom': 'Von',
|
||||
'cv.experience.dateTo': 'Bis',
|
||||
'cv.experience.current': 'Aktuell',
|
||||
'cv.experience.jobTitle': 'Position',
|
||||
'cv.experience.company': 'Unternehmen',
|
||||
'cv.experience.location': 'Ort',
|
||||
'cv.experience.description': 'Beschreibung',
|
||||
'cv.experience.bullets': 'Stichpunkte (einer pro Zeile)',
|
||||
'cv.education': 'Ausbildung / Studium',
|
||||
'cv.education.add': 'Eintrag hinzufügen',
|
||||
'cv.education.degree': 'Abschluss / Titel',
|
||||
'cv.education.school': 'Schule / Universität',
|
||||
'cv.skills': 'Kenntnisse & Fähigkeiten',
|
||||
'cv.skills.add': 'Kenntniss hinzufügen',
|
||||
'cv.skills.name': 'Name',
|
||||
'cv.skills.level': 'Level',
|
||||
'cv.skills.category': 'Kategorie',
|
||||
'cv.skills.level.1': 'Grundkenntnisse',
|
||||
'cv.skills.level.2': 'Gute Kenntnisse',
|
||||
'cv.skills.level.3': 'Sehr gute Kenntnisse',
|
||||
'cv.skills.level.4': 'Experte',
|
||||
'cv.skills.level.5': 'Meister',
|
||||
'cv.languages': 'Sprachen',
|
||||
'cv.languages.add': 'Sprache hinzufügen',
|
||||
'cv.language.name': 'Sprache',
|
||||
'cv.language.level': 'Niveau',
|
||||
'cv.interests': 'Interessen',
|
||||
'cv.interests.placeholder': 'Reisen, Fotografie, Sport... (kommagetrennt)',
|
||||
'cv.achievements': 'Erfolge & Auszeichnungen',
|
||||
'cv.achievements.add': 'Eintrag hinzufügen',
|
||||
'cv.certifications': 'Weiterbildungen & Kurse',
|
||||
'cv.certifications.add': 'Kurs hinzufügen',
|
||||
'cv.certifications.name': 'Kursname',
|
||||
'cv.certifications.issuer': 'Anbieter',
|
||||
'settings.template': 'Vorlage wählen',
|
||||
'settings.colors': 'Farben',
|
||||
'settings.primaryColor': 'Hauptfarbe',
|
||||
'settings.accentColor': 'Akzentfarbe',
|
||||
'settings.font': 'Schriftart',
|
||||
'settings.fontSize': 'Schriftgröße',
|
||||
'settings.language': 'Sprache',
|
||||
'settings.showPhoto': 'Foto anzeigen',
|
||||
'settings.public': 'Öffentlich teilen',
|
||||
'settings.publicLink': 'Öffentlicher Link',
|
||||
'settings.copy': 'Kopieren',
|
||||
'export.pdf': 'Als PDF',
|
||||
'export.html': 'Als HTML',
|
||||
'export.json': 'Als JSON',
|
||||
'export.csv': 'Als CSV',
|
||||
'export.excel': 'Als Excel',
|
||||
'export.formats': 'Exportformate',
|
||||
'section.bildung': 'BILDUNG UND QUALIFIKATION',
|
||||
'section.erfahrung': 'ARBEITSERFAHRUNG',
|
||||
'section.kompetenzen': 'KOMPETENZEN',
|
||||
'section.weiterbildung': 'WEITERBILDUNGEN, KURSE',
|
||||
'section.erfolge': 'ERFOLGE',
|
||||
'section.sprachen': 'SPRACHEN',
|
||||
'section.interessen': 'INTERESSEN',
|
||||
'section.persoenlich': 'PERSÖNLICH',
|
||||
'section.profil': 'PROFIL',
|
||||
'section.berufserfahrung': 'BERUFSERFAHRUNG',
|
||||
'section.ausbildung': 'AUSBILDUNG',
|
||||
'section.kenntnisse': 'KENNTNISSE',
|
||||
'section.faehigkeiten': 'FÄHIGKEITEN',
|
||||
'section.qualifikationen': 'QUALIFIKATIONEN',
|
||||
'cv.current': 'Aktuell',
|
||||
'nav.home': 'Startseite',
|
||||
'nav.dashboard': 'Dashboard',
|
||||
},
|
||||
en: {
|
||||
'app.name': 'Resume App',
|
||||
'app.tagline': 'Create Professional Resumes in Minutes',
|
||||
'auth.title': 'Sign In',
|
||||
'auth.email': 'Email Address',
|
||||
'auth.email.hint': 'We\'ll send you a one-time code',
|
||||
'auth.send': 'Send Code',
|
||||
'auth.sending': 'Sending...',
|
||||
'auth.otp.title': 'Enter Code',
|
||||
'auth.otp.hint': 'We sent a 6-digit code to {email}.',
|
||||
'auth.otp.label': 'One-Time Code',
|
||||
'auth.otp.submit': 'Sign In',
|
||||
'auth.otp.resend': 'Resend Code',
|
||||
'auth.logout': 'Sign Out',
|
||||
'dashboard.title': 'My Resumes',
|
||||
'dashboard.new': 'New Resume',
|
||||
'dashboard.empty': 'No resumes yet.',
|
||||
'dashboard.edit': 'Edit',
|
||||
'dashboard.delete': 'Delete',
|
||||
'dashboard.delete.confirm': 'Really delete this resume?',
|
||||
'dashboard.updated': 'Last updated',
|
||||
'editor.title': 'Edit Resume',
|
||||
'editor.save': 'Saved',
|
||||
'editor.saving': 'Saving...',
|
||||
'editor.preview': 'Preview',
|
||||
'editor.export': 'Export',
|
||||
'editor.tab.personal': 'Personal',
|
||||
'editor.tab.profile': 'Profile',
|
||||
'editor.tab.experience': 'Experience',
|
||||
'editor.tab.education': 'Education',
|
||||
'editor.tab.skills': 'Skills',
|
||||
'editor.tab.languages': 'Languages',
|
||||
'editor.tab.more': 'More',
|
||||
'editor.tab.settings': 'Settings',
|
||||
'editor.tab.template': 'Template',
|
||||
'cv.firstName': 'First Name',
|
||||
'cv.lastName': 'Last Name',
|
||||
'cv.jobTitle': 'Job Title',
|
||||
'cv.email': 'Email',
|
||||
'cv.phone': 'Phone',
|
||||
'cv.address': 'Street & Number',
|
||||
'cv.city': 'City',
|
||||
'cv.zipCode': 'ZIP Code',
|
||||
'cv.birthDate': 'Date of Birth',
|
||||
'cv.birthPlace': 'Place of Birth',
|
||||
'cv.nationality': 'Nationality',
|
||||
'cv.maritalStatus': 'Marital Status',
|
||||
'cv.linkedin': 'LinkedIn',
|
||||
'cv.website': 'Website',
|
||||
'cv.photo': 'Photo',
|
||||
'cv.photo.upload': 'Upload Photo',
|
||||
'cv.photo.remove': 'Remove',
|
||||
'cv.profile': 'Profile / About',
|
||||
'cv.profile.placeholder': 'Brief summary of your background, strengths and goals...',
|
||||
'cv.experience': 'Work Experience',
|
||||
'cv.experience.add': 'Add Position',
|
||||
'cv.experience.dateFrom': 'From',
|
||||
'cv.experience.dateTo': 'To',
|
||||
'cv.experience.current': 'Current',
|
||||
'cv.experience.jobTitle': 'Position',
|
||||
'cv.experience.company': 'Company',
|
||||
'cv.experience.location': 'Location',
|
||||
'cv.experience.description': 'Description',
|
||||
'cv.experience.bullets': 'Bullet points (one per line)',
|
||||
'cv.education': 'Education',
|
||||
'cv.education.add': 'Add Entry',
|
||||
'cv.education.degree': 'Degree / Title',
|
||||
'cv.education.school': 'School / University',
|
||||
'cv.skills': 'Skills & Competencies',
|
||||
'cv.skills.add': 'Add Skill',
|
||||
'cv.skills.name': 'Name',
|
||||
'cv.skills.level': 'Level',
|
||||
'cv.skills.category': 'Category',
|
||||
'cv.skills.level.1': 'Basic',
|
||||
'cv.skills.level.2': 'Good',
|
||||
'cv.skills.level.3': 'Very Good',
|
||||
'cv.skills.level.4': 'Expert',
|
||||
'cv.skills.level.5': 'Master',
|
||||
'cv.languages': 'Languages',
|
||||
'cv.languages.add': 'Add Language',
|
||||
'cv.language.name': 'Language',
|
||||
'cv.language.level': 'Level',
|
||||
'cv.interests': 'Interests & Hobbies',
|
||||
'cv.interests.placeholder': 'Travel, Photography, Sports... (comma-separated)',
|
||||
'cv.achievements': 'Achievements & Awards',
|
||||
'cv.achievements.add': 'Add Entry',
|
||||
'cv.certifications': 'Courses & Certifications',
|
||||
'cv.certifications.add': 'Add Course',
|
||||
'cv.certifications.name': 'Course Name',
|
||||
'cv.certifications.issuer': 'Provider',
|
||||
'settings.template': 'Choose Template',
|
||||
'settings.colors': 'Colors',
|
||||
'settings.primaryColor': 'Primary Color',
|
||||
'settings.accentColor': 'Accent Color',
|
||||
'settings.font': 'Font',
|
||||
'settings.fontSize': 'Font Size',
|
||||
'settings.language': 'Language',
|
||||
'settings.showPhoto': 'Show Photo',
|
||||
'settings.public': 'Share Publicly',
|
||||
'settings.publicLink': 'Public Link',
|
||||
'settings.copy': 'Copy',
|
||||
'export.pdf': 'As PDF',
|
||||
'export.html': 'As HTML',
|
||||
'export.json': 'As JSON',
|
||||
'export.csv': 'As CSV',
|
||||
'export.excel': 'As Excel',
|
||||
'export.formats': 'Export Formats',
|
||||
'section.bildung': 'EDUCATION & QUALIFICATIONS',
|
||||
'section.erfahrung': 'WORK EXPERIENCE',
|
||||
'section.kompetenzen': 'COMPETENCIES',
|
||||
'section.weiterbildung': 'COURSES & TRAINING',
|
||||
'section.erfolge': 'ACHIEVEMENTS',
|
||||
'section.sprachen': 'LANGUAGES',
|
||||
'section.interessen': 'INTERESTS',
|
||||
'section.persoenlich': 'PERSONAL',
|
||||
'section.profil': 'PROFILE',
|
||||
'section.berufserfahrung': 'WORK EXPERIENCE',
|
||||
'section.ausbildung': 'EDUCATION',
|
||||
'section.kenntnisse': 'SKILLS',
|
||||
'section.faehigkeiten': 'ABILITIES',
|
||||
'section.qualifikationen': 'QUALIFICATIONS',
|
||||
'cv.current': 'Present',
|
||||
'nav.home': 'Home',
|
||||
'nav.dashboard': 'Dashboard',
|
||||
}
|
||||
};
|
||||
|
||||
export function getLang(request: Request): Lang {
|
||||
const accept = request.headers.get('accept-language') || '';
|
||||
const url = new URL(request.url);
|
||||
const param = url.searchParams.get('lang');
|
||||
if (param === 'en' || param === 'de') return param;
|
||||
return accept.startsWith('en') ? 'en' : 'de';
|
||||
}
|
||||
|
||||
export function useT(lang: Lang) {
|
||||
const dict = t[lang] || t.de;
|
||||
return (key: string, vars?: Record<string, string>): string => {
|
||||
let str = (dict as any)[key] || (t.de as any)[key] || key;
|
||||
if (vars) {
|
||||
for (const [k, v] of Object.entries(vars)) {
|
||||
str = str.replace(`{${k}}`, v);
|
||||
}
|
||||
}
|
||||
return str;
|
||||
};
|
||||
}
|
||||
|
||||
export default t;
|
||||
8
src/lib/icons.ts
Normal file
8
src/lib/icons.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export const IcoEmail = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:1em;height:1em;vertical-align:-0.15em"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path><polyline points="22,6 12,13 2,6"></polyline></svg>';
|
||||
export const IcoPhone = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:1em;height:1em;vertical-align:-0.15em"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"></path></svg>';
|
||||
export const IcoCal = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:1em;height:1em;vertical-align:-0.15em"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect><line x1="16" y1="2" x2="16" y2="6"></line><line x1="8" y1="2" x2="8" y2="6"></line><line x1="3" y1="10" x2="21" y2="10"></line></svg>';
|
||||
export const IcoFlag = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:1em;height:1em;vertical-align:-0.15em"><path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"></path><line x1="4" y1="22" x2="4" y2="15"></line></svg>';
|
||||
export const IcoHome = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:1em;height:1em;vertical-align:-0.15em"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path><polyline points="9 22 9 12 15 12 15 22"></polyline></svg>';
|
||||
export const IcoPerson = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:1em;height:1em;vertical-align:-0.15em"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path><circle cx="12" cy="7" r="4"></circle></svg>';
|
||||
export const IcoEdu = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:1em;height:1em;vertical-align:-0.15em"><path d="M22 10v6M2 10l10-5 10 5-10 5z"></path><path d="M6 12v5c3 3 9 3 12 0v-5"></path></svg>';
|
||||
export const IcoExp = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:1em;height:1em;vertical-align:-0.15em"><rect x="2" y="7" width="20" height="14" rx="2" ry="2"></rect><path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"></path></svg>';
|
||||
30
src/middleware.ts
Normal file
30
src/middleware.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { defineMiddleware } from 'astro:middleware';
|
||||
import { getSessionFromRequest } from './lib/auth';
|
||||
|
||||
const EXACT_PUBLIC = ['/', '/verify'];
|
||||
const PREFIX_PUBLIC = ['/cv/', '/api/auth/'];
|
||||
|
||||
export const onRequest = defineMiddleware(async (context, next) => {
|
||||
const url = new URL(context.request.url);
|
||||
const path = url.pathname;
|
||||
|
||||
// Always allow public routes
|
||||
const isPublic = EXACT_PUBLIC.includes(path) || PREFIX_PUBLIC.some(r => path.startsWith(r));
|
||||
|
||||
if (!isPublic) {
|
||||
const session = getSessionFromRequest(context.request);
|
||||
if (!session) {
|
||||
if (path.startsWith('/api/')) {
|
||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
return context.redirect(`/?redirect=${encodeURIComponent(path)}`);
|
||||
}
|
||||
context.locals.user = { id: session.user_id, email: session.email };
|
||||
context.locals.session = session;
|
||||
}
|
||||
|
||||
return next();
|
||||
});
|
||||
13
src/pages/api/auth/logout.ts
Normal file
13
src/pages/api/auth/logout.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { logout, clearSessionCookie } from '../../../lib/auth';
|
||||
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
logout(request);
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: {
|
||||
'Location': '/',
|
||||
'Set-Cookie': clearSessionCookie()
|
||||
}
|
||||
});
|
||||
};
|
||||
23
src/pages/api/auth/request-otp.ts
Normal file
23
src/pages/api/auth/request-otp.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { sendOTP, verifyOTP, createNewSession, makeSessionCookie } from '../../../lib/auth';
|
||||
import { upsertUser } from '../../../lib/db';
|
||||
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
try {
|
||||
const { email, lang } = await request.json();
|
||||
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
return new Response(JSON.stringify({ error: 'Invalid email' }), {
|
||||
status: 400, headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
upsertUser(email.toLowerCase().trim());
|
||||
await sendOTP(email.toLowerCase().trim(), lang || 'de');
|
||||
return new Response(JSON.stringify({ ok: true }), {
|
||||
status: 200, headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
} catch (err: any) {
|
||||
return new Response(JSON.stringify({ error: err.message }), {
|
||||
status: 500, headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
};
|
||||
45
src/pages/api/auth/verify-otp.ts
Normal file
45
src/pages/api/auth/verify-otp.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { verifyOTP, createNewSession, makeSessionCookie } from '../../../lib/auth';
|
||||
import { getUser } from '../../../lib/db';
|
||||
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
try {
|
||||
const { email, otp } = await request.json();
|
||||
if (!email || !otp) {
|
||||
return new Response(JSON.stringify({ error: 'Missing email or otp' }), {
|
||||
status: 400, headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
const normalizedEmail = email.toLowerCase().trim();
|
||||
const valid = verifyOTP(normalizedEmail, otp.trim());
|
||||
|
||||
if (!valid) {
|
||||
return new Response(JSON.stringify({ error: 'Invalid or expired OTP' }), {
|
||||
status: 401, headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
const user = getUser(normalizedEmail);
|
||||
if (!user) {
|
||||
return new Response(JSON.stringify({ error: 'User not found' }), {
|
||||
status: 404, headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
const sessionId = createNewSession(user.id);
|
||||
const cookie = makeSessionCookie(sessionId);
|
||||
|
||||
return new Response(JSON.stringify({ ok: true }), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Set-Cookie': cookie
|
||||
}
|
||||
});
|
||||
} catch (err: any) {
|
||||
return new Response(JSON.stringify({ error: err.message }), {
|
||||
status: 500, headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
};
|
||||
17
src/pages/api/cv/[id]/delete.ts
Normal file
17
src/pages/api/cv/[id]/delete.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { deleteCV } from '../../../../lib/db';
|
||||
|
||||
export const DELETE: APIRoute = async ({ locals, params }) => {
|
||||
try {
|
||||
deleteCV(params.id!, locals.user.id);
|
||||
return new Response(JSON.stringify({ ok: true }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
} catch (err: any) {
|
||||
return new Response(JSON.stringify({ error: err.message }), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
};
|
||||
260
src/pages/api/cv/[id]/preview.ts
Normal file
260
src/pages/api/cv/[id]/preview.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { getProfileById } from '../../../../lib/db';
|
||||
|
||||
// Returns rendered template HTML for live preview in editor
|
||||
export const POST: APIRoute = async ({ request, params, locals }) => {
|
||||
try {
|
||||
const { template, settings, profile_id } = await request.json();
|
||||
|
||||
const user = locals.user;
|
||||
if (!user) return new Response('Unauthorized', { status: 401 });
|
||||
|
||||
const profile = getProfileById(profile_id, user.id);
|
||||
if (!profile) return new Response('Profile not found', { status: 404 });
|
||||
|
||||
const html = renderTemplate(template, settings, profile.data);
|
||||
|
||||
return new Response(html, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/html' }
|
||||
});
|
||||
} catch (err: any) {
|
||||
return new Response(`<div style="color:red;padding:20px">Error: ${err.message}</div>`, {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'text/html' }
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
function renderTemplate(template: number, s: any, d: any): string {
|
||||
const p = d.personal || {};
|
||||
const T = s.language === 'en';
|
||||
|
||||
function esc(str: string): string {
|
||||
return (str || '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
function datRange(from: string, to: string, cur: boolean) {
|
||||
return `${from} – ${cur ? (T ? 'present' : 'Aktuell') : to}`;
|
||||
}
|
||||
|
||||
const baseStyles = `
|
||||
<style>
|
||||
.cv-a4 { width: 794px; min-height: 1123px; background: white; font-size: 13px; line-height: 1.4; box-shadow: 0 8px 32px rgba(0,0,0,.15); }
|
||||
</style>`;
|
||||
|
||||
if (template === 1) return baseStyles + renderT1(p, d, s, T, esc, datRange);
|
||||
if (template === 2) return baseStyles + renderT2(p, d, s, T, esc, datRange);
|
||||
if (template === 3) return baseStyles + renderT3(p, d, s, T, esc, datRange);
|
||||
if (template === 4) return baseStyles + renderT4(p, d, s, T, esc, datRange);
|
||||
return '<div>Unknown template</div>';
|
||||
}
|
||||
|
||||
function renderT1(p: any, d: any, s: any, T: boolean, esc: Function, datRange: Function): string {
|
||||
const primary = s.primaryColor || '#1B2A5E';
|
||||
const accent = s.accentColor || '#4A7BC5';
|
||||
const font = (s.fontFamily || 'Arial, sans-serif').replace(/"/g, "'");
|
||||
|
||||
const skills = (d.skills || []).map((sk: any) =>
|
||||
`<li style="font-size:10px;color:rgba(255,255,255,.85);padding:2px 0 2px 10px;position:relative;list-style:none">
|
||||
▪ ${esc(sk.name)}${sk.level ? ` <span style="opacity:.5">${'●'.repeat(sk.level)}${'○'.repeat(5-sk.level)}</span>` : ''}
|
||||
</li>`
|
||||
).join('');
|
||||
|
||||
const langs = (d.languages || []).map((l: any) =>
|
||||
`<span style="font-weight:700;color:white;font-size:10px">${esc(l.name)}: </span><span style="font-size:10px;color:rgba(255,255,255,.75)">${esc(l.level)}</span><br/>`
|
||||
).join('');
|
||||
|
||||
const exp = (d.experience || []).map((e: any) => `
|
||||
<div style="display:grid;grid-template-columns:100px 1fr;gap:8px;margin-bottom:10px">
|
||||
<div style="font-size:9.5px;color:#888">${esc(datRange(e.dateFrom||'', e.dateTo||'', e.current))}</div>
|
||||
<div>
|
||||
<div style="font-size:11px;font-weight:700;color:#1a1a1a">${esc(e.jobTitle)}</div>
|
||||
${e.company ? `<div style="font-size:10px;color:${accent}">${esc(e.company)}${e.location ? `, ${esc(e.location)}` : ''}</div>` : ''}
|
||||
${e.description ? `<div style="font-size:10px;color:#444;margin-top:3px">${esc(e.description)}</div>` : ''}
|
||||
${(e.bullets||[]).filter(Boolean).map((b: string) => `<div style="font-size:10px;color:#333;padding-left:12px">▪ ${esc(b)}</div>`).join('')}
|
||||
</div>
|
||||
</div>`).join('');
|
||||
|
||||
const edu = (d.education || []).map((e: any) => `
|
||||
<div style="display:grid;grid-template-columns:100px 1fr;gap:8px;margin-bottom:10px">
|
||||
<div style="font-size:9.5px;color:#888">${esc(datRange(e.dateFrom||'', e.dateTo||'', e.current))}</div>
|
||||
<div>
|
||||
<div style="font-size:11px;font-weight:700;color:#1a1a1a">${esc(e.degree)}</div>
|
||||
${e.school ? `<div style="font-size:10px;color:${accent}">${esc(e.school)}${e.location ? `, ${esc(e.location)}` : ''}</div>` : ''}
|
||||
${e.description ? `<div style="font-size:10px;color:#444;margin-top:3px">${esc(e.description)}</div>` : ''}
|
||||
</div>
|
||||
</div>`).join('');
|
||||
|
||||
return `
|
||||
<div class="cv-a4" style="font-family:${font}">
|
||||
<div style="display:grid;grid-template-columns:220px 1fr;min-height:1123px">
|
||||
<aside style="background:${primary};color:white;padding:0 0 24px 0">
|
||||
${s.showPhoto ? `
|
||||
<div style="padding:24px;display:flex;justify-content:center">
|
||||
${p.photo
|
||||
? `<img src="${esc(p.photo)}" style="width:130px;height:130px;border-radius:50%;object-fit:cover;border:3px solid rgba(255,255,255,.3)"/>`
|
||||
: `<div style="width:130px;height:130px;border-radius:50%;background:rgba(255,255,255,.15);display:grid;place-items:center;color:rgba(255,255,255,.5);font-size:40px">👤</div>`
|
||||
}
|
||||
</div>` : ''}
|
||||
<div style="font-size:9.5px;font-weight:700;letter-spacing:2px;padding:10px 16px 6px;border-bottom:1px solid rgba(255,255,255,.2);margin-bottom:8px;color:rgba(255,255,255,.9)">${T ? 'PERSONAL' : 'PERSÖNLICH'}</div>
|
||||
<div style="padding:0 12px 8px">
|
||||
${p.firstName ? `<div style="display:flex;gap:8px;margin-bottom:8px"><span style="font-size:11px;filter:brightness(0) invert(1)">👤</span><div><div style="font-size:7.5px;font-weight:700;letter-spacing:1px;color:rgba(255,255,255,.6)">NAME</div><div style="font-size:10px;color:rgba(255,255,255,.9)">${esc(p.firstName)} ${esc(p.lastName)}</div></div></div>` : ''}
|
||||
${p.email ? `<div style="display:flex;gap:8px;margin-bottom:8px"><span style="font-size:11px;filter:brightness(0) invert(1)">✉️</span><div><div style="font-size:7.5px;font-weight:700;letter-spacing:1px;color:rgba(255,255,255,.6)">E-MAIL</div><div style="font-size:10px;color:rgba(255,255,255,.9)">${esc(p.email)}</div></div></div>` : ''}
|
||||
${p.phone ? `<div style="display:flex;gap:8px;margin-bottom:8px"><span style="font-size:11px;filter:brightness(0) invert(1)">📞</span><div><div style="font-size:7.5px;font-weight:700;letter-spacing:1px;color:rgba(255,255,255,.6)">TELEFON</div><div style="font-size:10px;color:rgba(255,255,255,.9)">${esc(p.phone)}</div></div></div>` : ''}
|
||||
${p.birthDate ? `<div style="display:flex;gap:8px;margin-bottom:8px"><span style="font-size:11px;filter:brightness(0) invert(1)">📅</span><div><div style="font-size:7.5px;font-weight:700;letter-spacing:1px;color:rgba(255,255,255,.6)">GEBURTSDATUM</div><div style="font-size:10px;color:rgba(255,255,255,.9)">${esc(p.birthDate)}</div></div></div>` : ''}
|
||||
${p.nationality ? `<div style="display:flex;gap:8px;margin-bottom:8px"><span style="font-size:11px;filter:brightness(0) invert(1)">🏳️</span><div><div style="font-size:7.5px;font-weight:700;letter-spacing:1px;color:rgba(255,255,255,.6)">NATIONALITÄT</div><div style="font-size:10px;color:rgba(255,255,255,.9)">${esc(p.nationality)}</div></div></div>` : ''}
|
||||
</div>
|
||||
${d.languages?.length ? `
|
||||
<div style="font-size:9.5px;font-weight:700;letter-spacing:2px;padding:10px 16px 6px;border-bottom:1px solid rgba(255,255,255,.2);margin-bottom:8px;color:rgba(255,255,255,.9)">${T ? 'LANGUAGES' : 'SPRACHEN'}</div>
|
||||
<div style="padding:0 12px 8px">${langs}</div>` : ''}
|
||||
${d.skills?.length ? `
|
||||
<div style="font-size:9.5px;font-weight:700;letter-spacing:2px;padding:10px 16px 6px;border-bottom:1px solid rgba(255,255,255,.2);margin-bottom:8px;color:rgba(255,255,255,.9)">${T ? 'SKILLS' : 'KENNTNISSE'}</div>
|
||||
<ul style="padding:0 12px 8px;margin:0">${skills}</ul>` : ''}
|
||||
${d.interests?.length ? `
|
||||
<div style="font-size:9.5px;font-weight:700;letter-spacing:2px;padding:10px 16px 6px;border-bottom:1px solid rgba(255,255,255,.2);margin-bottom:8px;color:rgba(255,255,255,.9)">${T ? 'INTERESTS' : 'INTERESSEN'}</div>
|
||||
<ul style="padding:0 12px 8px;margin:0">${d.interests.map((i: string) => `<li style="font-size:10px;color:rgba(255,255,255,.85);padding:2px 0;list-style:none">▪ ${esc(i)}</li>`).join('')}</ul>` : ''}
|
||||
</aside>
|
||||
<main style="padding:28px">
|
||||
<div style="font-size:28px;font-weight:700;color:${primary}">${esc(p.firstName)} ${esc(p.lastName)}</div>
|
||||
${p.jobTitle ? `<div style="font-size:11px;color:#666;letter-spacing:1px;text-transform:uppercase;margin-bottom:16px">${esc(p.jobTitle)}</div>` : ''}
|
||||
${d.profile ? `
|
||||
<div style="font-size:10px;font-weight:700;letter-spacing:2.5px;color:${primary};border-bottom:2px solid ${primary};padding-bottom:4px;margin-bottom:10px;margin-top:12px">${T ? 'PROFILE' : 'PROFIL'}</div>
|
||||
<p style="font-size:10.5px;color:#444;line-height:1.55;margin-bottom:14px">${esc(d.profile)}</p>` : ''}
|
||||
${edu ? `<div style="font-size:10px;font-weight:700;letter-spacing:2.5px;color:${primary};border-bottom:2px solid ${primary};padding-bottom:4px;margin-bottom:10px;margin-top:14px">🎓 ${T ? 'EDUCATION' : 'BILDUNG UND QUALIFIKATION'}</div>${edu}` : ''}
|
||||
${exp ? `<div style="font-size:10px;font-weight:700;letter-spacing:2.5px;color:${primary};border-bottom:2px solid ${primary};padding-bottom:4px;margin-bottom:10px;margin-top:14px">💼 ${T ? 'EXPERIENCE' : 'ARBEITSERFAHRUNG'}</div>${exp}` : ''}
|
||||
</main>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderT2(p: any, d: any, s: any, T: boolean, esc: Function, datRange: Function): string {
|
||||
const primary = s.primaryColor || '#1B2A5E';
|
||||
const font = (s.fontFamily || 'Arial, sans-serif').replace(/"/g, "'");
|
||||
return `<div class="cv-a4" style="font-family:${font};padding:24px 28px">
|
||||
<div style="display:grid;grid-template-columns:auto 1fr auto;gap:20px;margin-bottom:20px;border-bottom:2px solid #eee;padding-bottom:16px">
|
||||
${s.showPhoto ? (p.photo ? `<img src="${esc(p.photo)}" style="width:90px;height:90px;border-radius:50%;object-fit:cover"/>` : `<div style="width:90px;height:90px;border-radius:50%;background:#e5e8f0;display:grid;place-items:center;font-size:32px">👤</div>`) : '<div></div>'}
|
||||
<div>
|
||||
<div style="font-size:26px;font-weight:300;color:#222">${esc(p.firstName)} <strong>${esc(p.lastName)}</strong></div>
|
||||
<div style="font-size:10px;letter-spacing:3px;text-transform:uppercase;color:#888;margin:4px 0">${esc(p.jobTitle)}</div>
|
||||
${d.profile ? `<p style="font-size:9.5px;color:#555;margin-top:6px;line-height:1.5">${esc(d.profile)}</p>` : ''}
|
||||
</div>
|
||||
<div style="min-width:160px">
|
||||
${p.address ? `<div style="font-size:9.5px;color:#555;margin-bottom:5px"><span style="filter:brightness(0);opacity:.6">🏠</span> ${esc(p.address)}</div>` : ''}
|
||||
${p.email ? `<div style="font-size:9.5px;color:#555;margin-bottom:5px"><span style="filter:brightness(0);opacity:.6">✉️</span> ${esc(p.email)}</div>` : ''}
|
||||
${p.phone ? `<div style="font-size:9.5px;color:#555;margin-bottom:5px"><span style="filter:brightness(0);opacity:.6">📱</span> ${esc(p.phone)}</div>` : ''}
|
||||
${p.birthDate ? `<div style="font-size:9.5px;color:#555;margin-bottom:5px"><span style="filter:brightness(0);opacity:.6">📅</span> ${esc(p.birthDate)}</div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
${(d.experience||[]).length ? `<div style="font-size:13px;font-weight:300;color:${primary};border-bottom:1px solid #ddd;padding-bottom:4px;margin-bottom:10px">${T ? 'Work Experience' : 'Berufserfahrung'}</div>
|
||||
${(d.experience||[]).map((e: any) => `<div style="display:grid;grid-template-columns:140px 1fr;gap:10px;margin-bottom:10px">
|
||||
<div><div style="width:8px;height:8px;border-radius:50%;background:#ccc;margin-bottom:4px"></div>
|
||||
<div style="font-size:10.5px;font-weight:600;color:#333">${esc(e.company)}</div>
|
||||
<div style="font-size:9px;color:#888">${esc(datRange(e.dateFrom||'',e.dateTo||'',e.current))}</div></div>
|
||||
<div><div style="font-size:11px;font-weight:600">${esc(e.jobTitle)}</div>
|
||||
${(e.bullets||[]).filter(Boolean).map((b: string) => `<div style="font-size:9.5px;color:#555">– ${esc(b)}</div>`).join('')}
|
||||
</div></div>`).join('')}` : ''}
|
||||
${(d.education||[]).length ? `<div style="font-size:13px;font-weight:300;color:${primary};border-bottom:1px solid #ddd;padding-bottom:4px;margin-bottom:10px;margin-top:14px">${T ? 'Education' : 'Bildungsweg'}</div>
|
||||
${(d.education||[]).map((e: any) => `<div style="display:grid;grid-template-columns:140px 1fr;gap:10px;margin-bottom:10px">
|
||||
<div><div style="width:8px;height:8px;border-radius:50%;background:#ccc;margin-bottom:4px"></div>
|
||||
<div style="font-size:10.5px;font-weight:600;color:#333">${esc(e.school)}</div>
|
||||
<div style="font-size:9px;color:#888">${esc(datRange(e.dateFrom||'',e.dateTo||'',e.current))}</div></div>
|
||||
<div><div style="font-size:11px;font-weight:600">${esc(e.degree)}</div>
|
||||
${(e.bullets||[]).filter(Boolean).map((b: string) => `<div style="font-size:9.5px;color:#555">– ${esc(b)}</div>`).join('')}
|
||||
</div></div>`).join('')}` : ''}
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:20px;margin-top:14px;border-top:1px solid #eee;padding-top:12px">
|
||||
${(d.interests||[]).length ? `<div><div style="font-size:13px;font-weight:300;color:${primary};border-bottom:1px solid #ddd;padding-bottom:4px;margin-bottom:8px">${T ? 'Interests' : 'Interessen'}</div>${d.interests.map((i: string) => `<div style="font-size:9.5px;color:#555">• ${esc(i)}</div>`).join('')}</div>` : '<div></div>'}
|
||||
${(d.languages||[]).length ? `<div><div style="font-size:13px;font-weight:300;color:${primary};border-bottom:1px solid #ddd;padding-bottom:4px;margin-bottom:8px">${T ? 'Languages' : 'Sprachen'}</div>${d.languages.map((l: any) => `<div style="margin-bottom:6px"><div style="font-size:10px">${esc(l.name)}</div><div style="height:4px;background:#eee;border-radius:2px"><div style="height:100%;background:${primary};border-radius:2px;width:${({'C2':95,'C1':80,'B2':65,'B1':50,'A2':35,'A1':20,'Muttersprache':100,'Native':100}[l.level]||60)}%"></div></div></div>`).join('')}</div>` : '<div></div>'}
|
||||
${(d.skills||[]).length ? `<div><div style="font-size:13px;font-weight:300;color:${primary};border-bottom:1px solid #ddd;padding-bottom:4px;margin-bottom:8px">${T ? 'Skills' : 'Fähigkeiten'}</div>${d.skills.map((sk: any) => `<div style="margin-bottom:6px"><div style="font-size:10px">${esc(sk.name)}</div><div style="height:4px;background:#eee;border-radius:2px"><div style="height:100%;background:${primary};border-radius:2px;width:${sk.level*20}%"></div></div></div>`).join('')}</div>` : '<div></div>'}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderT3(p: any, d: any, s: any, T: boolean, esc: Function, datRange: Function): string {
|
||||
const font = (s.fontFamily || 'Arial, sans-serif').replace(/"/g, "'");
|
||||
return `<div class="cv-a4" style="font-family:${font}">
|
||||
<div style="display:grid;grid-template-columns:100px 1fr;gap:20px;padding:28px 28px 0;align-items:start">
|
||||
${s.showPhoto ? (p.photo ? `<img src="${esc(p.photo)}" style="width:88px;height:88px;border-radius:50%;object-fit:cover"/>` : `<div style="width:88px;height:88px;border-radius:50%;background:#f0f0f0;display:grid;place-items:center;font-size:32px">👤</div>`) : '<div></div>'}
|
||||
<div>
|
||||
<h1 style="font-size:24px;font-weight:300;color:#333;letter-spacing:1px;margin:0">${esc(p.firstName)} <strong>${esc(p.lastName)}</strong></h1>
|
||||
<hr style="border:none;border-top:1px solid #ccc;margin:8px 0"/>
|
||||
<div style="font-size:9px;letter-spacing:4px;text-transform:uppercase;color:#888">${esc(p.jobTitle)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:160px 1fr;padding:20px 0 0">
|
||||
<aside style="padding:0 16px 24px 28px;border-right:1px solid #e8e8e8">
|
||||
<div style="font-size:8px;letter-spacing:3px;font-weight:600;color:#666;margin-bottom:4px">${T ? 'P E R S O N A L' : 'P E R S Ö N L I C H E S'}</div>
|
||||
<hr style="border:none;border-top:1px solid #ddd;margin-bottom:8px"/>
|
||||
${p.birthDate ? `<div style="font-size:7.5px;letter-spacing:1.5px;font-weight:600;color:#888;text-transform:uppercase;margin-top:6px">GEBURTSDATUM</div><div style="font-size:9.5px;color:#444">${esc(p.birthDate)}</div>` : ''}
|
||||
${p.address ? `<div style="font-size:7.5px;letter-spacing:1.5px;font-weight:600;color:#888;text-transform:uppercase;margin-top:6px">ANSCHRIFT</div><div style="font-size:9.5px;color:#444">${esc(p.address)}, ${esc(p.city)}</div>` : ''}
|
||||
${(d.languages||[]).length ? `
|
||||
<div style="font-size:8px;letter-spacing:3px;font-weight:600;color:#666;margin-bottom:4px;margin-top:14px">${T ? 'L A N G U A G E S' : 'S P R A C H E N'}</div>
|
||||
<hr style="border:none;border-top:1px solid #ddd;margin-bottom:8px"/>
|
||||
${d.languages.map((l: any) => `<div style="font-size:7.5px;font-weight:600;color:#888;text-transform:uppercase;margin-top:6px">${esc(l.name)}</div><div style="font-size:9.5px;color:#444">${esc(l.level)}</div>`).join('')}` : ''}
|
||||
${(d.skills||[]).length ? `
|
||||
<div style="font-size:8px;letter-spacing:3px;font-weight:600;color:#666;margin-bottom:4px;margin-top:14px">${T ? 'S K I L L S' : 'K E N N T N I S S E'}</div>
|
||||
<hr style="border:none;border-top:1px solid #ddd;margin-bottom:8px"/>
|
||||
${d.skills.map((sk: any) => `<div style="margin-bottom:8px"><div style="font-size:9.5px;color:#444;margin-bottom:3px">${esc(sk.name)}</div><div style="position:relative;height:1px;background:#ccc;margin:6px 0"><div style="position:absolute;left:0;top:50%;transform:translateY(-50%);left:${sk.level*20}%;width:7px;height:7px;border-radius:50%;background:#555;margin-left:-3.5px"></div></div></div>`).join('')}` : ''}
|
||||
</aside>
|
||||
<main style="padding:0 28px 24px 20px">
|
||||
${(d.experience||[]).length ? `
|
||||
<div style="font-size:8px;letter-spacing:3px;font-weight:600;color:#666;margin-bottom:4px">${T ? 'W O R K E X P E R I E N C E' : 'B E R U F S E R F A H R U N G'}</div>
|
||||
<hr style="border:none;border-top:1px solid #ccc;margin-bottom:10px"/>
|
||||
${d.experience.map((e: any) => `<div style="margin-bottom:10px">
|
||||
<div style="font-size:9.5px;font-weight:600;text-transform:uppercase;color:#333;letter-spacing:.5px">${esc(e.jobTitle)}</div>
|
||||
<div style="font-size:9px;color:#888;text-decoration:underline;text-decoration-color:#ccc">${esc(e.company)}${e.location ? ` | ${esc(e.location)}` : ''}${e.dateFrom ? ` | ${esc(datRange(e.dateFrom,e.dateTo||'',e.current))}` : ''}</div>
|
||||
${e.description ? `<p style="font-size:9.5px;color:#555;margin-top:4px">${esc(e.description)}</p>` : ''}
|
||||
${(e.bullets||[]).filter(Boolean).map((b: string) => `<div style="font-size:9.5px;color:#555">• ${esc(b)}</div>`).join('')}
|
||||
</div>`).join('')}` : ''}
|
||||
${(d.education||[]).length ? `
|
||||
<div style="font-size:8px;letter-spacing:3px;font-weight:600;color:#666;margin-bottom:4px;margin-top:14px">${T ? 'E D U C A T I O N' : 'B I L D U N G S W E G'}</div>
|
||||
<hr style="border:none;border-top:1px solid #ccc;margin-bottom:10px"/>
|
||||
${d.education.map((e: any) => `<div style="margin-bottom:10px">
|
||||
<div style="font-size:9.5px;font-weight:600;text-transform:uppercase;color:#333">${esc(e.degree)}</div>
|
||||
<div style="font-size:9px;color:#888">${esc(e.school)}${e.dateFrom ? ` | ${esc(datRange(e.dateFrom,e.dateTo||'',e.current))}` : ''}</div>
|
||||
</div>`).join('')}` : ''}
|
||||
</main>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderT4(p: any, d: any, s: any, T: boolean, esc: Function, datRange: Function): string {
|
||||
const font = (s.fontFamily || 'Arial, sans-serif').replace(/"/g, "'");
|
||||
return `<div class="cv-a4" style="font-family:${font}">
|
||||
<div style="display:grid;grid-template-columns:240px 1fr;min-height:1123px">
|
||||
<aside style="padding:28px 16px 28px 28px;background:white">
|
||||
<div style="font-size:40px;font-weight:100;letter-spacing:4px;color:#1a1a1a;line-height:1">${esc(p.firstName||'VORNAME').toUpperCase()}</div>
|
||||
<div style="background:#9ea8b8;display:inline-block;padding:2px 8px 2px 0;margin:2px 0">
|
||||
<span style="font-size:18px;font-weight:700;letter-spacing:4px;color:white">${esc(p.lastName||'NACHNAME').toUpperCase()}</span>
|
||||
</div>
|
||||
<div style="font-size:9px;letter-spacing:3px;text-transform:uppercase;color:#888;margin:8px 0 16px">${esc(p.jobTitle)}</div>
|
||||
${s.showPhoto ? (p.photo
|
||||
? `<div style="width:100%;margin-bottom:16px"><img src="${esc(p.photo)}" style="width:100%;object-fit:cover"/></div>`
|
||||
: `<div style="width:100%;height:200px;background:#e8e8e8;display:grid;place-items:center;font-size:40px;margin-bottom:16px">👤</div>`) : ''}
|
||||
<div style="font-size:8px;letter-spacing:3px;font-weight:700;color:#888;text-transform:uppercase;margin-bottom:8px">${T ? 'PERSONAL' : 'PERSÖNLICHES'}</div>
|
||||
${p.phone ? `<div style="display:flex;gap:6px;font-size:9.5px;color:#555;margin-bottom:5px"><span style="filter:brightness(0);opacity:.6">📞</span> ${esc(p.phone)}</div>` : ''}
|
||||
${p.email ? `<div style="display:flex;gap:6px;font-size:9.5px;color:#555;margin-bottom:5px"><span style="filter:brightness(0);opacity:.6">✉️</span> ${esc(p.email)}</div>` : ''}
|
||||
${p.birthDate ? `<div style="display:flex;gap:6px;font-size:9.5px;color:#555;margin-bottom:5px"><span style="filter:brightness(0);opacity:.6">📅</span> ${esc(p.birthDate)}</div>` : ''}
|
||||
${p.address ? `<div style="display:flex;gap:6px;font-size:9.5px;color:#555;margin-bottom:5px"><span style="filter:brightness(0);opacity:.6">🏠</span> ${esc(p.address)}, ${esc(p.zipCode)} ${esc(p.city)}</div>` : ''}
|
||||
</aside>
|
||||
<main style="padding:28px;background:#fafafa;border-left:1px solid #e8e8e8">
|
||||
${(d.experience||[]).length ? `
|
||||
<div style="font-size:8.5px;letter-spacing:3.5px;font-weight:600;color:#888;text-transform:uppercase;border-bottom:1px solid #ccc;padding-bottom:4px;margin-bottom:10px">${T ? 'WORK EXPERIENCE' : 'BERUFLICHER WERDEGANG'}</div>
|
||||
${d.experience.map((e: any) => `<div style="margin-bottom:10px">
|
||||
<div style="font-size:11px;font-style:italic;font-weight:600;color:#333">${esc(e.jobTitle)}</div>
|
||||
<div style="font-size:9.5px;color:#888;text-decoration:underline;text-decoration-color:#ddd">${esc(datRange(e.dateFrom||'',e.dateTo||'',e.current))} ${esc(e.company)}</div>
|
||||
${e.description ? `<p style="font-size:9.5px;color:#555;margin-top:4px">${esc(e.description)}</p>` : ''}
|
||||
</div>`).join('')}` : ''}
|
||||
${(d.education||[]).length ? `
|
||||
<div style="font-size:8.5px;letter-spacing:3.5px;font-weight:600;color:#888;text-transform:uppercase;border-bottom:1px solid #ccc;padding-bottom:4px;margin-bottom:10px;margin-top:16px">${T ? 'EDUCATION' : 'SCHULBILDUNG'}</div>
|
||||
${d.education.map((e: any) => `<div style="margin-bottom:10px">
|
||||
<div style="font-size:11px;font-weight:600;text-decoration:underline;text-decoration-color:#ccc">${esc(e.school)}</div>
|
||||
<div style="font-size:10px;color:#666">${esc(e.degree)}</div>
|
||||
</div>`).join('')}` : ''}
|
||||
${(d.skills||[]).length || (d.achievements||[]).length ? `
|
||||
<div style="font-size:8.5px;letter-spacing:3.5px;font-weight:600;color:#888;text-transform:uppercase;border-bottom:1px solid #ccc;padding-bottom:4px;margin-bottom:10px;margin-top:16px">${T ? 'QUALIFICATIONS' : 'QUALIFIKATIONEN'}</div>
|
||||
${[...d.skills.map((s: any) => `<div style="font-size:9.5px;color:#555">• ${esc(s.name)}</div>`), ...(d.achievements||[]).filter(Boolean).map((a: string) => `<div style="font-size:9.5px;color:#555">• ${esc(a)}</div>`)].join('')}` : ''}
|
||||
</main>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
27
src/pages/api/cv/[id]/update.ts
Normal file
27
src/pages/api/cv/[id]/update.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { updateCV } from '../../../../lib/db';
|
||||
|
||||
export const PUT: APIRoute = async ({ request, locals, params }) => {
|
||||
try {
|
||||
const user = locals.user;
|
||||
const id = params.id!;
|
||||
const body = await request.json();
|
||||
|
||||
updateCV(id, user.id, {
|
||||
title: body.title,
|
||||
template: body.template,
|
||||
settings: body.settings,
|
||||
public: body.public,
|
||||
});
|
||||
|
||||
return new Response(JSON.stringify({ ok: true }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
} catch (err: any) {
|
||||
return new Response(JSON.stringify({ error: err.message }), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
};
|
||||
33
src/pages/api/cv/create.ts
Normal file
33
src/pages/api/cv/create.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { createCV } from '../../../lib/db';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
export const POST: APIRoute = async ({ request, locals }) => {
|
||||
try {
|
||||
const user = locals.user;
|
||||
const { title, template, profile_id } = await request.json();
|
||||
|
||||
if (!profile_id) return new Response(JSON.stringify({ error: 'Profile ID required' }), { status: 400 });
|
||||
|
||||
const id = uuidv4();
|
||||
const hash = uuidv4().replace(/-/g, '').slice(0, 12);
|
||||
|
||||
const cv = createCV(user.id, id, profile_id, hash, title || 'Mein Lebenslauf');
|
||||
|
||||
// Set template if provided
|
||||
if (template && template !== 1) {
|
||||
const { updateCV } = await import('../../../lib/db');
|
||||
updateCV(id, user.id, { template: parseInt(template) });
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ ok: true, id, hash }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
} catch (err: any) {
|
||||
return new Response(JSON.stringify({ error: err.message }), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
};
|
||||
14
src/pages/api/profile/[id]/delete.ts
Normal file
14
src/pages/api/profile/[id]/delete.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { deleteProfile } from '../../../../lib/db';
|
||||
|
||||
export const DELETE: APIRoute = async ({ params, locals }) => {
|
||||
try {
|
||||
const user = locals.user;
|
||||
if (!user) return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401 });
|
||||
|
||||
deleteProfile(params.id!, user.id);
|
||||
return new Response(JSON.stringify({ ok: true }), { status: 200 });
|
||||
} catch (err: any) {
|
||||
return new Response(JSON.stringify({ error: err.message }), { status: 500 });
|
||||
}
|
||||
};
|
||||
16
src/pages/api/profile/[id]/update.ts
Normal file
16
src/pages/api/profile/[id]/update.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { updateProfile } from '../../../../lib/db';
|
||||
|
||||
export const PUT: APIRoute = async ({ request, params, locals }) => {
|
||||
try {
|
||||
const user = locals.user;
|
||||
if (!user) return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401 });
|
||||
|
||||
const body = await request.json();
|
||||
updateProfile(params.id!, user.id, body);
|
||||
|
||||
return new Response(JSON.stringify({ ok: true }), { status: 200 });
|
||||
} catch (err: any) {
|
||||
return new Response(JSON.stringify({ error: err.message }), { status: 500 });
|
||||
}
|
||||
};
|
||||
20
src/pages/api/profile/create.ts
Normal file
20
src/pages/api/profile/create.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { createProfile } from '../../../lib/db';
|
||||
import { randomBytes, createHash } from 'crypto';
|
||||
|
||||
export const POST: APIRoute = async ({ request, locals }) => {
|
||||
try {
|
||||
const user = locals.user;
|
||||
if (!user) return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401 });
|
||||
|
||||
const body = await request.json();
|
||||
if (!body.title) return new Response(JSON.stringify({ error: 'Title required' }), { status: 400 });
|
||||
|
||||
const id = randomBytes(16).toString('hex');
|
||||
const profile = createProfile(user.id, id, body.title, body.language || 'de');
|
||||
|
||||
return new Response(JSON.stringify({ ok: true, id }), { status: 200 });
|
||||
} catch (err: any) {
|
||||
return new Response(JSON.stringify({ error: err.message }), { status: 500 });
|
||||
}
|
||||
};
|
||||
126
src/pages/cv/[hash].astro
Normal file
126
src/pages/cv/[hash].astro
Normal file
@@ -0,0 +1,126 @@
|
||||
---
|
||||
import { getCVByHash, getProfileById } from '../../lib/db';
|
||||
import { useT } from '../../lib/i18n';
|
||||
import Template1 from '../../components/templates/Template1.astro';
|
||||
import Template2 from '../../components/templates/Template2.astro';
|
||||
import Template3 from '../../components/templates/Template3.astro';
|
||||
import Template4 from '../../components/templates/Template4.astro';
|
||||
|
||||
const { hash } = Astro.params;
|
||||
const cv = getCVByHash(hash!);
|
||||
if (!cv) return new Response('Not Found', { status: 404 });
|
||||
|
||||
const profile = getProfileById(cv.profile_id);
|
||||
if (!profile) return new Response('Profile Not Found', { status: 404 });
|
||||
|
||||
const lang = cv.settings.language || 'de';
|
||||
const t = useT(lang as any);
|
||||
const name = `${profile.data.personal.firstName} ${profile.data.personal.lastName}`.trim() || cv.title;
|
||||
const appUrl = import.meta.env.APP_URL || Astro.url.origin;
|
||||
---
|
||||
<!DOCTYPE html>
|
||||
<html lang={lang}>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>{name} – Lebenslauf</title>
|
||||
<meta name="description" content={`Lebenslauf von ${name}`} />
|
||||
<meta property="og:title" content={name} />
|
||||
<meta property="og:description" content={profile.data.personal.jobTitle || ''} />
|
||||
<link rel="stylesheet" href="/styles/global.css" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600&family=Lora:ital,wght@0,400;0,600;1,400&family=Merriweather:wght@400;700&family=Montserrat:wght@300;400;600;700&family=Outfit:wght@300;400;600&family=Oswald:wght@400;600&family=Playfair+Display:ital,wght@0,400;0,700;1,400&family=Roboto:wght@300;400;700&display=swap" rel="stylesheet">
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/html2pdf.js/0.10.1/html2pdf.bundle.min.js" defer></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js" defer></script>
|
||||
<style>
|
||||
body { background: #E8ECF0; }
|
||||
.cv-public-wrap { min-height: 100vh; padding: 24px 16px; display: flex; flex-direction: column; align-items: center; gap: 20px; }
|
||||
.cv-public-bar {
|
||||
width: 100%;
|
||||
max-width: 794px;
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
padding: 12px 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,.08);
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
.cv-info { display: flex; flex-direction: column; }
|
||||
.cv-info-name { font-weight: 700; font-size: .95rem; }
|
||||
.cv-info-sub { font-size: .75rem; color: #888; }
|
||||
.cv-actions { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
@media (max-width: 840px) {
|
||||
.cv-public-bar { max-width: 100%; }
|
||||
.cv-a4 { width: 100% !important; min-height: auto !important; }
|
||||
}
|
||||
@media print {
|
||||
.cv-public-bar { display: none !important; }
|
||||
body { background: white; }
|
||||
.cv-public-wrap { padding: 0; background: white; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="cv-public-wrap">
|
||||
<div class="cv-public-bar">
|
||||
<div class="cv-info">
|
||||
<span class="cv-info-name">{name}</span>
|
||||
<span class="cv-info-sub">{profile.data.personal.jobTitle || ''}</span>
|
||||
</div>
|
||||
<div class="cv-actions">
|
||||
<button onclick="exportPDF()" class="btn btn-primary btn-sm">↓ PDF</button>
|
||||
<button onclick="exportHTML()" class="btn btn-ghost btn-sm">🌐 HTML</button>
|
||||
<button onclick="exportJSON()" class="btn btn-ghost btn-sm">{ } JSON</button>
|
||||
<button onclick="window.print()" class="btn btn-ghost btn-sm">🖨️ {lang==='de'?'Drucken':'Print'}</button>
|
||||
<a href="/" class="btn btn-ghost btn-sm">🏠 App</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="cv-content">
|
||||
{cv.template === 1 && <Template1 data={profile.data} settings={cv.settings} />}
|
||||
{cv.template === 2 && <Template2 data={profile.data} settings={cv.settings} />}
|
||||
{cv.template === 3 && <Template3 data={profile.data} settings={cv.settings} />}
|
||||
{cv.template === 4 && <Template4 data={profile.data} settings={cv.settings} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script define:vars={{ cvTitle: name, cvData: profile.data, cvSettings: cv.settings }}>
|
||||
window.exportPDF = function() {
|
||||
const el = document.getElementById('cv-content');
|
||||
window.html2pdf().set({
|
||||
margin: 0,
|
||||
filename: cvTitle + '.pdf',
|
||||
image: { type: 'jpeg', quality: 0.98 },
|
||||
html2canvas: { scale: 2, useCORS: true },
|
||||
jsPDF: { unit: 'mm', format: 'a4', orientation: 'portrait' }
|
||||
}).from(el).save();
|
||||
};
|
||||
|
||||
window.exportHTML = function() {
|
||||
const el = document.getElementById('cv-content');
|
||||
const styles = Array.from(document.styleSheets).map(ss => {
|
||||
try { return Array.from(ss.cssRules).map(r => r.cssText).join('\n'); } catch { return ''; }
|
||||
}).join('\n');
|
||||
const html = `<!DOCTYPE html><html><head><meta charset="UTF-8"><title>${cvTitle}</title><style>${styles}</style></head><body style="margin:0;background:#E8ECF0;display:flex;justify-content:center;padding:32px">${el.innerHTML}</body></html>`;
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(new Blob([html], { type: 'text/html' }));
|
||||
a.download = cvTitle + '.html';
|
||||
a.click();
|
||||
};
|
||||
|
||||
window.exportJSON = function() {
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(new Blob(
|
||||
[JSON.stringify({ title: cvTitle, settings: cvSettings, data: cvData }, null, 2)],
|
||||
{ type: 'application/json' }
|
||||
));
|
||||
a.download = cvTitle + '.json';
|
||||
a.click();
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
248
src/pages/dashboard.astro
Normal file
248
src/pages/dashboard.astro
Normal file
@@ -0,0 +1,248 @@
|
||||
---
|
||||
import { getProfilesByUser, getCVsByUser } from '../lib/db';
|
||||
import { getLang, useT } from '../lib/i18n';
|
||||
import { TEMPLATES } from '../types';
|
||||
|
||||
const user = Astro.locals.user;
|
||||
const lang = getLang(Astro.request);
|
||||
const t = useT(lang);
|
||||
|
||||
const profiles = getProfilesByUser(user.id);
|
||||
const cvs = getCVsByUser(user.id);
|
||||
|
||||
function timeAgo(ts: number) {
|
||||
const diff = Math.floor(Date.now() / 1000) - ts;
|
||||
if (diff < 60) return lang === 'de' ? 'Gerade eben' : 'Just now';
|
||||
if (diff < 3600) return `${Math.floor(diff/60)} ${lang==='de' ? 'Min.' : 'min.'}`;
|
||||
if (diff < 86400) return `${Math.floor(diff/3600)} ${lang==='de' ? 'Std.' : 'h'}`;
|
||||
return `${Math.floor(diff/86400)} ${lang==='de' ? 'Tage' : 'days'}`;
|
||||
}
|
||||
---
|
||||
<!DOCTYPE html>
|
||||
<html lang={lang}>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>{t('dashboard.title')} – {t('app.name')}</title>
|
||||
<link rel="stylesheet" href="/styles/global.css" />
|
||||
<style>
|
||||
.section-title { font-size: 1.25rem; font-weight: 600; margin-bottom: 16px; margin-top: 32px; border-bottom: 1px solid rgba(0,0,0,.05); padding-bottom: 8px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="app-header">
|
||||
<a href="/dashboard" class="logo">Lebenslauf<span>App</span></a>
|
||||
<nav>
|
||||
<span style="color:rgba(255,255,255,.6);font-size:.8rem">{user.email}</span>
|
||||
<form action="/api/auth/logout" method="POST" style="display:inline">
|
||||
<button type="submit" class="btn-logout">{t('auth.logout')}</button>
|
||||
</form>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<div class="page">
|
||||
<h1 style="font-size:1.5rem;font-weight:700">{t('dashboard.title')}</h1>
|
||||
|
||||
<div class="flex items-center justify-between mt-6 mb-2">
|
||||
<h2 class="section-title" style="margin:0;border:0">{lang==='de' ? 'Meine Datensätze (Profile)' : 'My Data Profiles'}</h2>
|
||||
<button onclick="openProfileModal()" class="btn btn-primary btn-sm">
|
||||
+ {lang==='de' ? 'Neuer Datensatz' : 'New Profile'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{profiles.length === 0 && (
|
||||
<div class="card" style="text-align:center;padding:32px;margin-bottom:24px">
|
||||
<div style="color:var(--muted);margin-bottom:12px">{lang==='de' ? 'Du hast noch keinen Datensatz angelegt.' : 'You have not created a profile yet.'}</div>
|
||||
<button onclick="openProfileModal()" class="btn btn-primary btn-sm">
|
||||
+ {lang==='de' ? 'Ersten Datensatz anlegen' : 'Create first profile'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div class="cv-grid">
|
||||
{profiles.map(p => (
|
||||
<div class="cv-card" onclick={`window.location.href='/profile/${p.id}'`} style="height:auto">
|
||||
<div class="cv-card-body">
|
||||
<div class="cv-card-title">{p.title}</div>
|
||||
<div class="cv-card-meta">
|
||||
Sprache: {p.language.toUpperCase()} · {timeAgo(p.updated_at)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="cv-card-actions" onclick="event.stopPropagation()">
|
||||
<a href={`/profile/${p.id}`} class="btn btn-ghost btn-sm">{t('dashboard.edit')}</a>
|
||||
<button class="btn btn-danger btn-sm" onclick={`deleteProfile('${p.id}')`}>{t('dashboard.delete')}</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between mt-8 mb-2">
|
||||
<h2 class="section-title" style="margin:0;border:0">{lang==='de' ? 'Meine Lebensläufe (Layouts)' : 'My Resumes (Layouts)'}</h2>
|
||||
<button onclick="openCvModal()" class="btn btn-primary btn-sm" disabled={profiles.length === 0}>
|
||||
+ {lang==='de' ? 'Neues Design' : 'New Design'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{cvs.length === 0 && profiles.length > 0 && (
|
||||
<div class="card" style="text-align:center;padding:32px;margin-bottom:24px">
|
||||
<div style="color:var(--muted);margin-bottom:12px">{t('dashboard.empty')}</div>
|
||||
<button onclick="openCvModal()" class="btn btn-primary btn-sm">
|
||||
+ {lang==='de' ? 'Neues Design erstellen' : 'Create new design'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div class="cv-grid">
|
||||
{cvs.map(cv => (
|
||||
<div class="cv-card" onclick={`window.location.href='/editor/${cv.id}'`}>
|
||||
<div class="cv-card-preview">
|
||||
<div class="t-badge">T{cv.template}</div>
|
||||
<div class="preview-mini" style={`background: linear-gradient(135deg, ${cv.settings.primaryColor} 0%, ${cv.settings.accentColor} 100%)`}>
|
||||
<div style="height:30%;background:white;border-bottom:2px solid #eee;padding:4px"></div>
|
||||
<div style="padding:4px;flex:1;display:flex;flex-direction:column;gap:3px">
|
||||
{[...Array(5)].map(() => <div style="height:4px;background:rgba(0,0,0,.1);border-radius:2px"></div>)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cv-card-body">
|
||||
<div class="cv-card-title">{cv.title}</div>
|
||||
<div class="cv-card-meta">
|
||||
{TEMPLATES.find(t => t.id === cv.template)?.name} · {t('dashboard.updated')} {timeAgo(cv.updated_at)}
|
||||
{cv.public && <span style="color:var(--success);margin-left:8px">🌐</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div class="cv-card-actions" onclick="event.stopPropagation()">
|
||||
<a href={`/editor/${cv.id}`} class="btn btn-ghost btn-sm">{t('dashboard.edit')}</a>
|
||||
{cv.public && <a href={`/cv/${cv.hash}`} target="_blank" class="btn btn-ghost btn-sm">🔗</a>}
|
||||
<button class="btn btn-danger btn-sm" onclick={`deleteCv('${cv.id}')`}>{t('dashboard.delete')}</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- New Profile Modal -->
|
||||
<div id="profile-modal" class="hidden" style="position:fixed;inset:0;background:rgba(0,0,0,.5);display:grid;place-items:center;z-index:1000">
|
||||
<div class="card" style="width:400px;max-width:95vw">
|
||||
<div class="card-header flex justify-between items-center">
|
||||
<strong>{lang==='de' ? 'Neuer Datensatz' : 'New Profile'}</strong>
|
||||
<button onclick="closeProfileModal()" class="btn btn-ghost btn-sm">✕</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="form-group">
|
||||
<label class="form-label">{lang==='de' ? 'Titel' : 'Title'}</label>
|
||||
<input type="text" id="profile-title" class="form-input" value={lang==='de' ? 'Mein Profil' : 'My Profile'} />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">{t('settings.language')}</label>
|
||||
<select id="profile-lang" class="form-input form-select">
|
||||
<option value="de">Deutsch</option>
|
||||
<option value="en">English</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer flex justify-between">
|
||||
<button onclick="closeProfileModal()" class="btn btn-ghost">{lang==='de' ? 'Abbrechen' : 'Cancel'}</button>
|
||||
<button id="create-profile-btn" class="btn btn-primary">{lang==='de' ? 'Erstellen' : 'Create'}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- New CV Modal -->
|
||||
<div id="cv-modal" class="hidden" style="position:fixed;inset:0;background:rgba(0,0,0,.5);display:grid;place-items:center;z-index:1000">
|
||||
<div class="card" style="width:440px;max-width:95vw">
|
||||
<div class="card-header flex justify-between items-center">
|
||||
<strong>{lang==='de' ? 'Neues Design' : 'New Design'}</strong>
|
||||
<button onclick="closeCvModal()" class="btn btn-ghost btn-sm">✕</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="form-group">
|
||||
<label class="form-label">{lang==='de' ? 'Titel' : 'Title'}</label>
|
||||
<input type="text" id="cv-title" class="form-input" value={lang==='de' ? 'Mein Lebenslauf' : 'My Resume'} />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">{lang==='de' ? 'Datensatz wählen' : 'Select Profile'}</label>
|
||||
<select id="cv-profile" class="form-input form-select">
|
||||
{profiles.map(p => <option value={p.id}>{p.title}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">{t('settings.template')}</label>
|
||||
<div class="template-grid">
|
||||
{TEMPLATES.map(tpl => (
|
||||
<div class={`template-option ${tpl.id === 1 ? 'selected' : ''}`} data-id={tpl.id} onclick="selectTemplate(this)">
|
||||
<div class="template-thumb" style="background:linear-gradient(135deg,#1B2A5E,#4A7BC5)">
|
||||
<span style="color:white;font-size:1.2rem">T{tpl.id}</span>
|
||||
</div>
|
||||
<div class="template-label">{tpl.name}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer flex justify-between">
|
||||
<button onclick="closeCvModal()" class="btn btn-ghost">{lang==='de' ? 'Abbrechen' : 'Cancel'}</button>
|
||||
<button id="create-cv-btn" class="btn btn-primary">{lang==='de' ? 'Erstellen' : 'Create'}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script define:vars={{ lang }}>
|
||||
let selectedTemplate = 1;
|
||||
|
||||
window.openProfileModal = () => document.getElementById('profile-modal').classList.remove('hidden');
|
||||
window.closeProfileModal = () => document.getElementById('profile-modal').classList.add('hidden');
|
||||
|
||||
window.openCvModal = () => document.getElementById('cv-modal').classList.remove('hidden');
|
||||
window.closeCvModal = () => document.getElementById('cv-modal').classList.add('hidden');
|
||||
|
||||
window.selectTemplate = function(el) {
|
||||
document.querySelectorAll('.template-option').forEach(e => e.classList.remove('selected'));
|
||||
el.classList.add('selected');
|
||||
selectedTemplate = parseInt(el.dataset.id);
|
||||
};
|
||||
|
||||
document.getElementById('create-profile-btn').addEventListener('click', async () => {
|
||||
const title = document.getElementById('profile-title').value.trim();
|
||||
const language = document.getElementById('profile-lang').value;
|
||||
if (!title) return;
|
||||
const res = await fetch('/api/profile/create', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title, language })
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
window.location.href = `/profile/${data.id}`;
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('create-cv-btn').addEventListener('click', async () => {
|
||||
const title = document.getElementById('cv-title').value.trim();
|
||||
const profile_id = document.getElementById('cv-profile').value;
|
||||
if (!title || !profile_id) return;
|
||||
const res = await fetch('/api/cv/create', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title, profile_id, template: selectedTemplate })
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
window.location.href = `/editor/${data.id}`;
|
||||
}
|
||||
});
|
||||
|
||||
window.deleteProfile = async function(id) {
|
||||
if (!confirm(lang === 'en' ? 'Really delete this profile? All layout documents that rely on this profile will be empty or deleted.' : 'Datensatz wirklich löschen? Zugehörige Lebensläufe können dann keine Daten mehr anzeigen.')) return;
|
||||
await fetch(`/api/profile/${id}/delete`, { method: 'DELETE' });
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
window.deleteCv = async function(id) {
|
||||
if (!confirm(lang === 'en' ? 'Really delete this layout?' : 'Layout wirklich löschen?')) return;
|
||||
await fetch(`/api/cv/${id}/delete`, { method: 'DELETE' });
|
||||
window.location.reload();
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
334
src/pages/editor/[id].astro
Normal file
334
src/pages/editor/[id].astro
Normal file
@@ -0,0 +1,334 @@
|
||||
---
|
||||
import { getCVById, getProfileById } from '../../lib/db';
|
||||
import { getLang, useT } from '../../lib/i18n';
|
||||
import { TEMPLATES } from '../../types';
|
||||
import Template1 from '../../components/templates/Template1.astro';
|
||||
import Template2 from '../../components/templates/Template2.astro';
|
||||
import Template3 from '../../components/templates/Template3.astro';
|
||||
import Template4 from '../../components/templates/Template4.astro';
|
||||
|
||||
const { id } = Astro.params;
|
||||
const user = Astro.locals.user;
|
||||
const lang = getLang(Astro.request);
|
||||
const t = useT(lang);
|
||||
|
||||
const cv = getCVById(id!, user.id);
|
||||
if (!cv) return Astro.redirect('/dashboard');
|
||||
|
||||
const profile = getProfileById(cv.profile_id, user.id);
|
||||
if (!profile) return Astro.redirect('/dashboard');
|
||||
|
||||
const d = profile.data;
|
||||
const s = cv.settings;
|
||||
const de = lang === 'de';
|
||||
|
||||
function j(v: any) { return JSON.stringify(v).replace(/</g, '\\u003c'); }
|
||||
---
|
||||
<!DOCTYPE html>
|
||||
<html lang={lang}>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>{cv.title} – {t('app.name')}</title>
|
||||
<link rel="stylesheet" href="/styles/global.css" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600&family=Lora:ital,wght@0,400;0,600;1,400&family=Merriweather:wght@400;700&family=Montserrat:wght@300;400;600;700&family=Outfit:wght@300;400;600&family=Oswald:wght@400;600&family=Playfair+Display:ital,wght@0,400;0,700;1,400&family=Roboto:wght@300;400;700&display=swap" rel="stylesheet">
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/html2pdf.js/0.10.1/html2pdf.bundle.min.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<header class="app-header">
|
||||
<div class="flex items-center gap-3">
|
||||
<a href="/dashboard" class="logo" style="font-size:.95rem">Lebenslauf<span>App</span></a>
|
||||
<span style="color:rgba(255,255,255,.3)">›</span>
|
||||
<span id="cv-title-display" style="color:rgba(255,255,255,.9);font-size:.9rem">{cv.title}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<a href={`/profile/${profile.id}`} class="btn btn-ghost btn-sm" style="color:#fff">{de ? '✏️ Daten bearbeiten' : '✏️ Edit Data'}</a>
|
||||
<span id="save-status" class="save-status hidden">✓ {t('editor.save')}</span>
|
||||
<button id="toggle-preview" class="btn btn-ghost btn-sm" style="display:none">
|
||||
{de ? '👁 Vorschau' : '👁 Preview'}
|
||||
</button>
|
||||
<div style="position:relative">
|
||||
<button id="export-btn" class="btn btn-accent btn-sm">↓ {t('editor.export')}</button>
|
||||
<div id="export-menu" class="hidden" style="position:absolute;right:0;top:calc(100% + 4px);background:white;border:1px solid #ddd;border-radius:8px;box-shadow:0 4px 20px rgba(0,0,0,.15);min-width:160px;z-index:50">
|
||||
<button onclick="exportPDF()" style="width:100%;text-align:left;padding:10px 16px;border:none;background:none;font-size:.875rem;cursor:pointer">📄 PDF</button>
|
||||
<button onclick="exportHTML()" style="width:100%;text-align:left;padding:10px 16px;border:none;background:none;font-size:.875rem;cursor:pointer">🌐 HTML</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="editor-layout" id="editor-layout">
|
||||
<!-- SIDEBAR -->
|
||||
<div class="editor-sidebar">
|
||||
<div class="editor-tabs" id="editor-tabs">
|
||||
<button class="editor-tab active" data-tab="settings">{t('editor.tab.settings')}</button>
|
||||
</div>
|
||||
|
||||
<div class="editor-content" id="editor-content">
|
||||
<!-- SETTINGS -->
|
||||
<div class="tab-pane" id="tab-settings">
|
||||
<div class="form-group">
|
||||
<label class="form-label">{t('settings.template')}</label>
|
||||
<div class="template-grid" id="template-grid">
|
||||
{TEMPLATES.map(tpl => (
|
||||
<div class={`template-option ${cv.template === tpl.id ? 'selected' : ''}`} data-id={tpl.id} onclick="selectTemplate(this)">
|
||||
<div class="template-thumb"><span style="color:white;font-size:1.3rem">T{tpl.id}</span></div>
|
||||
<div class="template-label"><strong>{tpl.name}</strong><br/><span style="font-size:.7rem;color:#888">{tpl.desc}</span></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<hr class="divider" />
|
||||
<div class="form-group">
|
||||
<label class="form-label">{t('settings.colors')}</label>
|
||||
<div class="color-row mt-1">
|
||||
<div class="color-swatch"><input type="color" id="color-primary" value={s.primaryColor} /></div>
|
||||
<span class="text-sm">{t('settings.primaryColor')}</span>
|
||||
</div>
|
||||
<div class="color-row mt-2">
|
||||
<div class="color-swatch"><input type="color" id="color-accent" value={s.accentColor} /></div>
|
||||
<span class="text-sm">{t('settings.accentColor')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">{de ? 'Text Schriftart' : 'Text Font'}</label>
|
||||
<select id="font-family" class="form-input form-select">
|
||||
{[
|
||||
["'Inter', sans-serif", 'Inter'],
|
||||
["'Roboto', sans-serif", 'Roboto'],
|
||||
["'Montserrat', sans-serif", 'Montserrat'],
|
||||
["'Outfit', sans-serif", 'Outfit'],
|
||||
["'Lora', serif", 'Lora'],
|
||||
['Arial, sans-serif', 'Arial'],
|
||||
].map(([val, label]) => (
|
||||
<option value={val} selected={s.fontFamily?.replace(/"/g,"'") === val || s.fontFamily === val}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">{de ? 'Überschriften Schriftart' : 'Heading Font'}</label>
|
||||
<select id="font-heading" class="form-input form-select">
|
||||
{[
|
||||
["'Inter', sans-serif", 'Inter'],
|
||||
["'Montserrat', sans-serif", 'Montserrat'],
|
||||
["'Playfair Display', serif", 'Playfair Display'],
|
||||
["'Oswald', sans-serif", 'Oswald'],
|
||||
["'Merriweather', serif", 'Merriweather'],
|
||||
['Arial, sans-serif', 'Arial'],
|
||||
].map(([val, label]) => (
|
||||
<option value={val} selected={(s.fontHeading || s.fontFamily)?.replace(/"/g,"'") === val || (s.fontHeading || s.fontFamily) === val}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">{t('settings.language')}</label>
|
||||
<select id="cv-lang" class="form-input form-select">
|
||||
<option value="de" selected={s.language === 'de'}>Deutsch</option>
|
||||
<option value="en" selected={s.language === 'en'}>English</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group flex items-center gap-2" style="flex-direction:row;align-items:center">
|
||||
<input type="checkbox" id="show-photo" checked={s.showPhoto} style="width:16px;height:16px" />
|
||||
<label for="show-photo" class="form-label" style="margin:0">{t('settings.showPhoto')}</label>
|
||||
</div>
|
||||
<hr class="divider" />
|
||||
<div class="form-group">
|
||||
<div class="flex items-center gap-2" style="flex-direction:row;align-items:center;margin-bottom:8px">
|
||||
<input type="checkbox" id="cv-public" checked={cv.public} style="width:16px;height:16px" />
|
||||
<label for="cv-public" class="form-label" style="margin:0">{t('settings.public')}</label>
|
||||
</div>
|
||||
{cv.public && (
|
||||
<div class="flex gap-2">
|
||||
<input type="text" class="form-input" id="public-link" value={`${Astro.url.origin}/cv/${cv.hash}`} readonly style="font-size:.75rem" />
|
||||
<button class="btn btn-ghost btn-sm" onclick="copyLink()">{t('settings.copy')}</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PREVIEW -->
|
||||
<div class="editor-preview">
|
||||
<div class="editor-preview-toolbar">
|
||||
<span style="font-size:.8rem;color:var(--muted)">{de ? 'Vorschau (A4)' : 'Preview (A4)'}</span>
|
||||
<div class="flex gap-2">
|
||||
<span id="preview-save-status" class="save-status hidden text-xs">✓</span>
|
||||
<button onclick="exportPDF()" class="btn btn-accent btn-sm">↓ PDF</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="preview-wrap" id="preview-wrap">
|
||||
<div class="preview-scale-wrap" id="preview-scale">
|
||||
<div id="cv-preview">
|
||||
{cv.template === 1 && <Template1 data={d} settings={s} />}
|
||||
{cv.template === 2 && <Template2 data={d} settings={s} />}
|
||||
{cv.template === 3 && <Template3 data={d} settings={s} />}
|
||||
{cv.template === 4 && <Template4 data={d} settings={s} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script define:vars={{
|
||||
cvId: cv.id,
|
||||
cvHash: cv.hash,
|
||||
cvTitle: cv.title,
|
||||
profileId: profile.id,
|
||||
initData: j(profile.data),
|
||||
initSettings: j(cv.settings),
|
||||
initTemplate: cv.template,
|
||||
isPublic: cv.public,
|
||||
de,
|
||||
origin: Astro.url.origin,
|
||||
}}>
|
||||
// ── State ────────────────────────────────────────────────────────
|
||||
let cvSettings = JSON.parse(initSettings);
|
||||
let cvData = JSON.parse(initData);
|
||||
let template = initTemplate;
|
||||
let saveTimer = null;
|
||||
let isDirty = false;
|
||||
|
||||
// ── Settings ─────────────────────────────────────────────────────
|
||||
document.getElementById('color-primary').addEventListener('input', function() { cvSettings.primaryColor = this.value; markDirty(); });
|
||||
document.getElementById('color-accent').addEventListener('input', function() { cvSettings.accentColor = this.value; markDirty(); });
|
||||
document.getElementById('font-family').addEventListener('change', function() { cvSettings.fontFamily = this.value; markDirty(); });
|
||||
document.getElementById('font-heading').addEventListener('change', function() { cvSettings.fontHeading = this.value; markDirty(); });
|
||||
document.getElementById('cv-lang').addEventListener('change', function() { cvSettings.language = this.value; markDirty(); });
|
||||
document.getElementById('show-photo').addEventListener('change', function() { cvSettings.showPhoto = this.checked; markDirty(); });
|
||||
document.getElementById('cv-public').addEventListener('change', async function() {
|
||||
await saveNow({ public: this.checked });
|
||||
const linkRow = document.getElementById('public-link');
|
||||
if (this.checked) {
|
||||
if (!linkRow) {
|
||||
document.getElementById('cv-public').parentElement.insertAdjacentHTML('afterend',
|
||||
`<div class="flex gap-2"><input type="text" class="form-input" id="public-link" value="${origin}/cv/${cvHash}" readonly style="font-size:.75rem"/><button class="btn btn-ghost btn-sm" onclick="copyLink()">${de ? 'Kopieren' : 'Copy'}</button></div>`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
window.selectTemplate = function(el) {
|
||||
document.querySelectorAll('.template-option').forEach(e => e.classList.remove('selected'));
|
||||
el.classList.add('selected');
|
||||
template = parseInt(el.dataset.id);
|
||||
markDirty();
|
||||
};
|
||||
|
||||
window.copyLink = function() {
|
||||
const link = document.getElementById('public-link');
|
||||
if (link) { navigator.clipboard.writeText(link.value); }
|
||||
};
|
||||
|
||||
// ── Save & Preview ───────────────────────────────────────────────
|
||||
function markDirty() {
|
||||
isDirty = true;
|
||||
clearTimeout(saveTimer);
|
||||
saveTimer = setTimeout(saveNow, 2000);
|
||||
const s = document.getElementById('save-status');
|
||||
s.className = 'save-status saving';
|
||||
s.textContent = de ? '⏳ Speichert...' : '⏳ Saving...';
|
||||
s.classList.remove('hidden');
|
||||
refreshPreview();
|
||||
}
|
||||
|
||||
async function saveNow(extra = {}) {
|
||||
const body = {
|
||||
title: cvTitle,
|
||||
template,
|
||||
settings: cvSettings,
|
||||
...extra
|
||||
};
|
||||
const res = await fetch(`/api/cv/${cvId}/update`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
if (res.ok) {
|
||||
isDirty = false;
|
||||
const s = document.getElementById('save-status');
|
||||
s.className = 'save-status';
|
||||
s.textContent = '✓ ' + (de ? 'Gespeichert' : 'Saved');
|
||||
setTimeout(() => s.classList.add('hidden'), 3000);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshPreview() {
|
||||
const preview = document.getElementById('cv-preview');
|
||||
// Using a new parameter format: profile_id
|
||||
const res = await fetch(`/api/cv/${cvId}/preview`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ template, settings: cvSettings, profile_id: profileId })
|
||||
});
|
||||
if (res.ok) {
|
||||
preview.innerHTML = await res.text();
|
||||
}
|
||||
}
|
||||
|
||||
// Scale preview
|
||||
function scalePreview() {
|
||||
const wrap = document.getElementById('preview-wrap');
|
||||
const scale = document.getElementById('preview-scale');
|
||||
if (!wrap || !scale) return;
|
||||
const available = wrap.clientWidth - 48;
|
||||
const factor = Math.min(1, available / 794);
|
||||
scale.style.transform = `scale(${factor})`;
|
||||
scale.style.transformOrigin = 'top center';
|
||||
scale.style.marginBottom = `${(1 - factor) * -1123}px`;
|
||||
}
|
||||
window.addEventListener('resize', scalePreview);
|
||||
scalePreview();
|
||||
|
||||
// ── Export ───────────────────────────────────────────────────────
|
||||
document.getElementById('export-btn').addEventListener('click', () => {
|
||||
document.getElementById('export-menu').classList.toggle('hidden');
|
||||
});
|
||||
document.addEventListener('click', e => {
|
||||
if (!e.target.closest('#export-btn') && !e.target.closest('#export-menu')) {
|
||||
document.getElementById('export-menu').classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
window.exportPDF = function() {
|
||||
const el = document.getElementById('cv-preview');
|
||||
const opt = {
|
||||
margin: 0,
|
||||
filename: (cvTitle || 'lebenslauf') + '.pdf',
|
||||
image: { type: 'jpeg', quality: 0.98 },
|
||||
html2canvas: { scale: 2, useCORS: true },
|
||||
jsPDF: { unit: 'mm', format: 'a4', orientation: 'portrait' }
|
||||
};
|
||||
window.html2pdf().set(opt).from(el).save();
|
||||
};
|
||||
|
||||
window.exportHTML = function() {
|
||||
const el = document.getElementById('cv-preview');
|
||||
const html = `<!DOCTYPE html><html><head><meta charset="UTF-8"><title>${cvTitle}</title><style>${getAllStyles()}</style></head><body style="margin:0;background:#E8ECF0;display:flex;justify-content:center;padding:32px">${el.outerHTML}</body></html>`;
|
||||
download(html, cvTitle + '.html', 'text/html');
|
||||
};
|
||||
|
||||
function getAllStyles() {
|
||||
return Array.from(document.styleSheets).map(ss => {
|
||||
try { return Array.from(ss.cssRules).map(r => r.cssText).join('\n'); } catch { return ''; }
|
||||
}).join('\n');
|
||||
}
|
||||
|
||||
function download(content, filename, mime) {
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(new Blob([content], { type: mime }));
|
||||
a.download = filename; a.click();
|
||||
}
|
||||
|
||||
// ── Mobile toggle ─────────────────────────────────────────────────
|
||||
if (window.innerWidth <= 900) {
|
||||
document.getElementById('toggle-preview').style.display = 'inline-flex';
|
||||
}
|
||||
document.getElementById('toggle-preview')?.addEventListener('click', () => {
|
||||
document.getElementById('editor-layout').classList.toggle('show-preview');
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
93
src/pages/index.astro
Normal file
93
src/pages/index.astro
Normal file
@@ -0,0 +1,93 @@
|
||||
---
|
||||
import { getSessionFromRequest } from '../lib/auth';
|
||||
import { getLang, useT } from '../lib/i18n';
|
||||
|
||||
const session = getSessionFromRequest(Astro.request);
|
||||
if (session) return Astro.redirect('/dashboard');
|
||||
|
||||
const lang = getLang(Astro.request);
|
||||
const t = useT(lang);
|
||||
const redirect = Astro.url.searchParams.get('redirect') || '/dashboard';
|
||||
const err = Astro.url.searchParams.get('error');
|
||||
---
|
||||
<!DOCTYPE html>
|
||||
<html lang={lang}>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>{t('app.name')}</title>
|
||||
<link rel="stylesheet" href="/styles/global.css" />
|
||||
<style>
|
||||
.lang-toggle { position: absolute; top: 20px; right: 20px; display: flex; gap: 8px; }
|
||||
.lang-btn { background: rgba(255,255,255,.2); color: white; border: none; padding: 4px 10px; border-radius: 20px; font-size: .8rem; cursor: pointer; }
|
||||
.lang-btn.active { background: rgba(255,255,255,.4); }
|
||||
.features { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-top: 32px; }
|
||||
.feature { background: rgba(255,255,255,.08); border-radius: 8px; padding: 12px 16px; color: rgba(255,255,255,.85); font-size: .8rem; }
|
||||
.feature-icon { font-size: 1.2rem; margin-bottom: 4px; }
|
||||
.feature-title { font-weight: 600; font-size: .85rem; color: white; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="auth-page">
|
||||
<div style="position:relative">
|
||||
<div class="lang-toggle">
|
||||
<a href="?lang=de"><button class={`lang-btn ${lang==='de'?'active':''}`}>DE</button></a>
|
||||
<a href="?lang=en"><button class={`lang-btn ${lang==='en'?'active':''}`}>EN</button></a>
|
||||
</div>
|
||||
|
||||
<div class="auth-card">
|
||||
<div class="auth-logo">Lebenslauf<span>App</span></div>
|
||||
<div class="auth-subtitle">{t('app.tagline')}</div>
|
||||
|
||||
{err && <div class="alert alert-error" style="margin-bottom:16px">{err === 'invalid' ? (lang==='de' ? 'Ungültiger oder abgelaufener Code.' : 'Invalid or expired code.') : err}</div>}
|
||||
|
||||
<form id="login-form">
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="email">{t('auth.email')}</label>
|
||||
<input type="email" id="email" name="email" class="form-input" required
|
||||
placeholder="max@mustermann.de" autocomplete="email" />
|
||||
<span class="form-hint">{t('auth.email.hint')}</span>
|
||||
</div>
|
||||
<button type="submit" id="submit-btn" class="btn btn-primary w-full btn-lg">
|
||||
{t('auth.send')}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="features">
|
||||
<div class="feature"><div class="feature-icon">🎨</div><div class="feature-title">{lang==='de' ? '4 Vorlagen' : '4 Templates'}</div>{lang==='de' ? 'Modern & professionell' : 'Modern & professional'}</div>
|
||||
<div class="feature"><div class="feature-icon">📄</div><div class="feature-title">PDF Export</div>{lang==='de' ? 'Druckfertig, DIN A4' : 'Print-ready, A4'}</div>
|
||||
<div class="feature"><div class="feature-icon">🔗</div><div class="feature-title">{lang==='de' ? 'Online teilen' : 'Share Online'}</div>{lang==='de' ? 'Link mit Hash' : 'Unique hash link'}</div>
|
||||
<div class="feature"><div class="feature-icon">📊</div><div class="feature-title">{lang==='de' ? 'Daten-Export' : 'Data Export'}</div>JSON, CSV, Excel</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const form = document.getElementById('login-form') as HTMLFormElement;
|
||||
const btn = document.getElementById('submit-btn') as HTMLButtonElement;
|
||||
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const email = (document.getElementById('email') as HTMLInputElement).value;
|
||||
btn.disabled = true;
|
||||
btn.textContent = '...';
|
||||
|
||||
const res = await fetch('/api/auth/request-otp', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, lang: new URLSearchParams(location.search).get('lang') || 'de' })
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
window.location.href = `/verify?email=${encodeURIComponent(email)}&redirect=${encodeURIComponent(new URLSearchParams(location.search).get('redirect') || '/dashboard')}`;
|
||||
} else {
|
||||
btn.disabled = false;
|
||||
btn.textContent = document.documentElement.lang === 'en' ? 'Send Code' : 'Code senden';
|
||||
const data = await res.json();
|
||||
alert(data.error || 'Error');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
526
src/pages/profile/[id].astro
Normal file
526
src/pages/profile/[id].astro
Normal file
@@ -0,0 +1,526 @@
|
||||
---
|
||||
import { getProfileById } from '../../lib/db';
|
||||
import { getLang, useT } from '../../lib/i18n';
|
||||
|
||||
const { id } = Astro.params;
|
||||
const user = Astro.locals.user;
|
||||
const lang = getLang(Astro.request);
|
||||
const t = useT(lang);
|
||||
|
||||
const profile = getProfileById(id!, user.id);
|
||||
if (!profile) return Astro.redirect('/dashboard');
|
||||
|
||||
const d = profile.data;
|
||||
const p = d.personal;
|
||||
const de = lang === 'de';
|
||||
|
||||
function j(v: any) { return JSON.stringify(v).replace(/</g, '\\u003c'); }
|
||||
---
|
||||
<!DOCTYPE html>
|
||||
<html lang={lang}>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>{profile.title} – {t('app.name')}</title>
|
||||
<link rel="stylesheet" href="/styles/global.css" />
|
||||
</head>
|
||||
<body style="background:#f9f9f9">
|
||||
<header class="app-header">
|
||||
<div class="flex items-center gap-3">
|
||||
<a href="/dashboard" class="logo" style="font-size:.95rem">Lebenslauf<span>App</span></a>
|
||||
<span style="color:rgba(255,255,255,.3)">›</span>
|
||||
<span style="color:rgba(255,255,255,.9);font-size:.9rem">{profile.title} (Datensatz)</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<input type="file" id="import-json" accept=".json" class="hidden" />
|
||||
<button id="btn-import" class="btn btn-ghost btn-sm" style="color:rgba(255,255,255,.9)">📥 {de ? 'Import (JSON)' : 'Import (JSON)'}</button>
|
||||
<button id="btn-export" class="btn btn-ghost btn-sm" style="color:rgba(255,255,255,.9)">📤 {de ? 'Export (JSON)' : 'Export (JSON)'}</button>
|
||||
<span id="save-status" class="save-status hidden">✓ {t('editor.save')}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="editor-layout" style="grid-template-columns: 1fr; max-width: 800px; margin: 0 auto;">
|
||||
<div class="editor-sidebar" style="border:1px solid #eee; border-radius:8px; margin-top:24px; box-shadow:0 4px 12px rgba(0,0,0,.05)">
|
||||
<div class="editor-tabs" id="editor-tabs">
|
||||
{[
|
||||
['personal', t('editor.tab.personal')],
|
||||
['profile', t('editor.tab.profile')],
|
||||
['experience', t('editor.tab.experience')],
|
||||
['education', t('editor.tab.education')],
|
||||
['skills', t('editor.tab.skills')],
|
||||
['languages', t('editor.tab.languages')],
|
||||
['more', t('editor.tab.more')],
|
||||
].map(([key, label]) => (
|
||||
<button class={`editor-tab ${key === 'personal' ? 'active' : ''}`} data-tab={key}>{label}</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div class="editor-content" id="editor-content">
|
||||
<!-- PERSONAL -->
|
||||
<div class="tab-pane" id="tab-personal">
|
||||
<div class="form-group">
|
||||
<label class="form-label">{t('cv.photo')}</label>
|
||||
<div id="photo-preview" style={`width:80px;height:80px;border-radius:50%;overflow:hidden;border:2px solid #ddd;background:#f0f0f0;display:grid;place-items:center`}>
|
||||
{p.photo ? <img src={p.photo} style="width:100%;height:100%;object-fit:cover" /> : <span style="font-size:2rem">👤</span>}
|
||||
</div>
|
||||
<input type="file" id="photo-file" accept="image/*" class="hidden" />
|
||||
<div class="flex gap-2 mt-1">
|
||||
<button class="btn btn-ghost btn-sm" onclick="document.getElementById('photo-file').click()">{t('cv.photo.upload')}</button>
|
||||
<button class="btn btn-ghost btn-sm" id="remove-photo">{t('cv.photo.remove')}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label class="form-label">{t('cv.firstName')}</label><input type="text" class="form-input cv-field" data-path="personal.firstName" value={p.firstName} /></div>
|
||||
<div class="form-group"><label class="form-label">{t('cv.lastName')}</label><input type="text" class="form-input cv-field" data-path="personal.lastName" value={p.lastName} /></div>
|
||||
</div>
|
||||
<div class="form-group"><label class="form-label">{t('cv.jobTitle')}</label><input type="text" class="form-input cv-field" data-path="personal.jobTitle" value={p.jobTitle} /></div>
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label class="form-label">{t('cv.email')}</label><input type="email" class="form-input cv-field" data-path="personal.email" value={p.email} /></div>
|
||||
<div class="form-group"><label class="form-label">{t('cv.phone')}</label><input type="tel" class="form-input cv-field" data-path="personal.phone" value={p.phone} /></div>
|
||||
</div>
|
||||
<div class="form-group"><label class="form-label">{t('cv.address')}</label><input type="text" class="form-input cv-field" data-path="personal.address" value={p.address} /></div>
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label class="form-label">{t('cv.zipCode')}</label><input type="text" class="form-input cv-field" data-path="personal.zipCode" value={p.zipCode} /></div>
|
||||
<div class="form-group"><label class="form-label">{t('cv.city')}</label><input type="text" class="form-input cv-field" data-path="personal.city" value={p.city} /></div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label class="form-label">{t('cv.birthDate')}</label><input type="text" class="form-input cv-field" data-path="personal.birthDate" value={p.birthDate} placeholder="01.01.1990" /></div>
|
||||
<div class="form-group"><label class="form-label">{t('cv.birthPlace')}</label><input type="text" class="form-input cv-field" data-path="personal.birthPlace" value={p.birthPlace} /></div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label class="form-label">{t('cv.nationality')}</label><input type="text" class="form-input cv-field" data-path="personal.nationality" value={p.nationality} /></div>
|
||||
<div class="form-group"><label class="form-label">{t('cv.maritalStatus')}</label><input type="text" class="form-input cv-field" data-path="personal.maritalStatus" value={p.maritalStatus} /></div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label class="form-label">{t('cv.linkedin')}</label><input type="text" class="form-input cv-field" data-path="personal.linkedin" value={p.linkedin} /></div>
|
||||
<div class="form-group"><label class="form-label">{t('cv.website')}</label><input type="text" class="form-input cv-field" data-path="personal.website" value={p.website} /></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PROFILE -->
|
||||
<div class="tab-pane hidden" id="tab-profile">
|
||||
<div class="form-group">
|
||||
<label class="form-label">{t('cv.profile')}</label>
|
||||
<textarea id="profile-text" class="form-input form-textarea" style="min-height:160px" placeholder={t('cv.profile.placeholder')}>{d.profile}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- EXPERIENCE -->
|
||||
<div class="tab-pane hidden" id="tab-experience">
|
||||
<div id="experience-list"></div>
|
||||
<button id="add-experience" class="btn btn-ghost w-full mt-2">+ {t('cv.experience.add')}</button>
|
||||
</div>
|
||||
|
||||
<!-- EDUCATION -->
|
||||
<div class="tab-pane hidden" id="tab-education">
|
||||
<div id="education-list"></div>
|
||||
<button id="add-education" class="btn btn-ghost w-full mt-2">+ {t('cv.education.add')}</button>
|
||||
</div>
|
||||
|
||||
<!-- SKILLS -->
|
||||
<div class="tab-pane hidden" id="tab-skills">
|
||||
<div id="skills-list"></div>
|
||||
<button id="add-skill" class="btn btn-ghost w-full mt-2">+ {t('cv.skills.add')}</button>
|
||||
</div>
|
||||
|
||||
<!-- LANGUAGES -->
|
||||
<div class="tab-pane hidden" id="tab-languages">
|
||||
<div id="languages-list"></div>
|
||||
<button id="add-language" class="btn btn-ghost w-full mt-2">+ {t('cv.languages.add')}</button>
|
||||
</div>
|
||||
|
||||
<!-- MORE -->
|
||||
<div class="tab-pane hidden" id="tab-more">
|
||||
<div class="form-group">
|
||||
<label class="form-label">{t('cv.interests')}</label>
|
||||
<input type="text" id="interests-input" class="form-input" placeholder={t('cv.interests.placeholder')} value={d.interests.join(', ')} />
|
||||
</div>
|
||||
<hr class="divider" />
|
||||
<div class="form-group"><label class="form-label" style="font-weight:600">{t('cv.certifications')}</label></div>
|
||||
<div id="certs-list"></div>
|
||||
<button id="add-cert" class="btn btn-ghost w-full mt-1">+ {t('cv.certifications.add')}</button>
|
||||
<hr class="divider" />
|
||||
<div class="form-group"><label class="form-label" style="font-weight:600">{t('cv.achievements')}</label></div>
|
||||
<div id="achievements-list"></div>
|
||||
<button id="add-achievement" class="btn btn-ghost w-full mt-1">+ {t('cv.achievements.add')}</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script define:vars={{
|
||||
profileId: profile.id,
|
||||
initData: j(profile.data),
|
||||
de
|
||||
}}>
|
||||
// ── State ────────────────────────────────────────────────────────
|
||||
let cvData = JSON.parse(initData);
|
||||
let saveTimer = null;
|
||||
let isDirty = false;
|
||||
|
||||
// ── Utilities ────────────────────────────────────────────────────
|
||||
function genId() { return Math.random().toString(36).slice(2,10); }
|
||||
function setPath(obj, path, val) {
|
||||
const parts = path.split('.');
|
||||
let cur = obj;
|
||||
for (let i = 0; i < parts.length - 1; i++) cur = cur[parts[i]];
|
||||
cur[parts[parts.length-1]] = val;
|
||||
}
|
||||
|
||||
// ── Tabs ─────────────────────────────────────────────────────────
|
||||
document.querySelectorAll('.editor-tab').forEach(tab => {
|
||||
tab.addEventListener('click', () => {
|
||||
document.querySelectorAll('.editor-tab').forEach(t => t.classList.remove('active'));
|
||||
document.querySelectorAll('.tab-pane').forEach(p => p.classList.add('hidden'));
|
||||
tab.classList.add('active');
|
||||
document.getElementById('tab-' + tab.dataset.tab).classList.remove('hidden');
|
||||
});
|
||||
});
|
||||
|
||||
// ── CV Fields ────────────────────────────────────────────────────
|
||||
document.querySelectorAll('.cv-field').forEach(input => {
|
||||
input.addEventListener('input', () => {
|
||||
setPath(cvData, input.dataset.path, input.value);
|
||||
markDirty();
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('profile-text').addEventListener('input', function() {
|
||||
cvData.profile = this.value;
|
||||
markDirty();
|
||||
});
|
||||
|
||||
document.getElementById('interests-input').addEventListener('input', function() {
|
||||
cvData.interests = this.value.split(',').map(s => s.trim()).filter(Boolean);
|
||||
markDirty();
|
||||
});
|
||||
|
||||
// ── Photo ────────────────────────────────────────────────────────
|
||||
document.getElementById('photo-file').addEventListener('change', function() {
|
||||
const file = this.files[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = e => {
|
||||
cvData.personal.photo = e.target.result;
|
||||
const prev = document.getElementById('photo-preview');
|
||||
prev.innerHTML = `<img src="${e.target.result}" style="width:100%;height:100%;object-fit:cover"/>`;
|
||||
markDirty();
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
document.getElementById('remove-photo').addEventListener('click', () => {
|
||||
cvData.personal.photo = '';
|
||||
document.getElementById('photo-preview').innerHTML = '<span style="font-size:2rem">👤</span>';
|
||||
markDirty();
|
||||
});
|
||||
|
||||
// ── Dynamic Lists ─────────────────────────────────────────────────
|
||||
function renderExperience() {
|
||||
const list = document.getElementById('experience-list');
|
||||
list.innerHTML = '';
|
||||
cvData.experience.forEach((e, i) => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'item-card open';
|
||||
div.innerHTML = `
|
||||
<div class="item-card-header" onclick="this.closest('.item-card').classList.toggle('open')">
|
||||
<span class="item-card-title">${e.jobTitle || e.company || (de ? 'Neue Stelle' : 'New Position')}</span>
|
||||
<div class="item-card-actions">
|
||||
<button class="btn btn-danger btn-sm" onclick="removeExp(${i},event)">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="item-card-body">
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label class="form-label">${de?'Von':'From'}</label><input type="text" class="form-input exp-field" data-i="${i}" data-f="dateFrom" value="${e.dateFrom}" placeholder="MM/YYYY"/></div>
|
||||
<div class="form-group"><label class="form-label">${de?'Bis':'To'}</label><input type="text" class="form-input exp-field" data-i="${i}" data-f="dateTo" value="${e.dateTo}" placeholder="MM/YYYY" ${e.current?'disabled':''}/></div>
|
||||
</div>
|
||||
<div class="form-group" style="flex-direction:row;align-items:center;gap:8px">
|
||||
<input type="checkbox" class="exp-current" data-i="${i}" ${e.current?'checked':''} style="width:14px;height:14px"/>
|
||||
<label class="form-label" style="margin:0">${de?'Aktuell':'Current'}</label>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label class="form-label">${de?'Position':'Job Title'}</label><input type="text" class="form-input exp-field" data-i="${i}" data-f="jobTitle" value="${e.jobTitle}"/></div>
|
||||
<div class="form-group"><label class="form-label">${de?'Unternehmen':'Company'}</label><input type="text" class="form-input exp-field" data-i="${i}" data-f="company" value="${e.company}"/></div>
|
||||
</div>
|
||||
<div class="form-group"><label class="form-label">${de?'Ort':'Location'}</label><input type="text" class="form-input exp-field" data-i="${i}" data-f="location" value="${e.location}"/></div>
|
||||
<div class="form-group"><label class="form-label">${de?'Beschreibung':'Description'}</label><textarea class="form-input form-textarea exp-field" data-i="${i}" data-f="description" style="min-height:60px">${e.description}</textarea></div>
|
||||
<div class="form-group"><label class="form-label">${de?'Stichpunkte (einer pro Zeile)':'Bullet points (one per line)'}</label><textarea class="form-input form-textarea exp-field" data-i="${i}" data-f="bullets" style="min-height:80px">${e.bullets.join('\n')}</textarea></div>
|
||||
</div>`;
|
||||
list.appendChild(div);
|
||||
});
|
||||
bindExpFields();
|
||||
}
|
||||
|
||||
function bindExpFields() {
|
||||
document.querySelectorAll('.exp-field').forEach(f => {
|
||||
f.addEventListener('input', function() {
|
||||
const i = parseInt(this.dataset.i);
|
||||
const field = this.dataset.f;
|
||||
cvData.experience[i][field] = field === 'bullets'
|
||||
? this.value.split('\n').filter(Boolean)
|
||||
: this.value;
|
||||
updateItemTitle(this.closest('.item-card'), cvData.experience[i].jobTitle || cvData.experience[i].company);
|
||||
markDirty();
|
||||
});
|
||||
});
|
||||
document.querySelectorAll('.exp-current').forEach(f => {
|
||||
f.addEventListener('change', function() {
|
||||
const i = parseInt(this.dataset.i);
|
||||
cvData.experience[i].current = this.checked;
|
||||
renderExperience();
|
||||
markDirty();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
window.removeExp = function(i, e) {
|
||||
e.stopPropagation();
|
||||
cvData.experience.splice(i, 1);
|
||||
renderExperience();
|
||||
markDirty();
|
||||
};
|
||||
|
||||
document.getElementById('add-experience').addEventListener('click', () => {
|
||||
cvData.experience.push({ id: genId(), dateFrom: '', dateTo: '', current: false, jobTitle: '', company: '', location: '', description: '', bullets: [] });
|
||||
renderExperience();
|
||||
markDirty();
|
||||
});
|
||||
|
||||
// Education
|
||||
function renderEducation() {
|
||||
const list = document.getElementById('education-list');
|
||||
list.innerHTML = '';
|
||||
cvData.education.forEach((e, i) => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'item-card open';
|
||||
div.innerHTML = `
|
||||
<div class="item-card-header" onclick="this.closest('.item-card').classList.toggle('open')">
|
||||
<span class="item-card-title">${e.degree || e.school || (de?'Neue Ausbildung':'New Education')}</span>
|
||||
<div class="item-card-actions"><button class="btn btn-danger btn-sm" onclick="removeEdu(${i},event)">✕</button></div>
|
||||
</div>
|
||||
<div class="item-card-body">
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label class="form-label">${de?'Von':'From'}</label><input type="text" class="form-input edu-field" data-i="${i}" data-f="dateFrom" value="${e.dateFrom}"/></div>
|
||||
<div class="form-group"><label class="form-label">${de?'Bis':'To'}</label><input type="text" class="form-input edu-field" data-i="${i}" data-f="dateTo" value="${e.dateTo}" ${e.current?'disabled':''}/></div>
|
||||
</div>
|
||||
<div class="form-group" style="flex-direction:row;align-items:center;gap:8px">
|
||||
<input type="checkbox" class="edu-current" data-i="${i}" ${e.current?'checked':''} style="width:14px;height:14px"/>
|
||||
<label class="form-label" style="margin:0">${de?'Aktuell':'Current'}</label>
|
||||
</div>
|
||||
<div class="form-group"><label class="form-label">${de?'Abschluss':'Degree'}</label><input type="text" class="form-input edu-field" data-i="${i}" data-f="degree" value="${e.degree}"/></div>
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label class="form-label">${de?'Schule/Uni':'School/University'}</label><input type="text" class="form-input edu-field" data-i="${i}" data-f="school" value="${e.school}"/></div>
|
||||
<div class="form-group"><label class="form-label">${de?'Ort':'Location'}</label><input type="text" class="form-input edu-field" data-i="${i}" data-f="location" value="${e.location}"/></div>
|
||||
</div>
|
||||
<div class="form-group"><label class="form-label">${de?'Beschreibung':'Description'}</label><textarea class="form-input form-textarea edu-field" data-i="${i}" data-f="description" style="min-height:60px">${e.description}</textarea></div>
|
||||
<div class="form-group"><label class="form-label">${de?'Stichpunkte':'Bullet points'}</label><textarea class="form-input form-textarea edu-field" data-i="${i}" data-f="bullets" style="min-height:60px">${e.bullets.join('\n')}</textarea></div>
|
||||
</div>`;
|
||||
list.appendChild(div);
|
||||
});
|
||||
document.querySelectorAll('.edu-field').forEach(f => {
|
||||
f.addEventListener('input', function() {
|
||||
const i = parseInt(this.dataset.i), field = this.dataset.f;
|
||||
cvData.education[i][field] = field === 'bullets' ? this.value.split('\n').filter(Boolean) : this.value;
|
||||
updateItemTitle(this.closest('.item-card'), cvData.education[i].degree || cvData.education[i].school);
|
||||
markDirty();
|
||||
});
|
||||
});
|
||||
document.querySelectorAll('.edu-current').forEach(f => {
|
||||
f.addEventListener('change', function() {
|
||||
cvData.education[parseInt(this.dataset.i)].current = this.checked;
|
||||
renderEducation(); markDirty();
|
||||
});
|
||||
});
|
||||
}
|
||||
window.removeEdu = function(i, e) { e.stopPropagation(); cvData.education.splice(i,1); renderEducation(); markDirty(); };
|
||||
document.getElementById('add-education').addEventListener('click', () => {
|
||||
cvData.education.push({ id: genId(), dateFrom:'', dateTo:'', current:false, degree:'', school:'', location:'', description:'', bullets:[] });
|
||||
renderEducation(); markDirty();
|
||||
});
|
||||
|
||||
// Skills
|
||||
function renderSkills() {
|
||||
const list = document.getElementById('skills-list');
|
||||
list.innerHTML = '';
|
||||
cvData.skills.forEach((sk, i) => {
|
||||
const div = document.createElement('div');
|
||||
div.style.cssText = 'display:grid;grid-template-columns:1fr auto 80px 24px;gap:8px;align-items:center;margin-bottom:8px';
|
||||
div.innerHTML = `
|
||||
<input type="text" class="form-input sk-name" data-i="${i}" value="${sk.name}" placeholder="${de?'Kenntniss':'Skill'}" style="font-size:.8rem"/>
|
||||
<div class="skill-dots" data-i="${i}">
|
||||
${[1,2,3,4,5].map(l => `<div class="skill-dot ${l<=sk.level?'filled':''}" data-i="${i}" data-l="${l}" onclick="setSkillLevel(${i},${l})"></div>`).join('')}
|
||||
</div>
|
||||
<input type="text" class="form-input sk-cat" data-i="${i}" value="${sk.category}" placeholder="${de?'Kategorie':'Category'}" style="font-size:.8rem"/>
|
||||
<button class="btn btn-danger btn-sm" onclick="removeSk(${i})">✕</button>`;
|
||||
list.appendChild(div);
|
||||
});
|
||||
document.querySelectorAll('.sk-name').forEach(f => {
|
||||
f.addEventListener('input', function() { cvData.skills[parseInt(this.dataset.i)].name = this.value; markDirty(); });
|
||||
});
|
||||
document.querySelectorAll('.sk-cat').forEach(f => {
|
||||
f.addEventListener('input', function() { cvData.skills[parseInt(this.dataset.i)].category = this.value; markDirty(); });
|
||||
});
|
||||
}
|
||||
window.setSkillLevel = function(i, l) { cvData.skills[i].level = l; renderSkills(); markDirty(); };
|
||||
window.removeSk = function(i) { cvData.skills.splice(i,1); renderSkills(); markDirty(); };
|
||||
document.getElementById('add-skill').addEventListener('click', () => {
|
||||
cvData.skills.push({ id: genId(), name:'', level:3, category:'' });
|
||||
renderSkills(); markDirty();
|
||||
});
|
||||
|
||||
// Languages
|
||||
const LANG_LEVELS = ['Muttersprache','A1','A2','B1','B2','C1','C2',de?'Fließend':'Fluent',de?'Grundkenntnisse':'Basic'];
|
||||
function renderLanguages() {
|
||||
const list = document.getElementById('languages-list');
|
||||
list.innerHTML = '';
|
||||
cvData.languages.forEach((l, i) => {
|
||||
const div = document.createElement('div');
|
||||
div.style.cssText = 'display:grid;grid-template-columns:1fr 1fr 24px;gap:8px;align-items:center;margin-bottom:8px';
|
||||
div.innerHTML = `
|
||||
<input type="text" class="form-input lng-name" data-i="${i}" value="${l.name}" placeholder="${de?'Sprache':'Language'}" style="font-size:.8rem"/>
|
||||
<select class="form-input form-select lng-level" data-i="${i}" style="font-size:.8rem">
|
||||
${LANG_LEVELS.map(lv => `<option ${l.level===lv?'selected':''}>${lv}</option>`).join('')}
|
||||
<option ${!LANG_LEVELS.includes(l.level)?'selected':''}>${l.level}</option>
|
||||
</select>
|
||||
<button class="btn btn-danger btn-sm" onclick="removeLng(${i})">✕</button>`;
|
||||
list.appendChild(div);
|
||||
});
|
||||
document.querySelectorAll('.lng-name').forEach(f => {
|
||||
f.addEventListener('input', function() { cvData.languages[parseInt(this.dataset.i)].name = this.value; markDirty(); });
|
||||
});
|
||||
document.querySelectorAll('.lng-level').forEach(f => {
|
||||
f.addEventListener('change', function() { cvData.languages[parseInt(this.dataset.i)].level = this.value; markDirty(); });
|
||||
});
|
||||
}
|
||||
window.removeLng = function(i) { cvData.languages.splice(i,1); renderLanguages(); markDirty(); };
|
||||
document.getElementById('add-language').addEventListener('click', () => {
|
||||
cvData.languages.push({ id: genId(), name:'', level:'B2' });
|
||||
renderLanguages(); markDirty();
|
||||
});
|
||||
|
||||
// Certifications
|
||||
function renderCerts() {
|
||||
const list = document.getElementById('certs-list');
|
||||
list.innerHTML = '';
|
||||
cvData.certifications.forEach((c, i) => {
|
||||
const div = document.createElement('div');
|
||||
div.style.cssText = 'border:1px solid #ddd;border-radius:6px;padding:10px;margin-bottom:8px';
|
||||
div.innerHTML = `
|
||||
<div style="display:flex;justify-content:space-between;margin-bottom:6px">
|
||||
<strong style="font-size:.8rem">${c.name||'...'}</strong>
|
||||
<button class="btn btn-danger btn-sm" onclick="removeCert(${i})">✕</button>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:6px">
|
||||
<input type="text" class="form-input cert-f" data-i="${i}" data-f="dateFrom" value="${c.dateFrom}" placeholder="Von" style="font-size:.8rem"/>
|
||||
<input type="text" class="form-input cert-f" data-i="${i}" data-f="dateTo" value="${c.dateTo}" placeholder="Bis" style="font-size:.8rem"/>
|
||||
<input type="text" class="form-input cert-f" data-i="${i}" data-f="name" value="${c.name}" placeholder="${de?'Kursname':'Course Name'}" style="font-size:.8rem"/>
|
||||
<input type="text" class="form-input cert-f" data-i="${i}" data-f="issuer" value="${c.issuer}" placeholder="${de?'Anbieter':'Provider'}" style="font-size:.8rem"/>
|
||||
<input type="text" class="form-input cert-f" data-i="${i}" data-f="location" value="${c.location}" placeholder="Ort" style="font-size:.8rem" style="grid-column:span 2"/>
|
||||
</div>`;
|
||||
list.appendChild(div);
|
||||
});
|
||||
document.querySelectorAll('.cert-f').forEach(f => {
|
||||
f.addEventListener('input', function() { cvData.certifications[parseInt(this.dataset.i)][this.dataset.f] = this.value; markDirty(); });
|
||||
});
|
||||
}
|
||||
window.removeCert = function(i) { cvData.certifications.splice(i,1); renderCerts(); markDirty(); };
|
||||
document.getElementById('add-cert').addEventListener('click', () => {
|
||||
cvData.certifications.push({ id: genId(), dateFrom:'', dateTo:'', name:'', issuer:'', location:'' });
|
||||
renderCerts(); markDirty();
|
||||
});
|
||||
|
||||
// Achievements
|
||||
function renderAchievements() {
|
||||
const list = document.getElementById('achievements-list');
|
||||
list.innerHTML = '';
|
||||
cvData.achievements.forEach((a, i) => {
|
||||
const div = document.createElement('div');
|
||||
div.style.cssText = 'display:grid;grid-template-columns:1fr 24px;gap:8px;margin-bottom:8px;align-items:center';
|
||||
div.innerHTML = `
|
||||
<input type="text" class="form-input ach-f" data-i="${i}" value="${a}" style="font-size:.8rem"/>
|
||||
<button class="btn btn-danger btn-sm" onclick="removeAch(${i})">✕</button>`;
|
||||
list.appendChild(div);
|
||||
});
|
||||
document.querySelectorAll('.ach-f').forEach(f => {
|
||||
f.addEventListener('input', function() { cvData.achievements[parseInt(this.dataset.i)] = this.value; markDirty(); });
|
||||
});
|
||||
}
|
||||
window.removeAch = function(i) { cvData.achievements.splice(i,1); renderAchievements(); markDirty(); };
|
||||
document.getElementById('add-achievement').addEventListener('click', () => {
|
||||
cvData.achievements.push(''); renderAchievements(); markDirty();
|
||||
});
|
||||
|
||||
function updateItemTitle(card, title) {
|
||||
if (title) card.querySelector('.item-card-title').textContent = title;
|
||||
}
|
||||
|
||||
// ── Init lists ───────────────────────────────────────────────────
|
||||
renderExperience(); renderEducation(); renderSkills();
|
||||
renderLanguages(); renderCerts(); renderAchievements();
|
||||
|
||||
// ── Import / Export ──────────────────────────────────────────────
|
||||
document.getElementById('btn-export').addEventListener('click', () => {
|
||||
const dataStr = JSON.stringify(cvData, null, 2);
|
||||
const blob = new Blob([dataStr], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'lebenslauf_daten.json';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
});
|
||||
|
||||
document.getElementById('btn-import').addEventListener('click', () => {
|
||||
document.getElementById('import-json').click();
|
||||
});
|
||||
|
||||
document.getElementById('import-json').addEventListener('change', function(e) {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (ev) => {
|
||||
try {
|
||||
const imported = JSON.parse(ev.target.result);
|
||||
if (imported && imported.personal) {
|
||||
cvData = imported;
|
||||
await saveNow();
|
||||
window.location.reload();
|
||||
} else {
|
||||
alert(de ? 'Ungültige JSON-Datei' : 'Invalid JSON file');
|
||||
}
|
||||
} catch (err) {
|
||||
alert(de ? 'Fehler beim Lesen der Datei' : 'Error reading file');
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
});
|
||||
|
||||
// ── Save ─────────────────────────────────────────────────────────
|
||||
function markDirty() {
|
||||
isDirty = true;
|
||||
clearTimeout(saveTimer);
|
||||
saveTimer = setTimeout(saveNow, 2000);
|
||||
const s = document.getElementById('save-status');
|
||||
s.className = 'save-status saving';
|
||||
s.textContent = de ? '⏳ Speichert...' : '⏳ Saving...';
|
||||
s.classList.remove('hidden');
|
||||
}
|
||||
|
||||
async function saveNow() {
|
||||
const body = { data: cvData };
|
||||
const res = await fetch(`/api/profile/${profileId}/update`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
if (res.ok) {
|
||||
isDirty = false;
|
||||
const s = document.getElementById('save-status');
|
||||
s.className = 'save-status';
|
||||
s.textContent = '✓ ' + (de ? 'Gespeichert' : 'Saved');
|
||||
setTimeout(() => s.classList.add('hidden'), 3000);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
100
src/pages/verify.astro
Normal file
100
src/pages/verify.astro
Normal file
@@ -0,0 +1,100 @@
|
||||
---
|
||||
import { getLang, useT } from '../lib/i18n';
|
||||
const lang = getLang(Astro.request);
|
||||
const t = useT(lang);
|
||||
const email = Astro.url.searchParams.get('email') || '';
|
||||
const redirect = Astro.url.searchParams.get('redirect') || '/dashboard';
|
||||
---
|
||||
<!DOCTYPE html>
|
||||
<html lang={lang}>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>{t('auth.otp.title')} – {t('app.name')}</title>
|
||||
<link rel="stylesheet" href="/styles/global.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="auth-page">
|
||||
<div class="auth-card">
|
||||
<div class="auth-logo">Lebenslauf<span>App</span></div>
|
||||
<div class="auth-subtitle">{t('auth.otp.hint', { email })}</div>
|
||||
|
||||
<div id="alert-box" class="hidden"></div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="otp">{t('auth.otp.label')}</label>
|
||||
<input type="text" id="otp" class="otp-input" maxlength="6"
|
||||
pattern="[0-9]{6}" inputmode="numeric" autocomplete="one-time-code"
|
||||
placeholder="000000" />
|
||||
</div>
|
||||
|
||||
<button id="verify-btn" class="btn btn-primary w-full btn-lg mt-2">
|
||||
{t('auth.otp.submit')}
|
||||
</button>
|
||||
|
||||
<div style="text-align:center;margin-top:16px">
|
||||
<button id="resend-btn" class="btn btn-ghost btn-sm">
|
||||
{t('auth.otp.resend')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style="text-align:center;margin-top:12px">
|
||||
<a href="/" style="font-size:.8rem;color:#888">← {lang==='de' ? 'Zurück' : 'Back'}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script define:vars={{ email, redirect, lang }}>
|
||||
const otpInput = document.getElementById('otp');
|
||||
const verifyBtn = document.getElementById('verify-btn');
|
||||
const resendBtn = document.getElementById('resend-btn');
|
||||
const alertBox = document.getElementById('alert-box');
|
||||
|
||||
function showAlert(msg, type = 'error') {
|
||||
alertBox.className = `alert alert-${type}`;
|
||||
alertBox.textContent = msg;
|
||||
alertBox.classList.remove('hidden');
|
||||
}
|
||||
|
||||
async function verify() {
|
||||
const otp = otpInput.value.trim();
|
||||
if (otp.length !== 6) return showAlert(lang==='de' ? 'Bitte 6-stelligen Code eingeben.' : 'Please enter the 6-digit code.');
|
||||
verifyBtn.disabled = true;
|
||||
verifyBtn.textContent = '...';
|
||||
|
||||
const res = await fetch('/api/auth/verify-otp', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, otp })
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
window.location.href = redirect || '/dashboard';
|
||||
} else {
|
||||
verifyBtn.disabled = false;
|
||||
verifyBtn.textContent = lang === 'en' ? 'Sign In' : 'Anmelden';
|
||||
showAlert(lang==='de' ? 'Ungültiger oder abgelaufener Code.' : 'Invalid or expired code.');
|
||||
}
|
||||
}
|
||||
|
||||
verifyBtn.addEventListener('click', verify);
|
||||
otpInput.addEventListener('keydown', e => e.key === 'Enter' && verify());
|
||||
otpInput.addEventListener('input', () => {
|
||||
if (otpInput.value.length === 6) verify();
|
||||
});
|
||||
|
||||
resendBtn.addEventListener('click', async () => {
|
||||
resendBtn.disabled = true;
|
||||
await fetch('/api/auth/request-otp', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, lang })
|
||||
});
|
||||
showAlert(lang==='de' ? 'Code erneut gesendet!' : 'Code resent!', 'success');
|
||||
setTimeout(() => { resendBtn.disabled = false; }, 30000);
|
||||
});
|
||||
|
||||
otpInput.focus();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
385
src/styles/global.css
Normal file
385
src/styles/global.css
Normal file
@@ -0,0 +1,385 @@
|
||||
/* ── Reset & Base ─────────────────────────────────────────────────── */
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
--navy: #1B2A5E;
|
||||
--accent: #4A7BC5;
|
||||
--bg: #F5F7FA;
|
||||
--surface: #FFFFFF;
|
||||
--border: #E2E8F0;
|
||||
--text: #1A202C;
|
||||
--muted: #64748B;
|
||||
--danger: #E53E3E;
|
||||
--success: #38A169;
|
||||
--radius: 8px;
|
||||
--shadow: 0 1px 3px rgba(0,0,0,.1), 0 1px 2px rgba(0,0,0,.06);
|
||||
--shadow-md: 0 4px 6px -1px rgba(0,0,0,.1), 0 2px 4px -1px rgba(0,0,0,.06);
|
||||
}
|
||||
|
||||
html { font-size: 16px; }
|
||||
body { font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; background: var(--bg); color: var(--text); line-height: 1.5; }
|
||||
a { color: var(--accent); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
button { cursor: pointer; font-family: inherit; }
|
||||
input, textarea, select { font-family: inherit; }
|
||||
|
||||
/* ── Layout ───────────────────────────────────────────────────────── */
|
||||
.app-header {
|
||||
background: var(--navy);
|
||||
color: white;
|
||||
padding: 0 24px;
|
||||
height: 56px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
.app-header .logo { font-size: 1.1rem; font-weight: 700; color: white; letter-spacing: -0.3px; }
|
||||
.app-header .logo span { color: var(--accent); }
|
||||
.app-header nav { display: flex; align-items: center; gap: 16px; }
|
||||
.app-header nav a { color: rgba(255,255,255,.8); font-size: .875rem; transition: color .2s; }
|
||||
.app-header nav a:hover { color: white; text-decoration: none; }
|
||||
.app-header .btn-logout {
|
||||
background: rgba(255,255,255,.12);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 6px 14px;
|
||||
border-radius: var(--radius);
|
||||
font-size: .875rem;
|
||||
transition: background .2s;
|
||||
}
|
||||
.app-header .btn-logout:hover { background: rgba(255,255,255,.22); }
|
||||
|
||||
.container { max-width: 1200px; margin: 0 auto; padding: 0 24px; }
|
||||
.page { padding: 32px 24px; max-width: 1200px; margin: 0 auto; }
|
||||
|
||||
/* ── Buttons ──────────────────────────────────────────────────────── */
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 8px 16px; border-radius: var(--radius);
|
||||
font-size: .875rem; font-weight: 500;
|
||||
border: none; transition: all .15s;
|
||||
text-decoration: none;
|
||||
}
|
||||
.btn-primary { background: var(--navy); color: white; }
|
||||
.btn-primary:hover { background: #243572; text-decoration: none; }
|
||||
.btn-accent { background: var(--accent); color: white; }
|
||||
.btn-accent:hover { background: #3a6ab5; text-decoration: none; }
|
||||
.btn-ghost { background: transparent; color: var(--text); border: 1px solid var(--border); }
|
||||
.btn-ghost:hover { background: var(--bg); text-decoration: none; }
|
||||
.btn-danger { background: var(--danger); color: white; border: none; }
|
||||
.btn-danger:hover { background: #c53030; }
|
||||
.btn-sm { padding: 5px 10px; font-size: .8rem; }
|
||||
.btn-lg { padding: 12px 24px; font-size: 1rem; }
|
||||
.btn:disabled { opacity: .5; cursor: not-allowed; }
|
||||
|
||||
/* ── Cards ────────────────────────────────────────────────────────── */
|
||||
.card {
|
||||
background: var(--surface); border: 1px solid var(--border);
|
||||
border-radius: var(--radius); box-shadow: var(--shadow);
|
||||
overflow: hidden;
|
||||
}
|
||||
.card-body { padding: 24px; }
|
||||
.card-header { padding: 16px 24px; border-bottom: 1px solid var(--border); background: var(--bg); }
|
||||
.card-footer { padding: 16px 24px; border-top: 1px solid var(--border); background: var(--bg); }
|
||||
|
||||
/* ── Forms ────────────────────────────────────────────────────────── */
|
||||
.form-group { display: flex; flex-direction: column; gap: 6px; margin-bottom: 16px; }
|
||||
.form-label { font-size: .875rem; font-weight: 500; color: var(--text); }
|
||||
.form-input {
|
||||
padding: 8px 12px; border: 1px solid var(--border); border-radius: var(--radius);
|
||||
font-size: .875rem; background: var(--surface); color: var(--text);
|
||||
transition: border-color .15s, box-shadow .15s;
|
||||
}
|
||||
.form-input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(74,123,197,.15); }
|
||||
.form-textarea { min-height: 80px; resize: vertical; }
|
||||
.form-select { appearance: none; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%2364748B' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 8px center; padding-right: 32px; }
|
||||
.form-hint { font-size: .75rem; color: var(--muted); }
|
||||
.form-error { font-size: .75rem; color: var(--danger); }
|
||||
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||
@media (max-width: 600px) { .form-row { grid-template-columns: 1fr; } }
|
||||
|
||||
/* ── Auth Page ────────────────────────────────────────────────────── */
|
||||
.auth-page {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: linear-gradient(135deg, var(--navy) 0%, #2d4a9e 100%);
|
||||
padding: 24px;
|
||||
}
|
||||
.auth-card {
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 40px 36px;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,.3);
|
||||
}
|
||||
.auth-logo { font-size: 1.5rem; font-weight: 800; color: var(--navy); margin-bottom: 8px; }
|
||||
.auth-logo span { color: var(--accent); }
|
||||
.auth-subtitle { color: var(--muted); font-size: .875rem; margin-bottom: 32px; }
|
||||
.otp-input {
|
||||
width: 100%; text-align: center; font-size: 2rem; font-weight: 700;
|
||||
letter-spacing: 12px; padding: 16px; border: 2px solid var(--border);
|
||||
border-radius: var(--radius); font-family: monospace;
|
||||
transition: border-color .15s;
|
||||
}
|
||||
.otp-input:focus { outline: none; border-color: var(--accent); }
|
||||
|
||||
/* ── Dashboard ────────────────────────────────────────────────────── */
|
||||
.cv-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 20px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
.cv-card {
|
||||
background: var(--surface); border: 1px solid var(--border);
|
||||
border-radius: var(--radius); overflow: hidden;
|
||||
transition: box-shadow .2s, transform .2s;
|
||||
cursor: pointer;
|
||||
}
|
||||
.cv-card:hover { box-shadow: var(--shadow-md); transform: translateY(-2px); }
|
||||
.cv-card-preview {
|
||||
height: 180px;
|
||||
background: linear-gradient(135deg, var(--navy) 0%, #243572 40%, #3a6ab5 100%);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.cv-card-preview .t-badge {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
background: rgba(255,255,255,.2);
|
||||
color: white;
|
||||
font-size: .7rem;
|
||||
padding: 2px 8px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
.cv-card-preview .preview-mini {
|
||||
width: 90px;
|
||||
height: 127px;
|
||||
background: white;
|
||||
border-radius: 3px;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,.3);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.cv-card-body { padding: 16px; }
|
||||
.cv-card-title { font-weight: 600; font-size: .95rem; margin-bottom: 4px; }
|
||||
.cv-card-meta { font-size: .75rem; color: var(--muted); }
|
||||
.cv-card-actions { display: flex; gap: 8px; padding: 12px 16px; border-top: 1px solid var(--border); }
|
||||
|
||||
/* ── Editor ───────────────────────────────────────────────────────── */
|
||||
.editor-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 360px 1fr;
|
||||
height: calc(100vh - 56px);
|
||||
overflow: hidden;
|
||||
}
|
||||
.editor-sidebar {
|
||||
background: var(--surface);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.editor-tabs {
|
||||
display: flex;
|
||||
overflow-x: auto;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.editor-tabs::-webkit-scrollbar { display: none; }
|
||||
.editor-tab {
|
||||
padding: 10px 14px;
|
||||
font-size: .8rem;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
color: var(--muted);
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: all .15s;
|
||||
}
|
||||
.editor-tab.active { color: var(--navy); border-bottom-color: var(--navy); }
|
||||
.editor-tab:hover { color: var(--text); }
|
||||
.editor-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
}
|
||||
.editor-preview {
|
||||
background: #E8ECF0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
.editor-preview-toolbar {
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 10px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.preview-wrap {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
}
|
||||
.preview-scale-wrap {
|
||||
transform-origin: top center;
|
||||
width: 794px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.save-status {
|
||||
font-size: .75rem;
|
||||
color: var(--success);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.save-status.saving { color: var(--muted); }
|
||||
|
||||
/* ── Section Items ────────────────────────────────────────────────── */
|
||||
.item-card {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
margin-bottom: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.item-card-header {
|
||||
padding: 10px 14px;
|
||||
background: var(--bg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.item-card-title { font-size: .875rem; font-weight: 500; }
|
||||
.item-card-body { padding: 14px; display: none; }
|
||||
.item-card.open .item-card-body { display: block; }
|
||||
.item-card-actions { display: flex; gap: 6px; }
|
||||
|
||||
/* ── Skill level ──────────────────────────────────────────────────── */
|
||||
.skill-dots { display: flex; gap: 4px; align-items: center; }
|
||||
.skill-dot {
|
||||
width: 14px; height: 14px; border-radius: 50%;
|
||||
border: 2px solid var(--accent);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
transition: background .15s;
|
||||
}
|
||||
.skill-dot.filled { background: var(--accent); }
|
||||
|
||||
/* ── Color picker ─────────────────────────────────────────────────── */
|
||||
.color-row { display: flex; align-items: center; gap: 10px; }
|
||||
.color-swatch { width: 36px; height: 36px; border-radius: 6px; border: 2px solid var(--border); cursor: pointer; overflow: hidden; padding: 0; }
|
||||
.color-swatch input[type=color] { width: 200%; height: 200%; margin: -50%; border: none; cursor: pointer; }
|
||||
|
||||
/* ── Template selector ────────────────────────────────────────────── */
|
||||
.template-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
||||
.template-option {
|
||||
border: 2px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: border-color .15s;
|
||||
}
|
||||
.template-option.selected { border-color: var(--accent); }
|
||||
.template-option:hover { border-color: var(--accent); }
|
||||
.template-thumb {
|
||||
height: 100px;
|
||||
background: linear-gradient(135deg, #1B2A5E, #4A7BC5);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.template-label { padding: 8px; font-size: .75rem; font-weight: 500; text-align: center; }
|
||||
|
||||
/* ── CV Templates (A4) ────────────────────────────────────────────── */
|
||||
.cv-a4 {
|
||||
width: 794px;
|
||||
min-height: 1123px;
|
||||
background: white;
|
||||
position: relative;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,.15);
|
||||
}
|
||||
|
||||
/* ── Public CV Page ───────────────────────────────────────────────── */
|
||||
.cv-public-wrap {
|
||||
min-height: 100vh;
|
||||
background: #E8ECF0;
|
||||
padding: 32px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
.cv-public-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* ── Alerts ───────────────────────────────────────────────────────── */
|
||||
.alert { padding: 12px 16px; border-radius: var(--radius); font-size: .875rem; margin-bottom: 16px; }
|
||||
.alert-success { background: #f0fff4; border: 1px solid #9ae6b4; color: #276749; }
|
||||
.alert-error { background: #fff5f5; border: 1px solid #fed7d7; color: #9b2c2c; }
|
||||
.alert-info { background: #ebf8ff; border: 1px solid #bee3f8; color: #2a4a6b; }
|
||||
|
||||
/* ── Utils ────────────────────────────────────────────────────────── */
|
||||
.flex { display: flex; }
|
||||
.items-center { align-items: center; }
|
||||
.justify-between { justify-content: space-between; }
|
||||
.gap-2 { gap: 8px; }
|
||||
.gap-3 { gap: 12px; }
|
||||
.mt-1 { margin-top: 4px; }
|
||||
.mt-2 { margin-top: 8px; }
|
||||
.mt-4 { margin-top: 16px; }
|
||||
.mt-6 { margin-top: 24px; }
|
||||
.mb-2 { margin-bottom: 8px; }
|
||||
.mb-4 { margin-bottom: 16px; }
|
||||
.text-sm { font-size: .875rem; }
|
||||
.text-xs { font-size: .75rem; }
|
||||
.text-muted { color: var(--muted); }
|
||||
.font-bold { font-weight: 700; }
|
||||
.font-semibold { font-weight: 600; }
|
||||
.w-full { width: 100%; }
|
||||
.hidden { display: none !important; }
|
||||
.divider { border: none; border-top: 1px solid var(--border); margin: 20px 0; }
|
||||
|
||||
/* ── Print ────────────────────────────────────────────────────────── */
|
||||
@media print {
|
||||
body { background: white !important; }
|
||||
.cv-public-actions, .app-header { display: none !important; }
|
||||
.cv-public-wrap { padding: 0 !important; background: white !important; }
|
||||
.cv-a4 { box-shadow: none !important; }
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.editor-layout { grid-template-columns: 1fr; }
|
||||
.editor-preview { display: none; }
|
||||
.editor-layout.show-preview .editor-sidebar { display: none; }
|
||||
.editor-layout.show-preview .editor-preview { display: flex; }
|
||||
.preview-scale-wrap { width: 100%; transform: none !important; }
|
||||
.cv-a4 { width: 100% !important; }
|
||||
}
|
||||
153
src/types.ts
Normal file
153
src/types.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
export interface User {
|
||||
id: number;
|
||||
email: string;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
export interface Session {
|
||||
id: string;
|
||||
user_id: number;
|
||||
email: string;
|
||||
expires_at: number;
|
||||
}
|
||||
|
||||
export interface Profile {
|
||||
id: string;
|
||||
user_id: number;
|
||||
title: string;
|
||||
language: string;
|
||||
data: CVData;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
export interface CV {
|
||||
id: string;
|
||||
user_id: number;
|
||||
profile_id: string;
|
||||
hash: string;
|
||||
title: string;
|
||||
template: number;
|
||||
settings: CVSettings;
|
||||
public: boolean;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
export interface CVSettings {
|
||||
primaryColor: string;
|
||||
accentColor: string;
|
||||
bgColor: string;
|
||||
fontFamily: string;
|
||||
fontHeading?: string;
|
||||
fontSize: 'small' | 'medium' | 'large';
|
||||
language: string;
|
||||
showPhoto: boolean;
|
||||
paperSize: 'a4' | 'letter';
|
||||
}
|
||||
|
||||
export interface CVData {
|
||||
personal: PersonalInfo;
|
||||
profile: string;
|
||||
experience: WorkExperience[];
|
||||
education: Education[];
|
||||
skills: Skill[];
|
||||
languages: Language[];
|
||||
interests: string[];
|
||||
certifications: Certification[];
|
||||
achievements: string[];
|
||||
}
|
||||
|
||||
export interface PersonalInfo {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
jobTitle: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
address: string;
|
||||
city: string;
|
||||
zipCode: string;
|
||||
birthDate: string;
|
||||
birthPlace: string;
|
||||
nationality: string;
|
||||
maritalStatus: string;
|
||||
linkedin: string;
|
||||
website: string;
|
||||
photo: string;
|
||||
}
|
||||
|
||||
export interface WorkExperience {
|
||||
id: string;
|
||||
dateFrom: string;
|
||||
dateTo: string;
|
||||
current: boolean;
|
||||
jobTitle: string;
|
||||
company: string;
|
||||
location: string;
|
||||
description: string;
|
||||
bullets: string[];
|
||||
}
|
||||
|
||||
export interface Education {
|
||||
id: string;
|
||||
dateFrom: string;
|
||||
dateTo: string;
|
||||
current: boolean;
|
||||
degree: string;
|
||||
school: string;
|
||||
location: string;
|
||||
description: string;
|
||||
bullets: string[];
|
||||
}
|
||||
|
||||
export interface Skill {
|
||||
id: string;
|
||||
name: string;
|
||||
level: number;
|
||||
category: string;
|
||||
}
|
||||
|
||||
export interface Language {
|
||||
id: string;
|
||||
name: string;
|
||||
level: string;
|
||||
}
|
||||
|
||||
export interface Certification {
|
||||
id: string;
|
||||
dateFrom: string;
|
||||
dateTo: string;
|
||||
name: string;
|
||||
issuer: string;
|
||||
location: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: CVSettings = {
|
||||
primaryColor: '#1B2A5E',
|
||||
accentColor: '#4A7BC5',
|
||||
bgColor: '#FFFFFF',
|
||||
fontFamily: 'Arial, Calibri, sans-serif',
|
||||
fontHeading: 'Arial, Calibri, sans-serif',
|
||||
fontSize: 'medium',
|
||||
language: 'de',
|
||||
showPhoto: true,
|
||||
paperSize: 'a4'
|
||||
};
|
||||
|
||||
export const DEFAULT_DATA: CVData = {
|
||||
personal: {
|
||||
firstName: '', lastName: '', jobTitle: '', email: '', phone: '',
|
||||
address: '', city: '', zipCode: '', birthDate: '', birthPlace: '',
|
||||
nationality: '', maritalStatus: '', linkedin: '', website: '', photo: ''
|
||||
},
|
||||
profile: '',
|
||||
experience: [], education: [], skills: [], languages: [],
|
||||
interests: [], certifications: [], achievements: []
|
||||
};
|
||||
|
||||
export const TEMPLATES = [
|
||||
{ id: 1, name: 'Navy Klassik', desc: 'Dunkle Sidebar, professionell', preview: '1' },
|
||||
{ id: 2, name: 'Modern Timeline', desc: 'Zeitachsen-Layout, zeitgemäß', preview: '2' },
|
||||
{ id: 3, name: 'Minimal Elegant', desc: 'Clean, viel Weißraum', preview: '3' },
|
||||
{ id: 4, name: 'Bold Creative', desc: 'Künstlerisch, mit großem Foto', preview: '4' },
|
||||
];
|
||||
7
tsconfig.json
Normal file
7
tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"compilerOptions": {
|
||||
"strictNullChecks": true,
|
||||
"allowJs": true
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user