Grundgerüst mit Server, PIN-Anmeldung und Docker-Deploy aufbauen

Die alte Clip-Auswahl-Oberfläche entfällt. Neu sind ein Express-Server,
der im Entwicklungsbetrieb Vite einbindet, eine PIN-Anmeldung mit
signiertem Sitzungs-Cookie und ein Docker-Image mit ffmpeg für VCH.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
ferdi2goandClaude Opus 5.5 committed 2026-09-29 09:15:01 +00:00
1 parent 8ed1396b1c
commit f7d249f28b
23 files changed
+4456 -1358

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
node_modules
dist
data
.git
.env
.vch
+4
View File
@@ -0,0 +1,4 @@
# PIN für die Anmeldung (Pflicht)
APP_PIN=
# Signiert die Sitzungs-Cookies; ohne Wert wird eines im Datenverzeichnis erzeugt
SESSION_SECRET= # optional
+1
View File
@@ -3,3 +3,4 @@ dist/
.env
.env.local
.env.*.local
data/
+1 -1
View File
@@ -1 +1 @@
A desktop tool for assembling weekly "Best-of" video reels from WhatsApp clips. Import clips, get automatic transcriptions via Whisper, review AI recommendations for each clip, select what goes into the reel, and reorder via drag-and-drop before exporting with ffmpeg.
FuerDieCuts macht aus einem WhatsApp-Chatexport automatisch ein fertiges Video für YouTube. Man lädt den Export mit allen Medien hoch, hakt an, was passieren soll, und bekommt ein geschnittenes Video zurück: alle Clips in der Reihenfolge, in der sie im Chat gepostet wurden, jeder auf höchstens zehn Sekunden beschleunigt, mit eigenem Intro und Outro, Musik darunter und auf Wunsch einem gesprochenen Kommentar zu jedem Clip. Das ersetzt stundenlanges Schneiden von Hand, wenn man regelmäßig aus Dutzenden Handyvideos einer Gruppe einen Zusammenschnitt macht.
+3
View File
@@ -0,0 +1,3 @@
runtime: docker
port: 3000
health: /healthz
+16
View File
@@ -0,0 +1,16 @@
FROM node:20-bookworm-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends ffmpeg \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build && npm prune --omit=dev
# APP_PORT hält den Server im Container fest auf dem Port aus .vch/deploy.yaml.
ENV NODE_ENV=production APP_PORT=3000
EXPOSE 3000
CMD ["node", "server/index.js"]
+50 -23
View File
@@ -1,38 +1,65 @@
# Reelkit
# FuerDieCuts
Desktop UI for assembling weekly "Best-of" video reels from WhatsApp clips. Shows the Clip-Auswahl (clip selection) screen: browse transcribed clips, review AI recommendations, pick what goes into the reel, and reorder via drag-and-drop.
Schneidet aus einem WhatsApp-Chatexport automatisch ein YouTube-Video.
## Installation
Der Export (ZIP mit Medien) wird hochgeladen, die Videos werden anhand der
Zeitstempel im Chat chronologisch sortiert. Über eine Maske wählt man, was
automatisch passieren soll:
- Intro und Outro einfügen (eigene Dateien)
- Clips chronologisch aneinanderreihen
- Clips über 10 Sekunden auf genau 10 Sekunden beschleunigen
- Ton der Clips stummschalten (Intro und Outro ausgenommen)
- Backing Track unter den Clip-Teil legen (wird wiederholt und passend gekürzt)
- Voice-over pro Clip aufnehmen oder hochladen
- Lautstärke jeder Tonspur von 0 bis 100
Ausgabe ist ein MP4 in 1920×1080 mit 30 fps. Hochkant-Clips werden mittig mit
unscharfem Hintergrund eingesetzt. Zugang ist über eine PIN geschützt.
> Stand: Grundgerüst mit Anmeldung. Import, Schnitt und Oberfläche folgen.
## Voraussetzungen
- Node.js 20
- ffmpeg und ffprobe im `PATH` (`sudo apt install ffmpeg`)
## Installation und Start
```bash
npm install
cp .env.example .env # APP_PIN eintragen
APP_PIN=1234 npm run dev
```
## Development
```bash
npm run dev
```
## Build for production
`npm run dev` startet den Express-Server samt Vite im Entwicklungsmodus auf
einem Port (`PORT`, Vorgabe 3000). In Produktion:
```bash
npm run build
NODE_ENV=production APP_PIN=… npm start
```
## Tech Stack
## Umgebungsvariablen
- React 18
- Vite 6
- Plain CSS with CSS custom properties (oklch color space)
- Inter Tight + JetBrains Mono (Google Fonts)
| Name | Bedeutung |
|---|---|
| `APP_PIN` | PIN für die Anmeldung, Pflicht |
| `SESSION_SECRET` | optional; signiert die Sitzungs-Cookies, sonst wird eines erzeugt |
| `PORT` | Port des Servers, Vorgabe 3000 |
| `VCH_DATA_DIR` | Ablage für Projekte und Uploads, Vorgabe `./data` |
## Project Structure
## Befehle
```
src/
main.jsx — React entry point
App.jsx — Main app: clip list, sidebar, sticky footer
TweaksPanel.jsx — Floating tweaks panel (theme, density, export settings, …)
styles.css — All styles via CSS custom properties
```
- `npm test` – Tests (Node-Testrunner)
- `npm run lint` – ESLint
- `npm run build` – Frontend bauen
## Aufbau
- `server/` – Express-Server: Anmeldung, API, später Import und ffmpeg-Schnitt
- `src/` – React-Oberfläche (Vite)
- `test/` – Tests
- `Dockerfile` – Produktionsimage mit ffmpeg; Deploy über VCH (`.vch/deploy.yaml`)
Aufgebaut auf Node.js, Express, React, Vite und ffmpeg.
+27
View File
@@ -0,0 +1,27 @@
import js from '@eslint/js';
import react from 'eslint-plugin-react';
import reactHooks from 'eslint-plugin-react-hooks';
import globals from 'globals';
export default [
{ ignores: ['dist/', 'data/', 'node_modules/'] },
js.configs.recommended,
{
files: ['server/**/*.js', 'test/**/*.js', '*.config.js'],
languageOptions: { globals: globals.node },
},
{
files: ['src/**/*.{js,jsx}'],
languageOptions: {
globals: globals.browser,
parserOptions: { ecmaFeatures: { jsx: true } },
},
plugins: { react, 'react-hooks': reactHooks },
settings: { react: { version: 'detect' } },
rules: {
'react/jsx-uses-vars': 'error',
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'warn',
},
},
];
+2 -2
View File
@@ -3,10 +3,10 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Reelkit — Clip-Auswahl · KW 17</title>
<title>FuerDieCuts</title>
<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+Tight:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Inter+Tight:wght@400;500;700&display=swap" rel="stylesheet">
</head>
<body>
<div id="root"></div>
+3823 -119
View File
File diff suppressed because it is too large. Load diff
+11 -4
View File
@@ -1,20 +1,27 @@
{
"name": "reelkit",
"name": "fuerdiecuts",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"dev": "node server/index.js",
"build": "vite build",
"preview": "vite preview",
"lint": "eslint src"
"lint": "eslint .",
"start": "node server/index.js",
"test": "node --test"
},
"dependencies": {
"express": "^5.2.1",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@eslint/js": "^9.39.5",
"@vitejs/plugin-react": "^4.3.4",
"eslint": "^9.39.5",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.1.1",
"globals": "^17.12.0",
"vite": "^6.3.3"
}
}
+61
View File
@@ -0,0 +1,61 @@
import express from 'express';
import {
SESSION_COOKIE,
SESSION_MAX_AGE_MS,
createLoginLimiter,
createToken,
pinMatches,
readCookie,
verifyToken,
} from './auth.js';
export function createApp({ config, secret }) {
const app = express();
app.disable('x-powered-by');
app.set('trust proxy', true);
const limiter = createLoginLimiter();
const isAuthenticated = (req) =>
verifyToken(secret, config.pin, readCookie(req, SESSION_COOKIE));
app.get('/healthz', (req, res) => res.json({ ok: true }));
app.use('/api', express.json({ limit: '100kb' }));
app.get('/api/session', (req, res) => {
res.json({ authenticated: isAuthenticated(req), pinConfigured: Boolean(config.pin) });
});
app.post('/api/login', (req, res) => {
if (limiter.isBlocked()) {
return res.status(429).json({ error: 'Zu viele Fehlversuche. Bitte später erneut versuchen.' });
}
if (!pinMatches(config.pin, req.body?.pin)) {
limiter.recordFailure();
return res.status(401).json({ error: 'PIN falsch.' });
}
limiter.reset();
res.cookie(SESSION_COOKIE, createToken(secret, config.pin), {
httpOnly: true,
sameSite: 'lax',
secure: req.secure,
maxAge: SESSION_MAX_AGE_MS,
path: '/',
});
res.json({ authenticated: true });
});
app.post('/api/logout', (req, res) => {
res.clearCookie(SESSION_COOKIE, { path: '/' });
res.json({ authenticated: false });
});
app.use('/api', (req, res, next) => {
if (!isAuthenticated(req)) return res.status(401).json({ error: 'Nicht angemeldet.' });
next();
});
app.use('/api', (req, res) => res.status(404).json({ error: 'Nicht gefunden.' }));
return app;
}
+81
View File
@@ -0,0 +1,81 @@
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
export const SESSION_COOKIE = 'fdc_session';
export const SESSION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
// Ohne SESSION_SECRET wird einmalig ein Geheimnis im Datenverzeichnis erzeugt,
// damit Sitzungen einen Neustart überleben.
export function loadSessionSecret(config) {
if (config.sessionSecret) return config.sessionSecret;
const file = path.join(config.dataDir, 'session-secret');
try {
return fs.readFileSync(file, 'utf8').trim();
} catch {
const secret = crypto.randomBytes(32).toString('hex');
fs.mkdirSync(config.dataDir, { recursive: true });
fs.writeFileSync(file, secret, { mode: 0o600 });
return secret;
}
}
// Die PIN fließt in die Signatur ein: wer die PIN ändert, meldet alle Sitzungen ab.
function sign(secret, pin, payload) {
return crypto.createHmac('sha256', secret).update(`${payload}|${pin}`).digest('base64url');
}
function safeEqual(a, b) {
const bufA = Buffer.from(a);
const bufB = Buffer.from(b);
return bufA.length === bufB.length && crypto.timingSafeEqual(bufA, bufB);
}
export function createToken(secret, pin, now = Date.now()) {
const payload = String(now + SESSION_MAX_AGE_MS);
return `${payload}.${sign(secret, pin, payload)}`;
}
export function verifyToken(secret, pin, token, now = Date.now()) {
if (!pin || typeof token !== 'string') return false;
const [payload, signature] = token.split('.');
if (!payload || !signature) return false;
if (!safeEqual(signature, sign(secret, pin, payload))) return false;
return Number(payload) > now;
}
export function pinMatches(expected, given) {
if (!expected || typeof given !== 'string') return false;
const hash = (value) => crypto.createHash('sha256').update(value).digest();
return crypto.timingSafeEqual(hash(expected), hash(given));
}
// Nur ein Nutzer: ein globaler Zähler reicht und greift auch hinter dem Proxy.
export function createLoginLimiter({ maxFailures = 10, windowMs = 15 * 60 * 1000 } = {}) {
let failures = [];
return {
isBlocked(now = Date.now()) {
failures = failures.filter((time) => now - time < windowMs);
return failures.length >= maxFailures;
},
recordFailure(now = Date.now()) {
failures.push(now);
},
reset() {
failures = [];
},
};
}
export function readCookie(req, name) {
const header = req.headers.cookie;
if (!header) return undefined;
for (const part of header.split(';')) {
const index = part.indexOf('=');
if (index === -1) continue;
if (part.slice(0, index).trim() === name) {
return decodeURIComponent(part.slice(index + 1).trim());
}
}
return undefined;
}
+12
View File
@@ -0,0 +1,12 @@
import path from 'node:path';
export function loadConfig(env = process.env) {
return {
// Im Docker-Image fest auf 3000 (APP_PORT), sonst der von VCH vergebene PORT.
port: Number(env.APP_PORT || env.PORT || 3000),
pin: env.APP_PIN || '',
sessionSecret: env.SESSION_SECRET || '',
dataDir: path.resolve(env.VCH_DATA_DIR || './data'),
isProduction: env.NODE_ENV === 'production',
};
}
+39
View File
@@ -0,0 +1,39 @@
import http from 'node:http';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import express from 'express';
import { createApp } from './app.js';
import { loadSessionSecret } from './auth.js';
import { loadConfig } from './config.js';
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const config = loadConfig();
if (!config.pin) {
console.warn('APP_PIN ist nicht gesetzt – Anmeldung ist nicht möglich.');
}
const app = createApp({ config, secret: loadSessionSecret(config) });
const server = http.createServer(app);
if (config.isProduction) {
const distDir = path.join(rootDir, 'dist');
app.use(express.static(distDir));
app.use((req, res, next) => {
if (req.method !== 'GET') return next();
res.sendFile(path.join(distDir, 'index.html'));
});
} else {
// Im Entwicklungsbetrieb liefert Vite das Frontend über denselben Port aus.
const { createServer } = await import('vite');
const vite = await createServer({
root: rootDir,
appType: 'spa',
server: { middlewareMode: true, hmr: { server } },
});
app.use(vite.middlewares);
}
server.listen(config.port, '0.0.0.0', () => {
console.log(`FuerDieCuts läuft auf Port ${config.port}`);
});
+28 -551
View File
@@ -1,569 +1,46 @@
import { useState, useEffect, useMemo, useRef } from 'react'
import './styles.css'
import {
useTweaks, TweaksPanel, TweakSection,
TweakSlider, TweakToggle, TweakRadio, TweakSelect,
} from './TweaksPanel.jsx'
import { useEffect, useState } from 'react'
import { api } from './api.js'
import Login from './Login.jsx'
// ===== DATA =====
const CLIPS_SEED = [
{ id: "c1", name: "IMG-20260418-WA0023.mp4", from: "Mama", duration: 18, size: "4.2 MB", hue: 18, status: "done", rec: "empfohlen", reason: "Klare Pointe am Ende, gut hörbar, reagiert direkt auf Papas Witz.", transcript: "… also ich sag' euch, der Hund hat sich den ganzen Kuchen geschnappt und ist damit unter den Tisch —", tags: ["Familie","Moment"] },
{ id: "c2", name: "IMG-20260418-WA0027.mp4", from: "Jonas", duration: 42, size: "9.8 MB", hue: 210, status: "done", rec: "empfohlen", reason: "Starker visueller Moment (Sonnenuntergang), ruhig eingesprochen.", transcript: "Guck mal wie das Licht gerade auf dem Wasser tanzt, das ist so verrückt, ich steh' hier schon seit zehn Minuten.", tags: ["Outdoor","Ruhig"] },
{ id: "c3", name: "IMG-20260418-WA0031.mp4", from: "Lena", duration: 8, size: "1.9 MB", hue: 340, status: "done", rec: "optional", reason: "Kurz und charmant, aber Ton etwas leise.", transcript: "Heey, ich wollt nur kurz sagen — ich hab's geschafft!", tags: ["Kurz"] },
{ id: "c4", name: "IMG-20260419-WA0002.mp4", from: "Papa", duration: 55, size: "12.1 MB", hue: 35, status: "done", rec: "überspringen", reason: "Lange Stille in der Mitte, Kameraruckler ab 0:32.", transcript: "So, jetzt müsste das eigentlich funktionieren … ähm … moment …", tags: ["Technik"] },
{ id: "c5", name: "IMG-20260419-WA0014.mp4", from: "Tante Rita", duration: 23, size: "5.4 MB", hue: 120, status: "done", rec: "empfohlen", reason: "Warme Begrüßung, gute Energie, passender Einstieg.", transcript: "Ihr Lieben, ich wünsch euch allen einen wunderbaren Sonntag und denkt dran —", tags: ["Gruß","Warm"] },
{ id: "c6", name: "IMG-20260419-WA0019.mp4", from: "Max", duration: 31, size: "7.2 MB", hue: 260, status: "done", rec: "optional", reason: "Gute Musik im Hintergrund, Sprache teils überlagert.", transcript: "Das Konzert gestern war einfach — ich hab keine Worte, Leute, das war —", tags: ["Musik"] },
{ id: "c7", name: "IMG-20260420-WA0005.mp4", from: "Mama", duration: 12, size: "2.8 MB", hue: 18, status: "done", rec: "empfohlen", reason: "Lacher, hohe emotionale Dichte in kurzer Zeit.", transcript: "Nein nein nein das kann nicht sein haha das ist nicht euer Ernst —", tags: ["Lacher"] },
{ id: "c8", name: "IMG-20260420-WA0011.mp4", from: "Jonas", duration: 47, size: "10.9 MB", hue: 210, status: "transcribing", progress: 64, rec: null, reason: null, transcript: null, tags: [] },
{ id: "c9", name: "IMG-20260420-WA0018.mp4", from: "Lena", duration: 15, size: "3.4 MB", hue: 340, status: "transcribing", progress: 22, rec: null, reason: null, transcript: null, tags: [] },
{ id: "c10", name: "IMG-20260421-WA0003.mp4", from: "Papa", duration: 29, size: "6.7 MB", hue: 35, status: "queued", rec: null, reason: null, transcript: null, tags: [] },
{ id: "c11", name: "IMG-20260421-WA0009.mp4", from: "Oma", duration: 38, size: "8.6 MB", hue: 80, status: "done", rec: "empfohlen", reason: "Seltener O-Ton von Oma, sehr bewegend.", transcript: "Weißt du, als ich in deinem Alter war, gab's das alles noch nicht und trotzdem —", tags: ["Herz","Familie"] },
{ id: "c12", name: "IMG-20260421-WA0021.mp4", from: "Max", duration: 19, size: "4.5 MB", hue: 260, status: "done", rec: "optional", reason: "Solider Moment, aber thematisch ähnlich zu Clip 06.", transcript: "Und dann hat er noch gesagt, dass das nächste Album schon —", tags: ["Musik"] },
{ id: "c13", name: "IMG-20260422-WA0001.mp4", from: "Tante Rita", duration: 52, size: "11.8 MB", hue: 120, status: "done", rec: "überspringen", reason: "Großer Teil ist Anleitung, wenig Reel-Material.", transcript: "Also ihr nehmt zuerst die Zwiebeln, schneidet die klein, dann kommt der Knoblauch dazu —", tags: ["Tutorial"] },
{ id: "c14", name: "IMG-20260422-WA0008.mp4", from: "Jonas", duration: 11, size: "2.6 MB", hue: 210, status: "done", rec: "empfohlen", reason: "Punchline mit klarer Pointe, starker Abschluss.", transcript: "… und das, meine Damen und Herren, ist der Grund warum ich keine Katzen mag.", tags: ["Punchline"] },
{ id: "c15", name: "IMG-20260422-WA0015.mp4", from: "Mama", duration: 26, size: "6.0 MB", hue: 18, status: "done", rec: "optional", reason: "Nett, aber wiederholt Thema aus Clip 01.", transcript: "Der Hund macht's schon wieder, der sitzt schon wieder unter'm Tisch —", tags: ["Familie"] },
{ id: "c16", name: "IMG-20260423-WA0004.mp4", from: "Lena", duration: 33, size: "7.5 MB", hue: 340, status: "done", rec: "empfohlen", reason: "Persönliche Reflexion, gut geschnitten, ruhige Kamera.", transcript: "Ich hab in der Woche echt viel über das nachgedacht was du letztens meintest und —", tags: ["Persönlich"] },
{ id: "c17", name: "IMG-20260423-WA0012.mp4", from: "Max", duration: 44, size: "10.2 MB", hue: 260, status: "done", rec: "überspringen", reason: "Vertikal/horizontal-Wechsel, Schnitt wird unruhig.", transcript: "Wartet, ich dreh mal um — nee andersrum — ok jetzt —", tags: ["Unruhig"] },
{ id: "c18", name: "IMG-20260423-WA0020.mp4", from: "Papa", duration: 21, size: "4.9 MB", hue: 35, status: "done", rec: "optional", reason: "Ordentlicher Ton, Inhalt wiederholt sich später.", transcript: "Hab gestern das neue Regal aufgebaut und es steht, gradmal vier Stunden gedauert.", tags: ["Alltag"] },
]
const SELECTED_SEED = ["c2","c5","c1","c7","c14","c16","c11"]
// ===== THUMB =====
function Thumb({ hue, duration, selected, index, w = 180, h = 108 }) {
const bg = `linear-gradient(135deg, oklch(0.22 0.04 ${hue}) 0%, oklch(0.14 0.03 ${hue}) 60%, oklch(0.09 0.02 ${hue}) 100%)`
const mm = String(Math.floor(duration / 60)).padStart(1, "0")
const ss = String(duration % 60).padStart(2, "0")
return (
<div className="thumb" style={{ background: bg, width: w, height: h }}>
<svg className="thumb-stripes" viewBox="0 0 160 100" preserveAspectRatio="none">
<defs>
<pattern id={`p-${hue}`} width="18" height="18" patternUnits="userSpaceOnUse" patternTransform="rotate(35)">
<line x1="0" y1="0" x2="0" y2="18" stroke={`oklch(0.30 0.03 ${hue})`} strokeWidth="1" />
</pattern>
</defs>
<rect width="160" height="100" fill={`url(#p-${hue})`} opacity="0.35" />
</svg>
<div className="thumb-play">
<svg width="22" height="22" viewBox="0 0 22 22"><path d="M8 6 L16 11 L8 16 Z" fill="rgba(255,255,255,0.92)" /></svg>
</div>
<div className="thumb-dur">{mm}:{ss}</div>
{selected && <div className="thumb-badge">#{index}</div>}
</div>
)
}
// ===== SIDEBAR =====
function Sidebar({ current, counts }) {
const steps = [
{ id: "import", label: "Import", sub: `${counts.total} Clips geladen` },
{ id: "transcribe",label: "Transkription", sub: `${counts.done}/${counts.total} fertig` },
{ id: "select", label: "Clip-Auswahl", sub: `${counts.selected} ausgewählt` },
{ id: "export", label: "Assembly & Export", sub: `~${counts.totalDur}s Reel` },
]
return (
<aside className="sidebar">
<div className="brand">
<div className="brand-mark"><div /><div /><div /></div>
<div className="brand-text">
<div className="brand-name">Reelkit</div>
<div className="brand-sub">Wochenrückblick</div>
</div>
</div>
<div className="project">
<div className="project-label">Aktuelles Projekt</div>
<div className="project-name">KW 17 · Familie & Freunde</div>
<div className="project-meta">18 Clips · 9,3 min Rohmaterial</div>
</div>
<nav className="steps">
{steps.map((s, i) => (
<a key={s.id} className={`step ${s.id === current ? "is-current" : ""} ${i < steps.findIndex(x => x.id === current) ? "is-done" : ""}`}>
<span className="step-num">{String(i + 1).padStart(2, "0")}</span>
<span className="step-body">
<span className="step-label">{s.label}</span>
<span className="step-sub">{s.sub}</span>
</span>
</a>
))}
</nav>
<div className="side-foot">
<div className="foot-row"><span>Speicher</span><span className="mono">2,1 / 10 GB</span></div>
<div className="foot-bar"><div style={{ width: "21%" }} /></div>
<div className="foot-hint">Whisper-Modell: <span className="mono">medium · de</span></div>
</div>
</aside>
)
}
// ===== CLIP ROW =====
function ClipRow({ clip, selected, orderIndex, onToggle, onDrag, onDragOver, onDrop, onHover, hovered, tweaks }) {
const thumbSizes = { small: { w: 130, h: 78 }, medium: { w: 180, h: 108 }, large: { w: 230, h: 138 } }
const ts = thumbSizes[tweaks.thumbSize] || thumbSizes.medium
const recMeta = {
"empfohlen": { dot: "rec-good", label: "empfohlen" },
"optional": { dot: "rec-mid", label: "optional" },
"überspringen": { dot: "rec-skip", label: "überspringen" },
}[clip.rec] || null
const statusLabel = {
"queued": "in Warteschlange",
"transcribing": `transkribiere … ${clip.progress ?? 0}%`,
"done": "Transkript fertig",
}[clip.status]
return (
<div
className={`row ${selected ? "is-selected" : ""} ${hovered ? "is-hovered" : ""} ${clip.status !== "done" ? "is-pending" : ""}`}
draggable={clip.status === "done"}
onDragStart={(e) => onDrag(e, clip.id)}
onDragOver={(e) => onDragOver(e, clip.id)}
onDrop={(e) => onDrop(e, clip.id)}
onMouseEnter={() => onHover(clip.id)}
onMouseLeave={() => onHover(null)}
>
<div className="row-check">
<button className={`check ${selected ? "is-on" : ""}`} onClick={() => onToggle(clip.id)} disabled={clip.status !== "done"}>
{selected && <svg viewBox="0 0 12 12" width="11" height="11"><path d="M2 6.2 L5 9 L10 3.2" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" /></svg>}
</button>
{selected && <div className="order-pill mono">#{orderIndex}</div>}
</div>
<div className="row-thumb" style={{ width: ts.w }}>
<Thumb hue={clip.hue} duration={clip.duration} selected={selected} index={orderIndex} w={ts.w} h={ts.h} />
</div>
<div className="row-body">
<div className="row-head">
<div className="row-title">
<span className="from">{clip.from}</span>
{tweaks.showFilename && <>
<span className="sep">·</span>
<span className="filename mono">{clip.name}</span>
</>}
</div>
{tweaks.showMetaBar && <div className="row-meta mono">
<span>{clip.duration}s</span>
<span className="dotsep">·</span>
<span>{clip.size}</span>
<span className="dotsep">·</span>
<span className={`status s-${clip.status}`}>
<span className="status-dot" />
{statusLabel}
</span>
</div>}
</div>
{clip.status === "done" ? (
<div className="transcript" style={{ WebkitLineClamp: tweaks.transcriptLines, display: "-webkit-box", WebkitBoxOrient: "vertical", overflow: "hidden" }}>
<span className="quote">„</span>
{clip.transcript}
<span className="quote">"</span>
</div>
) : clip.status === "transcribing" ? (
<div className="transcribing">
<div className="transcribe-bar"><div style={{ width: `${clip.progress}%` }} /></div>
<div className="transcribe-hint mono">Whisper · medium · verarbeite Audio …</div>
</div>
) : (
<div className="transcribing">
<div className="transcribe-hint mono">wartet auf GPU-Slot</div>
</div>
)}
{tweaks.showTags && clip.tags.length > 0 && (
<div className="tags">
{clip.tags.map(t => <span key={t} className="tag">{t}</span>)}
</div>
)}
</div>
<div className="row-rec">
{recMeta && (
<div className={`rec ${recMeta.dot}`}>
<div className="rec-head">
<span className="rec-dot" />
<span className="rec-label">{recMeta.label}</span>
</div>
{tweaks.showReasons && clip.reason && <div className="rec-reason">{clip.reason}</div>}
</div>
)}
</div>
<div className="row-grip" title="Zum Sortieren ziehen">
<svg width="10" height="16" viewBox="0 0 10 16">
<circle cx="2" cy="3" r="1.2" /><circle cx="8" cy="3" r="1.2" />
<circle cx="2" cy="8" r="1.2" /><circle cx="8" cy="8" r="1.2" />
<circle cx="2" cy="13" r="1.2" /><circle cx="8" cy="13" r="1.2" />
</svg>
</div>
</div>
)
}
// ===== MAIN =====
export default function App() {
const [clips, setClips] = useState(CLIPS_SEED)
const [selected, setSelected] = useState(SELECTED_SEED)
const [filter, setFilter] = useState("alle")
const [sort, setSort] = useState("empfehlung")
const [query, setQuery] = useState("")
const [hovered, setHovered] = useState(null)
const dragId = useRef(null)
const [session, setSession] = useState(null)
const [loadError, setLoadError] = useState('')
const DEFAULTS = {
density: "comfortable",
showReasons: true,
accent: "moss",
targetLength: 90,
theme: "light",
thumbSize: "medium",
transcriptLines: 2,
showTags: true,
showFilename: true,
showMetaBar: true,
autoSelectRecommended: false,
groupBySender: false,
hideSkipped: false,
whisperModel: "medium",
whisperLang: "de",
aiStrictness: 2,
exportFormat: "mp4",
exportResolution: "1080p",
aspectRatio: "9:16",
addCrossfade: true,
crossfadeMs: 300,
addCaptions: false,
captionStyle: "minimal",
musicBed: "keine",
musicVolume: 20,
watermark: false,
minClipLength: 4,
maxClipLength: 60,
fontFamily: "inter-tight",
radius: 10,
cornerStyle: "soft",
async function loadSession() {
try {
setSession(await api('session'))
} catch (err) {
setLoadError(err.message)
}
}
const [tweaks, setTweaks] = useTweaks(DEFAULTS)
// Simulate transcription progress
useEffect(() => {
const t = setInterval(() => {
setClips(cs => cs.map(c => {
if (c.status === "transcribing") {
const p = Math.min(100, (c.progress ?? 0) + 3)
if (p >= 100) return { ...c, status: "done", progress: 100, rec: "optional", reason: "Frisch transkribiert, noch ungeprüft.", transcript: "… (gerade fertig transkribiert) …", tags: ["Neu"] }
return { ...c, progress: p }
}
return c
}))
}, 1200)
return () => clearInterval(t)
loadSession()
}, [])
// Apply tweaks to CSS vars
useEffect(() => {
const map = {
amber: { h: 60, c: 0.13 },
ocean: { h: 230, c: 0.10 },
moss: { h: 145, c: 0.09 },
plum: { h: 340, c: 0.10 },
crimson: { h: 25, c: 0.14 },
graphite:{ h: 260, c: 0.02 },
}
const a = map[tweaks.accent] || map.moss
const root = document.documentElement
root.style.setProperty("--accent", `oklch(0.68 ${a.c} ${a.h})`)
root.style.setProperty("--accent-2", `oklch(0.58 ${a.c} ${a.h})`)
root.style.setProperty("--accent-bg", `oklch(0.96 ${a.c * 0.3} ${a.h})`)
root.style.setProperty("--density", tweaks.density === "compact" ? "10px" : tweaks.density === "spacious" ? "22px" : "16px")
root.style.setProperty("--radius", tweaks.cornerStyle === "sharp" ? "2px" : tweaks.cornerStyle === "round" ? "16px" : "10px")
const fonts = {
"inter-tight": '"Inter Tight", "Inter", system-ui, sans-serif',
"geist": '"Geist", "Inter", system-ui, sans-serif',
"ibm-plex": '"IBM Plex Sans", system-ui, sans-serif',
"system": 'system-ui, -apple-system, sans-serif',
}
root.style.setProperty("--font", fonts[tweaks.fontFamily] || fonts["inter-tight"])
root.style.fontFamily = fonts[tweaks.fontFamily] || fonts["inter-tight"]
if (tweaks.theme === "dark") {
root.style.setProperty("--bg", "oklch(0.18 0.01 80)")
root.style.setProperty("--bg-2", "oklch(0.22 0.01 80)")
root.style.setProperty("--panel", "oklch(0.24 0.01 80)")
root.style.setProperty("--line", "oklch(0.32 0.01 80)")
root.style.setProperty("--line-2", "oklch(0.38 0.01 80)")
root.style.setProperty("--ink", "oklch(0.96 0.004 80)")
root.style.setProperty("--ink-2", "oklch(0.78 0.008 80)")
root.style.setProperty("--ink-3", "oklch(0.58 0.006 80)")
} else {
root.style.setProperty("--bg", "oklch(0.985 0.004 85)")
root.style.setProperty("--bg-2", "oklch(0.97 0.004 85)")
root.style.setProperty("--panel", "#ffffff")
root.style.setProperty("--line", "oklch(0.92 0.004 85)")
root.style.setProperty("--line-2", "oklch(0.88 0.004 85)")
root.style.setProperty("--ink", "oklch(0.18 0.01 80)")
root.style.setProperty("--ink-2", "oklch(0.38 0.008 80)")
root.style.setProperty("--ink-3", "oklch(0.58 0.006 80)")
}
const thumbCols = { small: "130px", medium: "180px", large: "230px" }
root.style.setProperty("--thumb-col", thumbCols[tweaks.thumbSize] || thumbCols.medium)
}, [tweaks.accent, tweaks.density, tweaks.cornerStyle, tweaks.fontFamily, tweaks.theme, tweaks.thumbSize])
const filtered = useMemo(() => {
let list = clips.slice()
if (filter === "empfohlen") list = list.filter(c => c.rec === "empfohlen")
if (filter === "pending") list = list.filter(c => c.status !== "done")
if (filter === "ausgewählt") list = list.filter(c => selected.includes(c.id))
if (tweaks.hideSkipped) list = list.filter(c => c.rec !== "überspringen")
if (query.trim()) {
const q = query.toLowerCase()
list = list.filter(c => (c.transcript || "").toLowerCase().includes(q) || c.from.toLowerCase().includes(q) || c.name.toLowerCase().includes(q))
}
if (sort === "empfehlung") {
const rank = { "empfohlen": 0, "optional": 1, "überspringen": 2 }
list.sort((a, b) => (rank[a.rec] ?? 3) - (rank[b.rec] ?? 3))
} else if (sort === "dauer") {
list.sort((a, b) => b.duration - a.duration)
} else if (sort === "absender") {
list.sort((a, b) => a.from.localeCompare(b.from))
}
return list
}, [clips, filter, sort, query, selected, tweaks.hideSkipped])
// Auto-select recommended
useEffect(() => {
if (!tweaks.autoSelectRecommended) return
setSelected(s => {
const add = clips.filter(c => c.rec === "empfohlen" && !s.includes(c.id)).map(c => c.id)
return add.length ? [...s, ...add] : s
})
}, [tweaks.autoSelectRecommended, clips])
const toggle = (id) => {
setSelected(s => s.includes(id) ? s.filter(x => x !== id) : [...s, id])
async function logout() {
await api('logout', { method: 'POST' })
loadSession()
}
const onDrag = (e, id) => { dragId.current = id; e.dataTransfer.effectAllowed = "move" }
const onDragOver = (e, id) => {
if (!dragId.current || dragId.current === id) return
e.preventDefault()
if (loadError) return <main className="login"><p className="error">{loadError}</p></main>
if (!session) return null
if (!session.authenticated) {
return <Login pinConfigured={session.pinConfigured} onLogin={loadSession} />
}
const onDrop = (e, id) => {
if (!dragId.current || dragId.current === id) return
e.preventDefault()
setSelected(s => {
if (!s.includes(dragId.current) || !s.includes(id)) return s
const next = s.filter(x => x !== dragId.current)
const idx = next.indexOf(id)
next.splice(idx, 0, dragId.current)
return next
})
dragId.current = null
}
const counts = {
total: clips.length,
done: clips.filter(c => c.status === "done").length,
selected: selected.length,
totalDur: selected.reduce((n, id) => n + (clips.find(c => c.id === id)?.duration || 0), 0),
}
const overTarget = counts.totalDur > tweaks.targetLength
return (
<div className="app">
<Sidebar current="select" counts={counts} />
<main className="main">
<header className="top">
<div>
<div className="breadcrumb mono">Projekt · KW 17 · <span>Clip-Auswahl</span></div>
<h1>Welche Clips kommen ins Reel?</h1>
<p className="lede">18 Clips, 7 bereits empfohlen. Wähle aus, sortiere per Drag-and-drop und prüfe die KI-Begründung. Transkripte laufen im Hintergrund.</p>
</div>
<div className="top-actions">
<button className="btn-ghost">Auswahl zurücksetzen</button>
<button className="btn-primary">Weiter zu Export
<svg width="14" height="14" viewBox="0 0 14 14"><path d="M3 7 H11 M7.5 3.5 L11 7 L7.5 10.5" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" /></svg>
</button>
</div>
</header>
<div className="strip">
<div className="strip-group">
<div className="strip-label">Auswahl</div>
<div className="strip-value">
<span className="big">{counts.selected}</span>
<span className="unit">Clips</span>
</div>
</div>
<div className="strip-sep" />
<div className="strip-group">
<div className="strip-label">Länge gewählt</div>
<div className="strip-value">
<span className={`big ${overTarget ? "over" : ""}`}>{counts.totalDur}s</span>
<span className="unit">/ Ziel {tweaks.targetLength}s</span>
</div>
<div className="strip-bar">
<div className={overTarget ? "over" : ""} style={{ width: `${Math.min(100, counts.totalDur / tweaks.targetLength * 100)}%` }} />
</div>
</div>
<div className="strip-sep" />
<div className="strip-group">
<div className="strip-label">Transkription</div>
<div className="strip-value">
<span className="big">{counts.done}</span>
<span className="unit">/ {counts.total} fertig</span>
</div>
<div className="strip-bar">
<div style={{ width: `${counts.done / counts.total * 100}%` }} />
</div>
</div>
<div className="strip-sep" />
<div className="strip-group right">
<div className="strip-label">KI-Empfehlungen</div>
<div className="rec-legend">
<span><i className="rec-good" /> 7 empfohlen</span>
<span><i className="rec-mid" /> 5 optional</span>
<span><i className="rec-skip" /> 3 überspringen</span>
</div>
</div>
</div>
<div className="toolbar">
<div className="filters">
{[
["alle", `Alle · ${clips.length}`],
["empfohlen", `Nur empfohlen · ${clips.filter(c => c.rec === "empfohlen").length}`],
["ausgewählt", `Ausgewählt · ${selected.length}`],
["pending", `In Arbeit · ${clips.filter(c => c.status !== "done").length}`],
].map(([k, l]) => (
<button key={k} className={`chip ${filter === k ? "is-on" : ""}`} onClick={() => setFilter(k)}>{l}</button>
))}
</div>
<div className="tools">
<div className="search">
<svg width="14" height="14" viewBox="0 0 14 14"><circle cx="6" cy="6" r="4.2" fill="none" stroke="currentColor" strokeWidth="1.4" /><path d="M9.2 9.2 L12 12" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" /></svg>
<input placeholder="Im Transkript suchen …" value={query} onChange={e => setQuery(e.target.value)} />
</div>
<label className="sort">
Sortieren
<select value={sort} onChange={e => setSort(e.target.value)}>
<option value="empfehlung">KI-Empfehlung</option>
<option value="dauer">Länge</option>
<option value="absender">Absender</option>
</select>
</label>
</div>
</div>
<div className={`list density-${tweaks.density}`}>
<div className="list-head">
<div />
<div>Vorschau</div>
<div>Absender & Transkript</div>
<div>KI-Empfehlung</div>
<div />
</div>
{filtered.map(c => {
const orderIndex = selected.indexOf(c.id) + 1
return (
<ClipRow
key={c.id}
clip={c}
selected={selected.includes(c.id)}
orderIndex={orderIndex}
hovered={hovered === c.id}
onHover={setHovered}
onToggle={toggle}
onDrag={onDrag}
onDragOver={onDragOver}
onDrop={onDrop}
tweaks={tweaks}
/>
)
})}
</div>
<div className="sticky-foot">
<div className="foot-info">
<div className="foot-title">Reihenfolge deines Reels</div>
<div className="foot-chain">
{selected.length === 0 && <span className="foot-empty">Noch keine Clips ausgewählt.</span>}
{selected.map((id, i) => {
const c = clips.find(x => x.id === id)
if (!c) return null
return (
<span key={id} style={{ display: 'contents' }}>
<div className="chain-clip">
<div className="chain-thumb" style={{ background: `linear-gradient(135deg, oklch(0.22 0.04 ${c.hue}), oklch(0.12 0.02 ${c.hue}))` }} />
<div className="chain-meta">
<div className="chain-from">#{i + 1} {c.from}</div>
<div className="chain-dur mono">{c.duration}s</div>
</div>
</div>
{i < selected.length - 1 && <div className="chain-arrow">→</div>}
</span>
)
})}
</div>
</div>
<div className="foot-actions">
<button className="btn-ghost">Als Entwurf speichern</button>
<button className="btn-primary big">
Weiter → Assembly ({counts.totalDur}s)
</button>
</div>
</div>
<header className="topbar">
<span className="brand">FuerDieCuts</span>
<button type="button" className="secondary" onClick={logout}>Abmelden</button>
</header>
<main className="content">
<section className="card empty">
<h1>Neues Video</h1>
<p>Hier kommt als Nächstes der Import des WhatsApp-Exports hin.</p>
</section>
</main>
<TweaksPanel title="Tweaks">
<TweakSection label="Erscheinungsbild">
<TweakRadio label="Theme" value={tweaks.theme} options={[{ value: "light", label: "Hell" }, { value: "dark", label: "Dunkel" }]} onChange={v => setTweaks('theme', v)} />
<TweakRadio label="Akzent" value={tweaks.accent} options={[{ value: "moss", label: "Moss" }, { value: "amber", label: "Amber" }, { value: "ocean", label: "Ocean" }, { value: "plum", label: "Plum" }, { value: "crimson", label: "Crimson" }, { value: "graphite", label: "Graphite" }]} onChange={v => setTweaks('accent', v)} />
<TweakSelect label="Schriftart" value={tweaks.fontFamily} options={[{ value: "inter-tight", label: "Inter Tight" }, { value: "geist", label: "Geist" }, { value: "ibm-plex", label: "IBM Plex Sans" }, { value: "system", label: "System" }]} onChange={v => setTweaks('fontFamily', v)} />
<TweakRadio label="Ecken" value={tweaks.cornerStyle} options={[{ value: "sharp", label: "Scharf" }, { value: "soft", label: "Weich" }, { value: "round", label: "Rund" }]} onChange={v => setTweaks('cornerStyle', v)} />
</TweakSection>
<TweakSection label="Liste & Layout">
<TweakRadio label="Dichte" value={tweaks.density} options={[{ value: "compact", label: "Kompakt" }, { value: "comfortable", label: "Komfortabel" }, { value: "spacious", label: "Luftig" }]} onChange={v => setTweaks('density', v)} />
<TweakRadio label="Thumbnail-Größe" value={tweaks.thumbSize} options={[{ value: "small", label: "S" }, { value: "medium", label: "M" }, { value: "large", label: "L" }]} onChange={v => setTweaks('thumbSize', v)} />
<TweakSlider label="Transkript-Zeilen" value={tweaks.transcriptLines} min={1} max={5} step={1} onChange={v => setTweaks('transcriptLines', v)} />
<TweakToggle label="KI-Begründungen zeigen" value={tweaks.showReasons} onChange={v => setTweaks('showReasons', v)} />
<TweakToggle label="Tags zeigen" value={tweaks.showTags} onChange={v => setTweaks('showTags', v)} />
<TweakToggle label="Dateinamen zeigen" value={tweaks.showFilename} onChange={v => setTweaks('showFilename', v)} />
<TweakToggle label="Meta-Zeile zeigen" value={tweaks.showMetaBar} onChange={v => setTweaks('showMetaBar', v)} />
</TweakSection>
<TweakSection label="Verhalten">
<TweakToggle label="Empfohlene automatisch auswählen" value={tweaks.autoSelectRecommended} onChange={v => setTweaks('autoSelectRecommended', v)} />
<TweakToggle label="Nach Absender gruppieren" value={tweaks.groupBySender} onChange={v => setTweaks('groupBySender', v)} />
<TweakToggle label={'„Überspringen" ausblenden'} value={tweaks.hideSkipped} onChange={v => setTweaks('hideSkipped', v)} />
</TweakSection>
<TweakSection label="KI & Transkription">
<TweakSelect label="Whisper-Modell" value={tweaks.whisperModel} options={[{ value: "tiny", label: "tiny (schnell)" }, { value: "base", label: "base" }, { value: "small", label: "small" }, { value: "medium", label: "medium" }, { value: "large-v3", label: "large-v3 (genau)" }]} onChange={v => setTweaks('whisperModel', v)} />
<TweakSelect label="Sprache" value={tweaks.whisperLang} options={[{ value: "de", label: "Deutsch" }, { value: "en", label: "Englisch" }, { value: "auto", label: "Automatisch" }]} onChange={v => setTweaks('whisperLang', v)} />
<TweakSlider label="KI-Strenge" value={tweaks.aiStrictness} min={1} max={5} step={1} onChange={v => setTweaks('aiStrictness', v)} />
<TweakSlider label="Min. Clip-Länge (s)" value={tweaks.minClipLength} min={1} max={30} step={1} onChange={v => setTweaks('minClipLength', v)} />
<TweakSlider label="Max. Clip-Länge (s)" value={tweaks.maxClipLength} min={10} max={180} step={5} onChange={v => setTweaks('maxClipLength', v)} />
</TweakSection>
<TweakSection label="Reel-Export">
<TweakSlider label="Ziel-Länge (s)" value={tweaks.targetLength} min={30} max={300} step={10} onChange={v => setTweaks('targetLength', v)} />
<TweakRadio label="Seitenverhältnis" value={tweaks.aspectRatio} options={[{ value: "9:16", label: "9:16" }, { value: "1:1", label: "1:1" }, { value: "16:9", label: "16:9" }, { value: "4:5", label: "4:5" }]} onChange={v => setTweaks('aspectRatio', v)} />
<TweakSelect label="Auflösung" value={tweaks.exportResolution} options={[{ value: "720p", label: "720p" }, { value: "1080p", label: "1080p" }, { value: "1440p", label: "1440p" }, { value: "2160p", label: "4K" }]} onChange={v => setTweaks('exportResolution', v)} />
<TweakSelect label="Format" value={tweaks.exportFormat} options={[{ value: "mp4", label: "MP4 (H.264)" }, { value: "mov", label: "MOV (ProRes)" }, { value: "webm", label: "WebM (VP9)" }]} onChange={v => setTweaks('exportFormat', v)} />
<TweakToggle label="Crossfade zwischen Clips" value={tweaks.addCrossfade} onChange={v => setTweaks('addCrossfade', v)} />
{tweaks.addCrossfade && <TweakSlider label="Crossfade (ms)" value={tweaks.crossfadeMs} min={50} max={1500} step={50} onChange={v => setTweaks('crossfadeMs', v)} />}
<TweakToggle label="Untertitel einbrennen" value={tweaks.addCaptions} onChange={v => setTweaks('addCaptions', v)} />
{tweaks.addCaptions && <TweakRadio label="Untertitel-Stil" value={tweaks.captionStyle} options={[{ value: "minimal", label: "Minimal" }, { value: "bold", label: "Bold" }, { value: "karaoke", label: "Karaoke" }]} onChange={v => setTweaks('captionStyle', v)} />}
<TweakSelect label="Musikbett" value={tweaks.musicBed} options={[{ value: "keine", label: "Keine" }, { value: "ambient-01", label: "Ambient · ruhig" }, { value: "upbeat-01", label: "Upbeat · warm" }, { value: "cinematic", label: "Cinematic" }, { value: "eigene", label: "Eigene Datei …" }]} onChange={v => setTweaks('musicBed', v)} />
{tweaks.musicBed !== "keine" && <TweakSlider label="Musik-Lautstärke" value={tweaks.musicVolume} min={0} max={100} step={5} onChange={v => setTweaks('musicVolume', v)} />}
<TweakToggle label="Wasserzeichen" value={tweaks.watermark} onChange={v => setTweaks('watermark', v)} />
</TweakSection>
</TweaksPanel>
</div>
)
}
+52
View File
@@ -0,0 +1,52 @@
import { useState } from 'react'
import { api } from './api.js'
export default function Login({ pinConfigured, onLogin }) {
const [pin, setPin] = useState('')
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
async function submit(event) {
event.preventDefault()
setBusy(true)
setError('')
try {
await api('login', { method: 'POST', body: { pin } })
onLogin()
} catch (err) {
setError(err.message)
setPin('')
} finally {
setBusy(false)
}
}
return (
<main className="login">
<form className="card login-card" onSubmit={submit}>
<h1>FuerDieCuts</h1>
{pinConfigured ? (
<>
<label htmlFor="pin">PIN</label>
<input
id="pin"
type="password"
inputMode="numeric"
autoComplete="current-password"
autoFocus
value={pin}
onChange={(event) => setPin(event.target.value)}
required
/>
<button type="submit" disabled={busy || !pin}>
{busy ? 'Prüfe …' : 'Anmelden'}
</button>
</>
) : (
<p>Es ist keine PIN eingerichtet. Setze <code>APP_PIN</code> in der Umgebung.</p>
)}
<p className="error" role="alert" aria-live="assertive">{error}</p>
</form>
</main>
)
}
-358
View File
@@ -1,358 +0,0 @@
import { useState, useEffect, useRef, useCallback } from 'react'
const __TWEAKS_STYLE = `
.twk-panel{position:fixed;right:16px;bottom:16px;z-index:2147483646;width:280px;
max-height:calc(100vh - 32px);display:flex;flex-direction:column;
background:rgba(250,249,247,.78);color:#29261b;
-webkit-backdrop-filter:blur(24px) saturate(160%);backdrop-filter:blur(24px) saturate(160%);
border:.5px solid rgba(255,255,255,.6);border-radius:14px;
box-shadow:0 1px 0 rgba(255,255,255,.5) inset,0 12px 40px rgba(0,0,0,.18);
font:11.5px/1.4 ui-sans-serif,system-ui,-apple-system,sans-serif;overflow:hidden}
.twk-hd{display:flex;align-items:center;justify-content:space-between;
padding:10px 8px 10px 14px;cursor:move;user-select:none}
.twk-hd b{font-size:12px;font-weight:600;letter-spacing:.01em}
.twk-x{appearance:none;border:0;background:transparent;color:rgba(41,38,27,.55);
width:22px;height:22px;border-radius:6px;cursor:default;font-size:13px;line-height:1}
.twk-x:hover{background:rgba(0,0,0,.06);color:#29261b}
.twk-body{padding:2px 14px 14px;display:flex;flex-direction:column;gap:10px;
overflow-y:auto;overflow-x:hidden;min-height:0;
scrollbar-width:thin;scrollbar-color:rgba(0,0,0,.15) transparent}
.twk-body::-webkit-scrollbar{width:8px}
.twk-body::-webkit-scrollbar-track{background:transparent;margin:2px}
.twk-body::-webkit-scrollbar-thumb{background:rgba(0,0,0,.15);border-radius:4px;
border:2px solid transparent;background-clip:content-box}
.twk-body::-webkit-scrollbar-thumb:hover{background:rgba(0,0,0,.25);
border:2px solid transparent;background-clip:content-box}
.twk-row{display:flex;flex-direction:column;gap:5px}
.twk-row-h{flex-direction:row;align-items:center;justify-content:space-between;gap:10px}
.twk-lbl{display:flex;justify-content:space-between;align-items:baseline;
color:rgba(41,38,27,.72)}
.twk-lbl>span:first-child{font-weight:500}
.twk-val{color:rgba(41,38,27,.5);font-variant-numeric:tabular-nums}
.twk-sect{font-size:10px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;
color:rgba(41,38,27,.45);padding:10px 0 0}
.twk-sect:first-child{padding-top:0}
.twk-field{appearance:none;width:100%;height:26px;padding:0 8px;
border:.5px solid rgba(0,0,0,.1);border-radius:7px;
background:rgba(255,255,255,.6);color:inherit;font:inherit;outline:none}
.twk-field:focus{border-color:rgba(0,0,0,.25);background:rgba(255,255,255,.85)}
select.twk-field{padding-right:22px;
background-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'><path fill='rgba(0,0,0,.5)' d='M0 0h10L5 6z'/></svg>");
background-repeat:no-repeat;background-position:right 8px center}
.twk-slider{appearance:none;-webkit-appearance:none;width:100%;height:4px;margin:6px 0;
border-radius:999px;background:rgba(0,0,0,.12);outline:none}
.twk-slider::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;
width:14px;height:14px;border-radius:50%;background:#fff;
border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default}
.twk-slider::-moz-range-thumb{width:14px;height:14px;border-radius:50%;
background:#fff;border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default}
.twk-seg{position:relative;display:flex;padding:2px;border-radius:8px;
background:rgba(0,0,0,.06);user-select:none}
.twk-seg-thumb{position:absolute;top:2px;bottom:2px;border-radius:6px;
background:rgba(255,255,255,.9);box-shadow:0 1px 2px rgba(0,0,0,.12);
transition:left .15s cubic-bezier(.3,.7,.4,1),width .15s}
.twk-seg.dragging .twk-seg-thumb{transition:none}
.twk-seg button{appearance:none;position:relative;z-index:1;flex:1;border:0;
background:transparent;color:inherit;font:inherit;font-weight:500;height:22px;
border-radius:6px;cursor:default;padding:0}
.twk-toggle{position:relative;width:32px;height:18px;border:0;border-radius:999px;
background:rgba(0,0,0,.15);transition:background .15s;cursor:default;padding:0}
.twk-toggle[data-on="1"]{background:#34c759}
.twk-toggle i{position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;
background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.25);transition:transform .15s}
.twk-toggle[data-on="1"] i{transform:translateX(14px)}
.twk-num{display:flex;align-items:center;height:26px;padding:0 0 0 8px;
border:.5px solid rgba(0,0,0,.1);border-radius:7px;background:rgba(255,255,255,.6)}
.twk-num-lbl{font-weight:500;color:rgba(41,38,27,.6);cursor:ew-resize;
user-select:none;padding-right:8px}
.twk-num input{flex:1;min-width:0;height:100%;border:0;background:transparent;
font:inherit;font-variant-numeric:tabular-nums;text-align:right;padding:0 8px 0 0;
outline:none;color:inherit;-moz-appearance:textfield}
.twk-num input::-webkit-inner-spin-button,.twk-num input::-webkit-outer-spin-button{
-webkit-appearance:none;margin:0}
.twk-num-unit{padding-right:8px;color:rgba(41,38,27,.45)}
.twk-btn{appearance:none;height:26px;padding:0 12px;border:0;border-radius:7px;
background:rgba(0,0,0,.78);color:#fff;font:inherit;font-weight:500;cursor:default}
.twk-btn:hover{background:rgba(0,0,0,.88)}
.twk-btn.secondary{background:rgba(0,0,0,.06);color:inherit}
.twk-btn.secondary:hover{background:rgba(0,0,0,.1)}
.twk-swatch{appearance:none;-webkit-appearance:none;width:56px;height:22px;
border:.5px solid rgba(0,0,0,.1);border-radius:6px;padding:0;cursor:default;
background:transparent;flex-shrink:0}
.twk-swatch::-webkit-color-swatch-wrapper{padding:0}
.twk-swatch::-webkit-color-swatch{border:0;border-radius:5.5px}
.twk-swatch::-moz-color-swatch{border:0;border-radius:5.5px}
`
export function useTweaks(defaults) {
const init = { ...defaults, ...(window.__INITIAL_TWEAKS__ || {}) }
const [values, setValues] = useState(init)
const setTweak = useCallback((key, val) => {
setValues((prev) => ({ ...prev, [key]: val }))
window.parent.postMessage({ type: '__edit_mode_set_keys', edits: { [key]: val } }, '*')
}, [])
return [values, setTweak]
}
export function TweaksPanel({ title = 'Tweaks', children }) {
const [open, setOpen] = useState(false)
const dragRef = useRef(null)
const offsetRef = useRef({ x: 16, y: 16 })
const PAD = 16
const clampToViewport = useCallback(() => {
const panel = dragRef.current
if (!panel) return
const w = panel.offsetWidth, h = panel.offsetHeight
const maxRight = Math.max(PAD, window.innerWidth - w - PAD)
const maxBottom = Math.max(PAD, window.innerHeight - h - PAD)
offsetRef.current = {
x: Math.min(maxRight, Math.max(PAD, offsetRef.current.x)),
y: Math.min(maxBottom, Math.max(PAD, offsetRef.current.y)),
}
panel.style.right = offsetRef.current.x + 'px'
panel.style.bottom = offsetRef.current.y + 'px'
}, [])
useEffect(() => {
if (!open) return
clampToViewport()
if (typeof ResizeObserver === 'undefined') {
window.addEventListener('resize', clampToViewport)
return () => window.removeEventListener('resize', clampToViewport)
}
const ro = new ResizeObserver(clampToViewport)
ro.observe(document.documentElement)
return () => ro.disconnect()
}, [open, clampToViewport])
useEffect(() => {
const onMsg = (e) => {
const t = e?.data?.type
if (t === '__activate_edit_mode') setOpen(true)
else if (t === '__deactivate_edit_mode') setOpen(false)
}
window.addEventListener('message', onMsg)
window.parent.postMessage({ type: '__edit_mode_available' }, '*')
return () => window.removeEventListener('message', onMsg)
}, [])
const dismiss = () => {
setOpen(false)
window.parent.postMessage({ type: '__edit_mode_dismissed' }, '*')
}
const onDragStart = (e) => {
const panel = dragRef.current
if (!panel) return
const r = panel.getBoundingClientRect()
const sx = e.clientX, sy = e.clientY
const startRight = window.innerWidth - r.right
const startBottom = window.innerHeight - r.bottom
const move = (ev) => {
offsetRef.current = {
x: startRight - (ev.clientX - sx),
y: startBottom - (ev.clientY - sy),
}
clampToViewport()
}
const up = () => {
window.removeEventListener('mousemove', move)
window.removeEventListener('mouseup', up)
}
window.addEventListener('mousemove', move)
window.addEventListener('mouseup', up)
}
if (!open) return null
return (
<>
<style>{__TWEAKS_STYLE}</style>
<div ref={dragRef} className="twk-panel"
style={{ right: offsetRef.current.x, bottom: offsetRef.current.y }}>
<div className="twk-hd" onMouseDown={onDragStart}>
<b>{title}</b>
<button className="twk-x" aria-label="Close tweaks"
onMouseDown={(e) => e.stopPropagation()}
onClick={dismiss}>✕</button>
</div>
<div className="twk-body">{children}</div>
</div>
</>
)
}
export function TweakSection({ label, children }) {
return (
<>
<div className="twk-sect">{label}</div>
{children}
</>
)
}
export function TweakRow({ label, value, children, inline = false }) {
return (
<div className={inline ? 'twk-row twk-row-h' : 'twk-row'}>
<div className="twk-lbl">
<span>{label}</span>
{value != null && <span className="twk-val">{value}</span>}
</div>
{children}
</div>
)
}
export function TweakSlider({ label, value, min = 0, max = 100, step = 1, unit = '', onChange }) {
return (
<TweakRow label={label} value={`${value}${unit}`}>
<input type="range" className="twk-slider" min={min} max={max} step={step}
value={value} onChange={(e) => onChange(Number(e.target.value))} />
</TweakRow>
)
}
export function TweakToggle({ label, value, onChange }) {
return (
<div className="twk-row twk-row-h">
<div className="twk-lbl"><span>{label}</span></div>
<button type="button" className="twk-toggle" data-on={value ? '1' : '0'}
role="switch" aria-checked={!!value}
onClick={() => onChange(!value)}><i /></button>
</div>
)
}
export function TweakRadio({ label, value, options, onChange }) {
const trackRef = useRef(null)
const [dragging, setDragging] = useState(false)
const opts = options.map((o) => (typeof o === 'object' ? o : { value: o, label: o }))
const idx = Math.max(0, opts.findIndex((o) => o.value === value))
const n = opts.length
const valueRef = useRef(value)
valueRef.current = value
const segAt = (clientX) => {
const r = trackRef.current.getBoundingClientRect()
const inner = r.width - 4
const i = Math.floor(((clientX - r.left - 2) / inner) * n)
return opts[Math.max(0, Math.min(n - 1, i))].value
}
const onPointerDown = (e) => {
setDragging(true)
const v0 = segAt(e.clientX)
if (v0 !== valueRef.current) onChange(v0)
const move = (ev) => {
if (!trackRef.current) return
const v = segAt(ev.clientX)
if (v !== valueRef.current) onChange(v)
}
const up = () => {
setDragging(false)
window.removeEventListener('pointermove', move)
window.removeEventListener('pointerup', up)
}
window.addEventListener('pointermove', move)
window.addEventListener('pointerup', up)
}
return (
<TweakRow label={label}>
<div ref={trackRef} role="radiogroup" onPointerDown={onPointerDown}
className={dragging ? 'twk-seg dragging' : 'twk-seg'}>
<div className="twk-seg-thumb"
style={{ left: `calc(2px + ${idx} * (100% - 4px) / ${n})`,
width: `calc((100% - 4px) / ${n})` }} />
{opts.map((o) => (
<button key={o.value} type="button" role="radio" aria-checked={o.value === value}>
{o.label}
</button>
))}
</div>
</TweakRow>
)
}
export function TweakSelect({ label, value, options, onChange }) {
return (
<TweakRow label={label}>
<select className="twk-field" value={value} onChange={(e) => onChange(e.target.value)}>
{options.map((o) => {
const v = typeof o === 'object' ? o.value : o
const l = typeof o === 'object' ? o.label : o
return <option key={v} value={v}>{l}</option>
})}
</select>
</TweakRow>
)
}
export function TweakText({ label, value, placeholder, onChange }) {
return (
<TweakRow label={label}>
<input className="twk-field" type="text" value={value} placeholder={placeholder}
onChange={(e) => onChange(e.target.value)} />
</TweakRow>
)
}
export function TweakNumber({ label, value, min, max, step = 1, unit = '', onChange }) {
const clamp = (n) => {
if (min != null && n < min) return min
if (max != null && n > max) return max
return n
}
const startRef = useRef({ x: 0, val: 0 })
const onScrubStart = (e) => {
e.preventDefault()
startRef.current = { x: e.clientX, val: value }
const decimals = (String(step).split('.')[1] || '').length
const move = (ev) => {
const dx = ev.clientX - startRef.current.x
const raw = startRef.current.val + dx * step
const snapped = Math.round(raw / step) * step
onChange(clamp(Number(snapped.toFixed(decimals))))
}
const up = () => {
window.removeEventListener('pointermove', move)
window.removeEventListener('pointerup', up)
}
window.addEventListener('pointermove', move)
window.addEventListener('pointerup', up)
}
return (
<div className="twk-num">
<span className="twk-num-lbl" onPointerDown={onScrubStart}>{label}</span>
<input type="number" value={value} min={min} max={max} step={step}
onChange={(e) => onChange(clamp(Number(e.target.value)))} />
{unit && <span className="twk-num-unit">{unit}</span>}
</div>
)
}
export function TweakColor({ label, value, onChange }) {
return (
<div className="twk-row twk-row-h">
<div className="twk-lbl"><span>{label}</span></div>
<input type="color" className="twk-swatch" value={value}
onChange={(e) => onChange(e.target.value)} />
</div>
)
}
export function TweakButton({ label, onClick, secondary = false }) {
return (
<button type="button" className={secondary ? 'twk-btn secondary' : 'twk-btn'}
onClick={onClick}>{label}</button>
)
}
+16
View File
@@ -0,0 +1,16 @@
// Relative Pfade, damit die App auch hinter dem VCH-Pfad-Proxy funktioniert.
export async function api(path, { method = 'GET', body } = {}) {
const res = await fetch(`api/${path}`, {
method,
headers: body === undefined ? undefined : { 'Content-Type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
credentials: 'same-origin',
})
const data = await res.json().catch(() => ({}))
if (!res.ok) {
const error = new Error(data.error || `Anfrage fehlgeschlagen (${res.status})`)
error.status = res.status
throw error
}
return data
}
+1
View File
@@ -1,6 +1,7 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App.jsx'
import './styles.css'
createRoot(document.getElementById('root')).render(
<StrictMode>
+126 -300
View File
@@ -1,325 +1,151 @@
:root {
--bg: oklch(0.985 0.004 85);
--bg-2: oklch(0.97 0.004 85);
--panel: #ffffff;
--line: oklch(0.92 0.004 85);
--line-2: oklch(0.88 0.004 85);
--ink: oklch(0.18 0.01 80);
--ink-2: oklch(0.38 0.008 80);
--ink-3: oklch(0.58 0.006 80);
--accent: oklch(0.68 0.09 145);
--accent-2: oklch(0.58 0.09 145);
--accent-bg: oklch(0.96 0.027 145);
--rec-good: oklch(0.62 0.11 150);
--rec-mid: oklch(0.70 0.09 85);
--rec-skip: oklch(0.60 0.04 30);
--density: 16px;
--bg: #f4f4f2;
--surface: #ffffff;
--text: #1c1c1a;
--muted: #5e5e58;
--border: #d9d9d4;
--accent: #1f6f4a;
--accent-text: #ffffff;
--error: #b3261e;
--radius: 10px;
--radius-sm: 6px;
--shadow: 0 1px 2px rgba(20,15,10,0.04), 0 4px 20px -8px rgba(20,15,10,0.06);
--font: "Inter Tight", "Inter", -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
font-family: var(--font);
color-scheme: light;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; background: var(--bg); color: var(--ink); font-size: 14px; line-height: 1.45; }
.mono { font-family: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace; font-feature-settings: "tnum"; }
button { font-family: inherit; cursor: pointer; border: none; background: none; color: inherit; }
input, select { font-family: inherit; font-size: inherit; color: inherit; }
.app { display: grid; grid-template-columns: 280px 1fr; min-height: 100vh; }
/* ===== SIDEBAR ===== */
.sidebar {
background: var(--bg-2);
border-right: 1px solid var(--line);
padding: 28px 22px;
display: flex;
flex-direction: column;
gap: 28px;
position: sticky;
top: 0;
height: 100vh;
@media (prefers-color-scheme: dark) {
:root:not([data-theme='light']) {
--bg: #151514;
--surface: #1f1f1d;
--text: #ecece8;
--muted: #a3a39c;
--border: #34342f;
--accent: #4cc38a;
--accent-text: #0d1f16;
--error: #ff8a80;
color-scheme: dark;
}
}
.brand { display: flex; align-items: center; gap: 12px; }
.brand-mark { display: grid; grid-template-columns: repeat(3, 4px); gap: 3px; width: 28px; height: 28px; align-content: center; justify-content: center; background: var(--ink); border-radius: 6px; padding: 6px; }
.brand-mark > div { background: var(--bg-2); height: 4px; border-radius: 1px; }
.brand-mark > div:nth-child(2) { background: var(--accent); }
.brand-name { font-weight: 600; font-size: 15px; letter-spacing: -0.01em; }
.brand-sub { font-size: 11px; color: var(--ink-3); letter-spacing: 0.04em; text-transform: uppercase; }
.project {
border: 1px solid var(--line);
:root[data-theme='dark'] {
--bg: #151514;
--surface: #1f1f1d;
--text: #ecece8;
--muted: #a3a39c;
--border: #34342f;
--accent: #4cc38a;
--accent-text: #0d1f16;
--error: #ff8a80;
color-scheme: dark;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: 'Inter Tight', system-ui, sans-serif;
font-size: 16px;
line-height: 1.5;
}
h1 {
margin: 0 0 0.5rem;
font-size: 1.4rem;
}
button {
font: inherit;
padding: 0.6rem 1.1rem;
border: 1px solid var(--accent);
border-radius: var(--radius);
padding: 14px;
background: var(--panel);
background: var(--accent);
color: var(--accent-text);
cursor: pointer;
}
.project-label { font-size: 10px; color: var(--ink-3); letter-spacing: 0.08em; text-transform: uppercase; }
.project-name { font-weight: 600; font-size: 14px; margin-top: 4px; letter-spacing: -0.01em; }
.project-meta { font-size: 12px; color: var(--ink-3); margin-top: 4px; }
.steps { display: flex; flex-direction: column; gap: 2px; }
.step {
display: flex;
gap: 12px;
padding: 10px 12px;
border-radius: 8px;
text-decoration: none;
color: var(--ink-2);
position: relative;
transition: background 0.12s;
button.secondary {
background: transparent;
color: var(--text);
border-color: var(--border);
}
button:disabled {
opacity: 0.5;
cursor: default;
}
.step:hover { background: rgba(0,0,0,0.025); }
.step.is-current { background: var(--panel); box-shadow: 0 0 0 1px var(--line); }
.step.is-current .step-label { color: var(--ink); font-weight: 600; }
.step.is-current .step-num { color: var(--accent-2); background: var(--accent-bg); }
.step.is-done .step-num { background: var(--ink); color: var(--bg); border-color: var(--ink); }
.step-num {
width: 26px; height: 26px; border-radius: 6px; border: 1px solid var(--line);
display: grid; place-items: center; font-family: "JetBrains Mono", monospace; font-size: 11px; color: var(--ink-3);
flex-shrink: 0;
}
.step-body { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
.step-label { font-size: 13px; }
.step-sub { font-size: 11px; color: var(--ink-3); }
.side-foot { margin-top: auto; font-size: 12px; color: var(--ink-3); display: flex; flex-direction: column; gap: 8px; }
.foot-row { display: flex; justify-content: space-between; }
.foot-bar { height: 3px; background: var(--line); border-radius: 2px; overflow: hidden; }
.foot-bar > div { height: 100%; background: var(--ink-2); }
.foot-hint { font-size: 11px; }
/* ===== MAIN ===== */
.main { padding: 36px 44px 140px; max-width: 1400px; }
.top { display: flex; justify-content: space-between; align-items: flex-end; gap: 32px; margin-bottom: 28px; }
.breadcrumb { font-size: 11px; color: var(--ink-3); letter-spacing: 0.04em; margin-bottom: 8px; }
.breadcrumb span { color: var(--ink); }
.top h1 { margin: 0; font-size: 30px; font-weight: 600; letter-spacing: -0.015em; line-height: 1.2; }
.lede { margin: 12px 0 0; color: var(--ink-2); font-size: 15px; line-height: 1.6; max-width: 60ch; letter-spacing: 0; text-wrap: pretty; }
.top-actions { display: flex; gap: 10px; align-items: center; flex-shrink: 0; }
.btn-ghost {
padding: 9px 14px; border-radius: 8px; font-size: 13px; color: var(--ink-2);
border: 1px solid var(--line); background: var(--panel); transition: all 0.12s;
}
.btn-ghost:hover { border-color: var(--line-2); color: var(--ink); }
.btn-primary {
padding: 9px 16px; border-radius: 8px; font-size: 13px; font-weight: 500;
background: var(--ink); color: var(--bg); display: inline-flex; align-items: center; gap: 8px;
transition: background 0.12s;
}
.btn-primary:hover { background: oklch(0.10 0.01 80); }
.btn-primary.big { padding: 12px 20px; font-size: 14px; }
/* ===== STRIP ===== */
.strip {
display: grid; grid-template-columns: 1fr 1fr 1fr 1.2fr;
gap: 0;
background: var(--panel); border: 1px solid var(--line);
border-radius: var(--radius); padding: 18px 22px;
margin-bottom: 24px;
align-items: center;
}
.strip-sep { width: 1px; background: var(--line); height: 42px; justify-self: center; display: none; }
.strip-group { display: flex; flex-direction: column; gap: 6px; padding: 0 22px; border-left: 1px solid var(--line); }
.strip-group:first-child { border-left: none; padding-left: 0; }
.strip-group.right { align-items: flex-start; }
.strip-label { font-size: 10px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--ink-3); }
.strip-value { display: flex; align-items: baseline; gap: 6px; }
.strip-value .big { font-size: 26px; font-weight: 600; letter-spacing: -0.02em; font-family: "JetBrains Mono", monospace; font-feature-settings: "tnum"; }
.strip-value .big.over { color: oklch(0.55 0.14 30); }
.strip-value .unit { font-size: 12px; color: var(--ink-3); }
.strip-bar { height: 3px; background: var(--line); border-radius: 2px; overflow: hidden; margin-top: 2px; max-width: 160px; }
.strip-bar > div { height: 100%; background: var(--accent); transition: width 0.3s; }
.strip-bar > div.over { background: oklch(0.60 0.14 30); }
.rec-legend { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: var(--ink-2); }
.rec-legend span { display: inline-flex; align-items: center; gap: 7px; }
.rec-legend i { width: 6px; height: 6px; border-radius: 50%; display: inline-block; }
.rec-legend i.rec-good { background: var(--rec-good); }
.rec-legend i.rec-mid { background: var(--rec-mid); }
.rec-legend i.rec-skip { background: var(--rec-skip); }
/* ===== TOOLBAR ===== */
.toolbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; gap: 16px; flex-wrap: wrap; }
.filters { display: flex; gap: 6px; }
.chip {
padding: 7px 12px; border-radius: 999px; font-size: 12.5px;
color: var(--ink-2); border: 1px solid var(--line); background: var(--panel);
transition: all 0.12s;
}
.chip:hover { border-color: var(--line-2); }
.chip.is-on { background: var(--ink); color: var(--bg); border-color: var(--ink); }
.tools { display: flex; gap: 10px; align-items: center; }
.search {
display: flex; align-items: center; gap: 8px;
padding: 7px 12px; border: 1px solid var(--line); border-radius: 8px; background: var(--panel);
color: var(--ink-3);
}
.search:focus-within { border-color: var(--ink-2); color: var(--ink-2); }
.search input { border: none; outline: none; background: none; width: 260px; font-size: 13px; color: var(--ink); }
.search input::placeholder { color: var(--ink-3); }
.sort { display: flex; align-items: center; gap: 8px; font-size: 12.5px; color: var(--ink-3); }
.sort select {
padding: 7px 10px; border: 1px solid var(--line); border-radius: 8px; background: var(--panel);
font-size: 13px; color: var(--ink);
input {
font: inherit;
padding: 0.6rem 0.8rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg);
color: var(--text);
}
/* ===== LIST ===== */
.list {
background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius);
overflow: hidden;
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.list-head {
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.5rem;
}
.error {
color: var(--error);
min-height: 1.5em;
margin: 0;
}
.login {
min-height: 100vh;
display: grid;
grid-template-columns: 56px var(--thumb-col, 180px) 1fr 210px 24px;
gap: 20px;
padding: 12px 18px;
border-bottom: 1px solid var(--line);
background: var(--bg-2);
font-size: 10px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--ink-3);
place-items: center;
padding: 16px;
}
.row {
display: grid;
grid-template-columns: 56px var(--thumb-col, 180px) 1fr 210px 24px;
gap: 20px;
padding: var(--density) 18px;
border-bottom: 1px solid var(--line);
.login-card {
width: 100%;
max-width: 320px;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.login-card input {
font-size: 1.4rem;
letter-spacing: 0.3em;
text-align: center;
}
.topbar {
display: flex;
align-items: center;
transition: background 0.1s;
cursor: grab;
}
.row:last-child { border-bottom: none; }
.row:hover, .row.is-hovered { background: var(--bg-2); }
.row.is-selected { background: color-mix(in oklch, var(--accent-bg) 40%, var(--panel)); }
.row.is-selected:hover { background: color-mix(in oklch, var(--accent-bg) 60%, var(--panel)); }
.row.is-pending { opacity: 0.75; cursor: default; }
.row-check { display: flex; flex-direction: column; align-items: center; gap: 6px; }
.check {
width: 20px; height: 20px; border-radius: 5px;
border: 1.5px solid var(--line-2); background: var(--panel);
display: grid; place-items: center;
transition: all 0.12s;
color: var(--bg);
}
.check:hover:not(:disabled) { border-color: var(--ink-2); }
.check:disabled { opacity: 0.35; cursor: not-allowed; }
.check.is-on { background: var(--accent-2); border-color: var(--accent-2); }
.order-pill {
font-size: 10px; padding: 2px 6px; border-radius: 4px;
background: var(--ink); color: var(--bg);
letter-spacing: 0.02em;
justify-content: space-between;
padding: 0.75rem 16px;
border-bottom: 1px solid var(--border);
background: var(--surface);
}
/* THUMB */
.thumb {
width: 180px; height: 108px; border-radius: var(--radius-sm);
position: relative; overflow: hidden;
box-shadow: 0 1px 2px rgba(0,0,0,0.15), inset 0 0 0 1px rgba(255,255,255,0.04);
}
.thumb-stripes { position: absolute; inset: 0; width: 100%; height: 100%; }
.thumb-play {
position: absolute; inset: 0; display: grid; place-items: center;
background: radial-gradient(circle at center, rgba(0,0,0,0.25), transparent 60%);
}
.thumb-dur {
position: absolute; bottom: 6px; right: 6px;
font-family: "JetBrains Mono", monospace; font-size: 10.5px;
color: #fff; background: rgba(0,0,0,0.6); padding: 2px 6px; border-radius: 3px;
letter-spacing: 0.02em;
}
.thumb-badge {
position: absolute; top: 6px; left: 6px;
font-family: "JetBrains Mono", monospace; font-size: 10.5px;
background: var(--accent); color: var(--ink); padding: 2px 6px; border-radius: 3px;
font-weight: 600;
.brand {
font-weight: 700;
}
/* BODY */
.row-body { min-width: 0; display: flex; flex-direction: column; gap: 6px; }
.row-head { display: flex; flex-direction: column; gap: 2px; }
.row-title { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; }
.row-title .from { font-weight: 600; font-size: 14px; letter-spacing: -0.005em; }
.row-title .sep { color: var(--ink-3); }
.row-title .filename { font-size: 11.5px; color: var(--ink-3); }
.row-meta { font-size: 11.5px; color: var(--ink-3); display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.dotsep { opacity: 0.5; }
.status { display: inline-flex; align-items: center; gap: 5px; }
.status-dot { width: 5px; height: 5px; border-radius: 50%; background: currentColor; display: inline-block; }
.status.s-done { color: var(--rec-good); }
.status.s-transcribing { color: var(--accent-2); }
.status.s-queued { color: var(--ink-3); }
.transcript {
font-size: 13px; color: var(--ink-2);
line-height: 1.5;
max-width: 62ch;
font-style: italic;
position: relative;
padding-left: 10px;
border-left: 2px solid var(--line-2);
.content {
max-width: 960px;
margin: 0 auto;
padding: 1.5rem 16px;
}
.transcript .quote { opacity: 0.4; font-style: normal; }
.transcribing { display: flex; flex-direction: column; gap: 6px; max-width: 360px; }
.transcribe-bar { height: 3px; background: var(--line); border-radius: 2px; overflow: hidden; }
.transcribe-bar > div { height: 100%; background: var(--accent); animation: pulse 2s ease-in-out infinite alternate; }
@keyframes pulse { from { opacity: 0.7; } to { opacity: 1; } }
.transcribe-hint { font-size: 11px; color: var(--ink-3); }
.tags { display: flex; gap: 5px; margin-top: 2px; }
.tag { font-size: 10.5px; padding: 2px 7px; border-radius: 999px; background: var(--bg-2); color: var(--ink-2); border: 1px solid var(--line); }
/* REC */
.row-rec { display: flex; flex-direction: column; gap: 6px; }
.rec { padding: 10px 12px; border-radius: 8px; border: 1px solid var(--line); background: var(--bg-2); }
.rec-head { display: flex; align-items: center; gap: 8px; }
.rec-dot { width: 7px; height: 7px; border-radius: 50%; }
.rec-label { font-size: 12px; font-weight: 600; letter-spacing: -0.005em; }
.rec-reason { font-size: 11.5px; color: var(--ink-2); margin-top: 6px; line-height: 1.4; }
.rec.rec-good .rec-dot { background: var(--rec-good); }
.rec.rec-good { background: color-mix(in oklch, var(--rec-good) 8%, var(--panel)); border-color: color-mix(in oklch, var(--rec-good) 25%, var(--line)); }
.rec.rec-good .rec-label { color: oklch(0.45 0.10 150); }
.rec.rec-mid .rec-dot { background: var(--rec-mid); }
.rec.rec-mid .rec-label { color: oklch(0.48 0.09 85); }
.rec.rec-skip .rec-dot { background: var(--rec-skip); }
.rec.rec-skip .rec-label { color: var(--ink-3); }
.rec.rec-skip { opacity: 0.75; }
.row-grip { color: var(--ink-3); opacity: 0; transition: opacity 0.12s; cursor: grab; }
.row-grip svg { fill: currentColor; }
.row:hover .row-grip { opacity: 0.5; }
/* ===== STICKY FOOT ===== */
.sticky-foot {
position: fixed; bottom: 0; left: 280px; right: 0;
background: color-mix(in oklch, var(--panel) 90%, transparent);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
border-top: 1px solid var(--line);
padding: 16px 44px;
display: flex; justify-content: space-between; align-items: center; gap: 24px;
z-index: 10;
.empty p {
color: var(--muted);
margin: 0;
}
.foot-info { display: flex; flex-direction: column; gap: 8px; flex: 1; min-width: 0; }
.foot-title { font-size: 10px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--ink-3); }
.foot-chain { display: flex; gap: 10px; align-items: center; overflow-x: auto; padding-bottom: 2px; }
.foot-chain::-webkit-scrollbar { height: 4px; }
.foot-chain::-webkit-scrollbar-thumb { background: var(--line-2); border-radius: 2px; }
.foot-empty { font-size: 12.5px; color: var(--ink-3); }
.chain-clip {
display: flex; align-items: center; gap: 8px; padding: 4px 10px 4px 4px;
background: var(--bg-2); border: 1px solid var(--line); border-radius: 6px;
flex-shrink: 0;
}
.chain-thumb { width: 28px; height: 20px; border-radius: 3px; flex-shrink: 0; }
.chain-meta { display: flex; flex-direction: column; gap: 0; }
.chain-from { font-size: 11.5px; font-weight: 500; letter-spacing: -0.005em; }
.chain-dur { font-size: 10px; color: var(--ink-3); }
.chain-arrow { color: var(--ink-3); font-size: 12px; flex-shrink: 0; }
.foot-actions { display: flex; gap: 10px; flex-shrink: 0; }
+52
View File
@@ -0,0 +1,52 @@
import assert from 'node:assert/strict';
import { after, before, test } from 'node:test';
import { createApp } from '../server/app.js';
let server;
let baseUrl;
before(async () => {
const app = createApp({ config: { pin: '4711' }, secret: 'testgeheimnis' });
server = app.listen(0);
await new Promise((resolve) => server.once('listening', resolve));
baseUrl = `http://127.0.0.1:${server.address().port}`;
});
after(() => server.close());
const login = (pin) =>
fetch(`${baseUrl}/api/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pin }),
});
test('Healthcheck antwortet ohne Anmeldung', async () => {
const res = await fetch(`${baseUrl}/healthz`);
assert.equal(res.status, 200);
});
test('Geschützte Routen verlangen eine Anmeldung', async () => {
const res = await fetch(`${baseUrl}/api/irgendwas`);
assert.equal(res.status, 401);
});
test('Falsche PIN wird abgelehnt', async () => {
const res = await login('0000');
assert.equal(res.status, 401);
assert.equal(res.headers.get('set-cookie'), null);
});
test('Richtige PIN liefert ein Sitzungs-Cookie, das gilt', async () => {
const res = await login('4711');
assert.equal(res.status, 200);
const cookie = res.headers.get('set-cookie').split(';')[0];
assert.match(cookie, /^fdc_session=/);
assert.match(res.headers.get('set-cookie'), /HttpOnly/);
const session = await fetch(`${baseUrl}/api/session`, { headers: { cookie } });
assert.deepEqual(await session.json(), { authenticated: true, pinConfigured: true });
const protectedRes = await fetch(`${baseUrl}/api/irgendwas`, { headers: { cookie } });
assert.equal(protectedRes.status, 404);
});
+44
View File
@@ -0,0 +1,44 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { createLoginLimiter, createToken, pinMatches, verifyToken } from '../server/auth.js';
test('Token ist mit Geheimnis und PIN gültig', () => {
const token = createToken('geheim', '1234');
assert.equal(verifyToken('geheim', '1234', token), true);
});
test('Token wird nach PIN-Wechsel ungültig', () => {
const token = createToken('geheim', '1234');
assert.equal(verifyToken('geheim', '9999', token), false);
});
test('Token mit fremdem Geheimnis oder manipuliertem Ablauf ist ungültig', () => {
const token = createToken('geheim', '1234');
assert.equal(verifyToken('anderes', '1234', token), false);
const [, signature] = token.split('.');
assert.equal(verifyToken('geheim', '1234', `${Date.now() + 1e12}.${signature}`), false);
});
test('Abgelaufenes Token ist ungültig', () => {
const token = createToken('geheim', '1234', 0);
assert.equal(verifyToken('geheim', '1234', token), false);
});
test('Ohne gesetzte PIN gilt kein Token und keine Eingabe', () => {
assert.equal(verifyToken('geheim', '', createToken('geheim', '')), false);
assert.equal(pinMatches('', ''), false);
});
test('PIN-Vergleich', () => {
assert.equal(pinMatches('1234', '1234'), true);
assert.equal(pinMatches('1234', '1235'), false);
assert.equal(pinMatches('1234', undefined), false);
});
test('Sperre nach zu vielen Fehlversuchen läuft ab', () => {
const limiter = createLoginLimiter({ maxFailures: 2, windowMs: 1000 });
limiter.recordFailure(0);
limiter.recordFailure(10);
assert.equal(limiter.isBlocked(500), true);
assert.equal(limiter.isBlocked(1500), false);
});