Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f0442a617c | |||
| 4fb34cdab8 | |||
| 21a0a68972 | |||
| 32f3b91779 | |||
| cd9081823d | |||
| e8b3fde652 | |||
| a593dea6c5 | |||
| a651025422 | |||
| ed22a76fda | |||
| ae91f4bfd5 | |||
| d54d322d6a | |||
| 3a061aa612 | |||
| efcb889c96 | |||
| 70f3904d0e | |||
| 32f06297a6 | |||
| 5f0c33e44b | |||
| af34cb9cc8 | |||
| f95a3b58b7 | |||
| e6b325eb53 | |||
| 2588a54eb7 | |||
| 295f0ce91e | |||
| 5cfafb456c | |||
| 36f3c8da22 | |||
| 3a098d708c | |||
| 79454c0b92 | |||
| 5fba397efa | |||
| 3bfeadbaa0 | |||
| 7608bc62da | |||
| 5105c7d529 | |||
| 063903c60a | |||
| 5b654b1436 | |||
| 78737936fb | |||
| e59d26fadd | |||
| 34aa45b292 | |||
| a69e2452e1 | |||
| 3e2e09a38d | |||
| f5cbcdf4d1 | |||
| b705d77240 | |||
| 044eb7f1ae |
+104
@@ -0,0 +1,104 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to Klarbild are documented here. Newest first.
|
||||
|
||||
## 2026-08-18 — Print module: exact sizes, sheets & crop marks (no AI)
|
||||
|
||||
### Added
|
||||
- **Print module (`/druck`)** — a lab-style, completely AI-free path: crop, scale and place
|
||||
images at exact physical sizes. No model call, no cost, no queue.
|
||||
- **Interactive crop editor** — pan and zoom on a fixed target aspect ratio (rule-of-thirds
|
||||
overlay); the crop is stored relative (0..1) so it survives any source resolution.
|
||||
- **Size presets & free input** — biometric passport 35 × 45 mm, 2 × 3 / 3 × 4 / 4 × 5 / 4,5 × 6 cm,
|
||||
9 × 13 … 50 × 70 cm, squares, DIN. Free input understands `12x15` (cm), `35x45mm`, `5`
|
||||
(= 5 × 5 cm) and `4:3/15` (ratio + long edge).
|
||||
- **Sheet layout** — paper presets DIN A6–A2 incl. **A3+ (329 × 483 mm)**, photo-paper cuts
|
||||
(9 × 13 … 20 × 30), US Letter/Legal, plus any custom paper size. Portrait/landscape,
|
||||
printer margin, centering, per-image copy count.
|
||||
- **Automatic nesting** — identical sizes produce an exact grid; mixed sizes go through a
|
||||
MaxRects packer that fixes one orientation per format and tries every combination,
|
||||
so eight passport photos land in the free space next to the 13 × 18.
|
||||
- **Crop marks** — `corner` (fine 0.25 pt marks outside the trim box, Photoshop/InDesign
|
||||
convention, 4 mm long / 3 mm offset by default) or `grid` (continuous guides across the
|
||||
sheet that never cross another image). Gap between images adapts to the chosen marks.
|
||||
- **Bleed** (0–10 mm) — the image extends past the trim box, edge pixels are copied rather
|
||||
than stretched; the gap is always ≥ 2 × bleed so neighbouring bleeds cannot overlap.
|
||||
- **PDF output at 1:1** — the PDF page is exactly the sheet size (pdf-lib), with a footer
|
||||
stating the sizes, dpi and the "print at 100 %, not fit-to-page" reminder.
|
||||
- **Single export** — one image at an exact size as PNG/JPG with dpi metadata, plus a
|
||||
resolution warning when the source cannot hold the requested dpi.
|
||||
- **Sheet templates** — save the whole sheet setup (paper, marks, formats, counts) as a
|
||||
reusable preset (`print_presets`, migration `013`).
|
||||
- **All three entrances** — `/druck` in the web app, `📐 Druckbogen` in Telegram (fully
|
||||
button-driven, returns an A4 PDF), and the MCP tools `exact_size` / `print_sheet`.
|
||||
- **API** — `POST /api/print/single`, `GET /api/print/single?size=…` (mm → px calculator),
|
||||
`POST /api/print/sheet`, `GET|POST|DELETE /api/print/presets`. Documented in `/llms.txt`.
|
||||
- **Seeded sheet templates** — "Kita-Satz (A4)", "Schulsatz klein (A4)", "Passbildbogen 35×45 (A4)"
|
||||
and "2 × 10×7,5 auf Fotopapier 10×15". Loading a multi-format template with a single image
|
||||
clones that image into every format, so one click produces the whole set.
|
||||
- **Margin profiles** — borderless (0 mm), standard (5 mm) and safe (10 mm) as one-click buttons.
|
||||
- **Deliver the sheet** — the finished PDF can additionally be pushed to Picdrop, the NAS or any
|
||||
extra delivery target (`deliver` in the sheet request).
|
||||
- **Tests** — `tests/printlayout.test.ts` (`npm test`) covers size parsing, exact mm → px,
|
||||
non-overlap, margins, bleed spacing, mark geometry and multi-sheet paging.
|
||||
|
||||
## 2026-07-23 — Compose, generate & second backup
|
||||
|
||||
### Added
|
||||
- **Compose mode** — combine multiple images + a text description into one new image.
|
||||
- **Generate mode** — create images from text only, no source needed.
|
||||
- **The Frame "extend"** — outpaint more scene around the motif instead of only cropping.
|
||||
- **Reuse** — take a finished result back into the studio as a new source to edit further.
|
||||
- **Alternatives** — generate additional versions of the same image, grouped with a switcher.
|
||||
- **Folders** — filter, rename and delete in the library; per-folder delivery gallery mapping.
|
||||
- **Bulk actions** — download selected results as a ZIP, move several into a folder at once.
|
||||
- **Thumbnails** — low-res previews for the library grid.
|
||||
- **Models** — quality tiers incl. open-source (FLUX.2); the used model is shown per image.
|
||||
- **Color tags** — Picdrop-style flags (red / orange / green / final) per image, filterable, and
|
||||
written into the companion `.md` metadata.
|
||||
- **Tap-to-save** — "Laden" opens the image full-bleed so on iPhone/iPad a long-press offers
|
||||
"Save to Photos" (plus a classic file download).
|
||||
- **Access control & privacy** — library visibility (own-only vs shared), optional anonymous
|
||||
generations (creator hidden from non-admins), admins always see all.
|
||||
- **Private sessions** — generate-and-forget: no library entry, no delivery/backup, no stored
|
||||
prompt, sources purged, result auto-deleted shortly after. Enabled globally by admin or forced
|
||||
per user.
|
||||
- **NSFW gate** — models flagged NSFW are only selectable/visible when the admin enables it.
|
||||
Operator responsibility; content involving minors is never permitted.
|
||||
- **Single-image transform** — compose now accepts one image, so a single photo can be
|
||||
transformed by prompt (e.g. "make it an oil painting") and put onto a format.
|
||||
- **Multiple delivery targets** — besides the default Picdrop, add any number of FTP/SFTP
|
||||
destinations; selectable in the studio ("Wohin?", incl. NAS); a preset or folder can be
|
||||
hard-wired to a specific target + gallery.
|
||||
- **Backup to any target** — mark any target as a backup destination (like the NAS); one-click
|
||||
"back up all images" mirrors to all backup destinations.
|
||||
- **Metadata sidecar** — optionally ship a companion `.md` (prompt, model, format …) next to
|
||||
each image on Picdrop/NAS/FTP.
|
||||
- **Prompt view** — every generated image shows its prompt in the library (with copy).
|
||||
- **Preset manager** — save current settings as a preset, load and delete presets.
|
||||
- **API token + MCP server** — programmatic access so an assistant can push images from a
|
||||
local folder or iMessage into Klarbild (`mcp/klarbild-mcp.mjs`).
|
||||
- **The Frame** preset now delivers to its own gallery ("TheFrame-Backgrounds").
|
||||
- **Second backup** — mirror every result over SFTP/FTPS to a NAS (works over Tailscale too),
|
||||
with a "NAS ✓" badge and a "mirror all existing images" action.
|
||||
- **Storage management** — thumbnails, delete sources after processing, auto-retention.
|
||||
- **Telegram** — fully button-driven (persistent keyboard + command menu): edit, compose,
|
||||
"new image", reuse — no slash commands required.
|
||||
- **Reset** — wipe library, jobs and folders cleanly (keeps users, settings, models, recipes),
|
||||
guarded by a typed confirmation.
|
||||
- **Help page**, in-app **changelog**, and a NAS **reachability diagnostic**.
|
||||
|
||||
### Fixed / hardened
|
||||
- Real per-image cost cap (monthly budget) enforced by the worker.
|
||||
- Idempotent job completion (no duplicate delivery/notification).
|
||||
- Before/after comparison works for composed images; hidden for text-only results.
|
||||
|
||||
## 2026-07-22 — Foundation
|
||||
|
||||
### Added
|
||||
- Astro 5 + React islands UI (mobile-first), PostgreSQL, fs/S3 storage abstraction.
|
||||
- pg-boss job queue with in-process worker; sharp pipeline (exact cm/dpi print sizes,
|
||||
sticker contour).
|
||||
- OpenRouter image gateway; admin-managed models.
|
||||
- Picdrop delivery over SFTP/FTPS; recipes; library with before/after.
|
||||
- Telegram bot (grammY webhook); argon2 auth; AES-256-GCM secret encryption.
|
||||
@@ -8,7 +8,8 @@ für den Cricut freistellen, und automatisch nach Picdrop ausliefern.
|
||||
|
||||
## Stack
|
||||
Astro 5 (SSR, Node standalone) + React-Inseln · Postgres · S3 (MinIO) · pg-boss ·
|
||||
sharp · grammY (Telegram) · OKLCH-Tokens, kein Tailwind. Deploy: Gitea → Coolify → Hetzner.
|
||||
sharp · pdf-lib (Druckbogen) · grammY (Telegram) · OKLCH-Tokens, kein Tailwind.
|
||||
Deploy: Gitea → Coolify → Hetzner.
|
||||
|
||||
## Entwicklung
|
||||
```bash
|
||||
@@ -19,6 +20,54 @@ npm run dev
|
||||
Migrationen und Seeds laufen beim Serverstart automatisch. Erststart-Passwörter über
|
||||
`SEED_TILL_PASSWORD` / `SEED_LEA_PASSWORD`.
|
||||
|
||||
## Funktionen (Kurzüberblick)
|
||||
- **Modi:** Bereinigen (Screenshot säubern/freistellen/formatieren), Umwandeln (ein Bild + Text,
|
||||
z. B. Foto → Ölgemälde), Kombinieren (mehrere Bilder + Text), Neu erzeugen (nur Text).
|
||||
- **Formate:** feste cm-/DIN-/Screen-Formate **und frei definierbare** — Schlüssel `WxH` (z. B.
|
||||
`25x35`) oder `sticker<N>` (`sticker5` = 5×5 cm). Parsing in `src/lib/format.ts`, keine Migration nötig.
|
||||
- **Dateiformat:** PNG (verlustfrei, Transparenz) oder JPG (klein, Picdrop-freundlich). Global über
|
||||
`settings.output_ext`, pro Rezept via `recipes.output_ext`, pro Umwandlung im Studio. JPG nur ohne Alpha.
|
||||
- **Auslieferung:** Picdrop (SFTP/FTPS) + beliebige Zusatzziele; NAS/Backup-Ziele; optionaler
|
||||
`.md`-Metadaten-Beileger **pro Ziel** schaltbar. Nicht-transparente Ergebnisse gehen als JPG an Picdrop.
|
||||
- **Bibliothek:** Ordner, Farbmarkierungen, Prompt-Anzeige, Vorher/Nachher, Vollbild + „In Fotos sichern".
|
||||
- **Rechte/Datenschutz:** Sichtbarkeit (eigene/alle), anonyme Generierungen, private Sessions, NSFW-Gate.
|
||||
- **Telegram-Bot** (grammY-Webhook) als vollwertiger Website-Ersatz. **API-Token** (`Bearer`) für `/api/*`.
|
||||
- **Druck (`/druck`, ohne KI):** Bilder exakt auf physische Maße bringen (interaktiver Zuschnitt mit
|
||||
Zoom/Verschieben) und mehrere davon in 100-%-Größe mit **Schnittmarken** auf einen Bogen setzen —
|
||||
Passbildsatz, Kita-Satz, Sticker. Papier DIN A6–A2 inkl. **A3+**, Fotopapier 9×13–20×30, Letter/Legal
|
||||
oder freies Maß. Marken: Eckmarken (0,25 pt, außerhalb des Endformats) oder durchgehende Linien.
|
||||
Beschnittzugabe 0–10 mm. Ausgabe: PDF in 1:1 (`pdf-lib`) bzw. Einzelbild mit dpi-Metadaten.
|
||||
Layout: gleiche Größen → exaktes Raster, gemischte Größen → MaxRects-Packer.
|
||||
Kern: `src/lib/paper.ts` (Formate), `src/lib/printlayout.ts` (reine Mathematik, getestet),
|
||||
`src/lib/printrender.ts` (sharp + pdf-lib). Auch per Telegram („📐 Druckbogen") und MCP
|
||||
(`exact_size`, `print_sheet`).
|
||||
- **Admin:** Key/Picdrop/Modelle/Nutzer/Kostendeckel/Presets/Speicherverwaltung, Picdrop-Diagnose.
|
||||
|
||||
## Datenmodell / Migrationen
|
||||
Nummerierte SQL in `migrations/`, beim Start automatisch (kein ORM-Push). Wichtige Spalten:
|
||||
`items.has_alpha`, `jobs.status` (inkl. `failed`), `jobs.finished_at`,
|
||||
`settings.output_ext` / `recipes.output_ext`, `settings.*_metadata_sidecar`, `delivery_targets.*`,
|
||||
`print_presets` (Bogen-Vorlagen, `013`).
|
||||
|
||||
## Tests
|
||||
`npm test` — Node-Testrunner über `tsx`. Deckt die Druck-Layoutmathematik ab (Maß-Parsing,
|
||||
mm → px exakt, Überlappungsfreiheit, Ränder, Beschnittzugabe, Markengeometrie, Mehrseitigkeit).
|
||||
|
||||
## Stolpersteine & Lösungen (Learnings)
|
||||
- **„Bild bei Picdrop ✓, aber nicht in der Galerie":** Fehldiagnose vermeiden — die Dateien lagen die ganze
|
||||
Zeit **korrekt** per SFTP in `/POSTER LEA` (per Admin → Picdrop → „Ordner anzeigen" verifizierbar). Der
|
||||
eigentliche Grund war die **Sortierung in der Picdrop-Weboberfläche**, nicht das Format. Unabhängig davon
|
||||
liefern wir nicht-transparente Ergebnisse jetzt als JPG aus (`src/lib/delivery.ts`, `deliverableBuffer`):
|
||||
~25 MB PNG → ~2 MB JPG, schneller und handlicher — plus wählbares Ausgabeformat (`output_ext`).
|
||||
- **„Unbekanntes Format: sticker5":** Seed legte ein Rezept mit Formatschlüssel `sticker5` an, den der
|
||||
Resolver nicht kannte → jeder Lauf schlug fehl. Lösung: generisches Parsen von `sticker<N>`/`WxH`.
|
||||
- **Auftrag trotz Fehler grün:** `jobs.status` kannte kein `failed`; alle Positionen fehlgeschlagen wurde
|
||||
trotzdem `done`. Lösung: Status `failed`, wenn `done=0 && failed>0` (`src/worker.ts` `maybeComplete`).
|
||||
- **Doppelte Datums-/UUID-Namen:** `\b`-Wortgrenzen greifen nicht an Unterstrichen. Lösung: `\b` entfernt,
|
||||
Datum/UUID/Hex global ersetzt (`src/lib/pipeline.ts` `cleanMotif`).
|
||||
- **Content-Type bei gemischten PNG/JPG-Ergebnissen:** `file.ts` snifft jetzt die Magic Bytes.
|
||||
- **`.tmp-`-Reste auf Picdrop:** früher Temp-Rename-Bug; `cleanupTmp` + Admin-Knopf „Reste aufräumen".
|
||||
|
||||
## Status
|
||||
Im Aufbau. Reihenfolge und Nachweise siehe `CLAUDE.md`. Anforderungen im Handover-Paket
|
||||
Reihenfolge und Nachweise siehe `CLAUDE.md`. Anforderungen im Handover-Paket
|
||||
(`Imagetool-Lea/00..07`). Führend: `01-anforderungen.md`.
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
# Klarbild MCP-Server
|
||||
|
||||
Erlaubt einem KI-Assistenten (z. B. Claude), Bilder an Klarbild zu übergeben und dort zu
|
||||
verarbeiten — etwa „nimm die Bilder aus Ordner XY" oder „verarbeite die Fotos, die mir jemand
|
||||
per iMessage geschickt hat". Der Assistent besorgt die Bilddateien (mit seinen eigenen
|
||||
Werkzeugen) und ruft dann die Klarbild-Tools auf.
|
||||
|
||||
## Einrichtung
|
||||
|
||||
1. In Klarbild einen Token erzeugen: **Admin → „Automatisierung / MCP-Zugriff" → Token erzeugen**.
|
||||
2. Abhängigkeit installieren: `npm i @modelcontextprotocol/sdk`
|
||||
3. Server starten (bzw. im MCP-Client eintragen):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"klarbild": {
|
||||
"command": "node",
|
||||
"args": ["/pfad/zu/mcp/klarbild-mcp.mjs"],
|
||||
"env": {
|
||||
"KLARBILD_URL": "https://klarbild.heidrich-digital.de",
|
||||
"KLARBILD_TOKEN": "klb_…"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Tools
|
||||
|
||||
- **list_recipes** — verfügbare Presets/Rezepte auflisten.
|
||||
- **process_images** — Bilder (lokale Pfade oder `data:`-URLs) hochladen und verarbeiten.
|
||||
Entweder `recipeId` (Preset) oder `mode` (`each`/`compose`/`generate`) + Optionen:
|
||||
`tasks` (`clean`/`cutout`/`format`/`contour`), `prompt_text`, `output_format`
|
||||
(fest wie `30x40`/`theframe` **oder frei** `25x35` / `sticker5`), `output_ext` (`png`/`jpg`,
|
||||
weglassen = globaler Standard), `orientation`, `crop_mode`, `contour_mm`, `picdrop_gallery`,
|
||||
`delivery`. Liefert die Auftrags-ID.
|
||||
- **job_status** — Status eines Auftrags abfragen (inkl. Endzeit und Fehlermeldungen).
|
||||
|
||||
## Sicherheit
|
||||
|
||||
Der Token gibt vollen programmatischen Zugriff auf Upload/Verarbeitung (nicht auf den
|
||||
Admin-Bereich). Wie ein Passwort behandeln; bei Verdacht neuen Token erzeugen (der alte wird
|
||||
dadurch ungültig).
|
||||
@@ -0,0 +1,188 @@
|
||||
#!/usr/bin/env node
|
||||
// Klarbild MCP-Server — erlaubt einem KI-Assistenten (z. B. Claude), lokale Bilder
|
||||
// oder anderweitig beschaffte Bilder (iMessage, Ordner XY …) an Klarbild zu übergeben
|
||||
// und dort zu verarbeiten. Der Assistent besorgt die Bilddateien; dieser Server lädt
|
||||
// sie hoch und legt einen Auftrag an.
|
||||
//
|
||||
// Setup:
|
||||
// npm i @modelcontextprotocol/sdk
|
||||
// KLARBILD_URL=https://klarbild.heidrich-digital.de \
|
||||
// KLARBILD_TOKEN=klb_xxx node mcp/klarbild-mcp.mjs
|
||||
// Den Token erzeugt man in Klarbild unter Admin → „Automatisierung / MCP-Zugriff".
|
||||
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { basename, resolve } from 'node:path';
|
||||
|
||||
const BASE = (process.env.KLARBILD_URL || '').replace(/\/$/, '');
|
||||
const TOKEN = process.env.KLARBILD_TOKEN || '';
|
||||
if (!BASE || !TOKEN) { console.error('KLARBILD_URL und KLARBILD_TOKEN müssen gesetzt sein.'); process.exit(1); }
|
||||
|
||||
const authHeaders = { Authorization: `Bearer ${TOKEN}` };
|
||||
const api = (path) => `${BASE}${path}`;
|
||||
|
||||
async function uploadFile(pathOrBase64, name) {
|
||||
let buf, filename = name;
|
||||
if (pathOrBase64.startsWith('data:')) {
|
||||
buf = Buffer.from(pathOrBase64.split(',')[1], 'base64');
|
||||
filename = name || 'bild.png';
|
||||
} else {
|
||||
buf = await readFile(pathOrBase64);
|
||||
filename = name || basename(pathOrBase64);
|
||||
}
|
||||
const fd = new FormData();
|
||||
fd.append('files', new Blob([buf]), filename);
|
||||
const r = await fetch(api('/api/uploads'), { method: 'POST', headers: authHeaders, body: fd });
|
||||
const j = await r.json();
|
||||
const info = (j.files || [])[0];
|
||||
if (!info?.source_path) throw new Error(info?.error || 'Upload fehlgeschlagen');
|
||||
return { source_path: info.source_path, filename };
|
||||
}
|
||||
|
||||
const server = new Server({ name: 'klarbild', version: '1.0.0' }, { capabilities: { tools: {} } });
|
||||
|
||||
const TOOLS = [
|
||||
{ name: 'list_recipes', description: 'Verfügbare Presets/Rezepte in Klarbild auflisten.',
|
||||
inputSchema: { type: 'object', properties: {} } },
|
||||
{ name: 'process_images',
|
||||
description: 'Bilder an Klarbild übergeben und verarbeiten. Dateien als lokale Pfade ODER data:-URLs. Entweder recipeId ODER mode+Optionen angeben.',
|
||||
inputSchema: { type: 'object', properties: {
|
||||
files: { type: 'array', items: { type: 'string' }, description: 'Lokale Pfade oder data:-URLs' },
|
||||
recipeId: { type: 'string', description: 'Optional: Preset-ID (aus list_recipes) — überschreibt die Einzeloptionen' },
|
||||
mode: { type: 'string', enum: ['each', 'compose', 'generate'], description: 'Falls kein recipeId' },
|
||||
tasks: { type: 'array', items: { type: 'string', enum: ['clean', 'cutout', 'format', 'contour'] },
|
||||
description: 'Nur bei mode=each. Standard: ["format"] wenn output_format gesetzt.' },
|
||||
prompt_text: { type: 'string', description: 'Beschreibung für compose/generate' },
|
||||
output_format: { type: 'string', description: 'Fest: 30x40, A4, theframe, hochformat, keep … ODER frei: "25x35" (=25×35 cm) bzw. "sticker5" (=5×5 cm).' },
|
||||
output_ext: { type: 'string', enum: ['png', 'jpg'], description: 'Dateiformat. Weglassen = globale Standard-Einstellung. JPG nur ohne Transparenz.' },
|
||||
orientation: { type: 'string', enum: ['portrait', 'landscape'] },
|
||||
crop_mode: { type: 'string', enum: ['crop', 'extend'] },
|
||||
contour_mm: { type: 'number', description: 'Stickerrand in mm (nur mit tasks=cutout+contour).' },
|
||||
picdrop_gallery: { type: 'string', description: 'Ziel-Galerie/Unterordner (bei delivery picdrop/both).' },
|
||||
delivery: { type: 'string', enum: ['library', 'picdrop', 'both'] },
|
||||
}, required: ['files'] } },
|
||||
{ name: 'job_status', description: 'Status eines Auftrags abfragen (inkl. Endzeit und Fehlermeldungen).',
|
||||
inputSchema: { type: 'object', properties: { jobId: { type: 'string' } }, required: ['jobId'] } },
|
||||
|
||||
{ name: 'exact_size',
|
||||
description: 'OHNE KI: ein Bild exakt auf ein physisches Maß bringen (mittiger Ausschnitt im Zielverhältnis, dpi-Metadaten). Ergebnis wird lokal gespeichert.',
|
||||
inputSchema: { type: 'object', properties: {
|
||||
file: { type: 'string', description: 'Lokaler Pfad oder data:-URL' },
|
||||
size: { type: 'string', description: 'Maß: "12x15" (cm), "35x45mm", "5" (=5×5 cm) oder "4:3/15"' },
|
||||
landscape: { type: 'boolean', description: 'Querformat statt Hochformat' },
|
||||
dpi: { type: 'number', description: 'Standard 300' },
|
||||
ext: { type: 'string', enum: ['jpg', 'png'] },
|
||||
out: { type: 'string', description: 'Zielpfad für die Datei (Standard: ./klarbild-<maß>.<ext>)' },
|
||||
}, required: ['file', 'size'] } },
|
||||
|
||||
{ name: 'print_sheet',
|
||||
description: 'OHNE KI: mehrere Bilder in 100 %-Größe mit Schnittmarken auf einen Druckbogen setzen und als PDF speichern. Für Passbildsätze, Kita-Bilder, Sticker.',
|
||||
inputSchema: { type: 'object', properties: {
|
||||
images: { type: 'array', description: 'Je Eintrag: Datei + Endmaß + Stückzahl',
|
||||
items: { type: 'object', properties: {
|
||||
file: { type: 'string', description: 'Lokaler Pfad oder data:-URL' },
|
||||
size: { type: 'string', description: 'Endmaß, z. B. "35x45mm", "9x13", "12x15"' },
|
||||
count: { type: 'number', description: 'Wie oft auf den Bogen (Standard 1)' },
|
||||
landscape: { type: 'boolean' },
|
||||
allowRotate: { type: 'boolean', description: 'Darf gedreht platziert werden (Standard true)' },
|
||||
}, required: ['file', 'size'] } },
|
||||
paper: { type: 'string', description: 'A4 (Standard), A3, A3plus, A5, A6, A2, F10x15, F13x18, F9x13, F15x20, F20x30, Letter — oder ein freies Maß wie "32,9x48,3"' },
|
||||
landscape: { type: 'boolean', description: 'Bogen quer' },
|
||||
marks: { type: 'string', enum: ['none', 'corner', 'grid'], description: 'Schnitthilfen (Standard corner)' },
|
||||
marginMm: { type: 'number', description: 'Rand zum Papier, Standard 5' },
|
||||
gapMm: { type: 'number', description: 'Abstand zwischen den Bildern, Standard passend zu den Marken' },
|
||||
bleedMm: { type: 'number', description: 'Beschnittzugabe je Seite, Standard 0' },
|
||||
dpi: { type: 'number', description: 'Standard 300' },
|
||||
out: { type: 'string', description: 'Zielpfad des PDFs (Standard: ./klarbild-druckbogen.pdf)' },
|
||||
}, required: ['images'] } },
|
||||
];
|
||||
|
||||
server.setRequestHandler({ method: 'tools/list' }, async () => ({ tools: TOOLS }));
|
||||
|
||||
server.setRequestHandler({ method: 'tools/call' }, async (req) => {
|
||||
const { name, arguments: a = {} } = req.params;
|
||||
try {
|
||||
if (name === 'list_recipes') {
|
||||
const j = await (await fetch(api('/api/recipes'), { headers: authHeaders })).json();
|
||||
const list = (j.recipes || []).map((r) => `${r.id} — ${r.name} (${r.mode || 'each'})`).join('\n');
|
||||
return { content: [{ type: 'text', text: list || 'Keine Presets.' }] };
|
||||
}
|
||||
if (name === 'process_images') {
|
||||
const files = a.files || [];
|
||||
const mode = a.mode || 'each';
|
||||
const sources = (mode === 'generate') ? [] : await Promise.all(files.map((f) => uploadFile(f)));
|
||||
const wantsFormat = a.output_format && a.output_format !== 'keep';
|
||||
const tasks = Array.isArray(a.tasks) && a.tasks.length ? a.tasks : (wantsFormat ? ['format'] : []);
|
||||
const body = a.recipeId
|
||||
? { recipeId: a.recipeId, mode, sources, prompt_text: a.prompt_text }
|
||||
: { mode, prompt_text: a.prompt_text, sources,
|
||||
recipe: {
|
||||
tasks: mode === 'each' ? tasks : (wantsFormat ? ['format'] : []),
|
||||
output_format: a.output_format || 'keep',
|
||||
output_ext: a.output_ext || null,
|
||||
orientation: a.orientation || 'portrait',
|
||||
crop_mode: a.crop_mode || 'crop',
|
||||
contour_mm: a.contour_mm ?? null,
|
||||
picdrop_gallery: a.picdrop_gallery || null,
|
||||
delivery: a.delivery || 'library',
|
||||
},
|
||||
delivery: a.delivery || 'library' };
|
||||
const j = await (await fetch(api('/api/jobs'), { method: 'POST', headers: { ...authHeaders, 'Content-Type': 'application/json' }, body: JSON.stringify(body) })).json();
|
||||
if (!j.jobId) throw new Error(j.error || 'Auftrag fehlgeschlagen');
|
||||
return { content: [{ type: 'text', text: `Auftrag angelegt: ${j.jobId}` }] };
|
||||
}
|
||||
if (name === 'exact_size') {
|
||||
const up = await uploadFile(a.file);
|
||||
const r = await fetch(api('/api/print/single'), {
|
||||
method: 'POST', headers: { ...authHeaders, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ src: { kind: 'upload', path: up.source_path }, size: a.size,
|
||||
landscape: !!a.landscape, dpi: a.dpi || 300, ext: a.ext || 'jpg', name: up.filename }),
|
||||
});
|
||||
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || `HTTP ${r.status}`);
|
||||
const ext = a.ext === 'png' ? 'png' : 'jpg';
|
||||
const out = resolve(a.out || `./klarbild-${String(a.size).replace(/[^0-9a-z]+/gi, 'x')}.${ext}`);
|
||||
await writeFile(out, Buffer.from(await r.arrayBuffer()));
|
||||
const px = r.headers.get('x-klarbild-px'), real = r.headers.get('x-klarbild-real-dpi');
|
||||
const warn = r.headers.get('x-klarbild-dpi-ok') === '0' ? ` ⚠️ Quelle reicht nur für ca. ${real} dpi.` : '';
|
||||
return { content: [{ type: 'text', text: `Gespeichert: ${out} (${px} px).${warn}` }] };
|
||||
}
|
||||
if (name === 'print_sheet') {
|
||||
const imgs = a.images || [];
|
||||
if (!imgs.length) throw new Error('Keine Bilder angegeben.');
|
||||
const cells = [];
|
||||
for (const [i, im] of imgs.entries()) {
|
||||
const up = await uploadFile(im.file);
|
||||
cells.push({ id: `c${i}`, src: { kind: 'upload', path: up.source_path }, size: im.size,
|
||||
count: im.count || 1, landscape: !!im.landscape, allowRotate: im.allowRotate !== false });
|
||||
}
|
||||
const paper = a.paper && /[x×]/.test(a.paper) ? { size: a.paper } : { id: a.paper || 'A4' };
|
||||
const body = { paper, landscape: !!a.landscape, marginMm: a.marginMm ?? 5,
|
||||
bleedMm: a.bleedMm ?? 0, dpi: a.dpi || 300, ext: 'jpg', footer: true,
|
||||
marks: { mode: a.marks || 'corner' }, cells };
|
||||
if (a.gapMm != null) body.gapMm = a.gapMm;
|
||||
const r = await fetch(api('/api/print/sheet'), {
|
||||
method: 'POST', headers: { ...authHeaders, 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || `HTTP ${r.status}`);
|
||||
const out = resolve(a.out || './klarbild-druckbogen.pdf');
|
||||
await writeFile(out, Buffer.from(await r.arrayBuffer()));
|
||||
return { content: [{ type: 'text', text:
|
||||
`Gespeichert: ${out} — ${r.headers.get('x-klarbild-pages')} Bogen, ${r.headers.get('x-klarbild-per-sheet')} Bilder auf Bogen 1. ` +
|
||||
'Beim Drucken „Tatsächliche Größe / 100 %" wählen.' }] };
|
||||
}
|
||||
if (name === 'job_status') {
|
||||
const j = await (await fetch(api(`/api/jobs/${a.jobId}`), { headers: authHeaders })).json();
|
||||
const job = j.job || j;
|
||||
const errs = (j.items || []).map((it) => it.error_message).filter(Boolean);
|
||||
const fin = job.finished_at ? ` · beendet ${job.finished_at}` : '';
|
||||
const errTxt = errs.length ? `\nFehler: ${errs.join('; ')}` : '';
|
||||
return { content: [{ type: 'text', text: `Status: ${job.status} — ${job.done_count}/${job.total} fertig${job.failed_count ? `, ${job.failed_count} fehlgeschlagen` : ''}${fin}${errTxt}` }] };
|
||||
}
|
||||
return { content: [{ type: 'text', text: `Unbekanntes Tool: ${name}` }], isError: true };
|
||||
} catch (e) {
|
||||
return { content: [{ type: 'text', text: `Fehler: ${e?.message || e}` }], isError: true };
|
||||
}
|
||||
});
|
||||
|
||||
await server.connect(new StdioServerTransport());
|
||||
console.error('[klarbild-mcp] bereit');
|
||||
@@ -0,0 +1,35 @@
|
||||
-- Klarbild — Erzeugungs-Modi, Vorschaubilder, Speicherverwaltung, NAS-Sicherung.
|
||||
-- (04) Kombinieren aus mehreren Bildern + Freitext-Erzeugung, Thumbnails für die
|
||||
-- Bibliothek, „nicht den Server vollmüllen" (Retention/Quellenlöschen), zweite
|
||||
-- Datensicherung auf ein Synology-NAS.
|
||||
|
||||
-- Auftrags-Modus: each = jede Vorlage einzeln (bisher), compose = mehrere Bilder + Text
|
||||
-- zu EINEM neuen Bild, generate = reiner Freitext ohne Vorlage.
|
||||
ALTER TABLE jobs ADD COLUMN IF NOT EXISTS mode text NOT NULL DEFAULT 'each'
|
||||
CHECK (mode IN ('each','compose','generate'));
|
||||
|
||||
-- Positionen: mehrere Quellbilder (compose) + verkleinertes Vorschaubild.
|
||||
ALTER TABLE items ADD COLUMN IF NOT EXISTS source_paths jsonb;
|
||||
ALTER TABLE items ADD COLUMN IF NOT EXISTS thumb_path text;
|
||||
|
||||
-- Rezepte können einen Modus vorbelegen (für Telegram-Standardrezepte).
|
||||
ALTER TABLE recipes ADD COLUMN IF NOT EXISTS mode text NOT NULL DEFAULT 'each'
|
||||
CHECK (mode IN ('each','compose','generate'));
|
||||
|
||||
-- Speicherverwaltung -------------------------------------------------------
|
||||
ALTER TABLE settings ADD COLUMN IF NOT EXISTS keep_sources bool NOT NULL DEFAULT true;
|
||||
ALTER TABLE settings ADD COLUMN IF NOT EXISTS make_thumbnails bool NOT NULL DEFAULT true;
|
||||
ALTER TABLE settings ADD COLUMN IF NOT EXISTS retention_days int; -- NULL = unbegrenzt
|
||||
|
||||
-- Zweite Sicherung auf Synology-NAS (SFTP/FTPS, wie Picdrop) ----------------
|
||||
ALTER TABLE settings ADD COLUMN IF NOT EXISTS nas_enabled bool NOT NULL DEFAULT false;
|
||||
ALTER TABLE settings ADD COLUMN IF NOT EXISTS nas_host text;
|
||||
ALTER TABLE settings ADD COLUMN IF NOT EXISTS nas_protocol text CHECK (nas_protocol IN ('ftps','sftp'));
|
||||
ALTER TABLE settings ADD COLUMN IF NOT EXISTS nas_port int;
|
||||
ALTER TABLE settings ADD COLUMN IF NOT EXISTS nas_user text;
|
||||
ALTER TABLE settings ADD COLUMN IF NOT EXISTS nas_password_enc text;
|
||||
ALTER TABLE settings ADD COLUMN IF NOT EXISTS nas_base_path text;
|
||||
|
||||
-- Spiegel-Status je Position (für Sichtbarkeit/Wiederholung).
|
||||
ALTER TABLE items ADD COLUMN IF NOT EXISTS nas_status text NOT NULL DEFAULT 'none'
|
||||
CHECK (nas_status IN ('none','pending','mirrored','failed'));
|
||||
@@ -0,0 +1,5 @@
|
||||
-- Telegram: Kombinieren/Erzeugen — Bildunterschrift als Beschreibung + Zwischenstatus.
|
||||
ALTER TABLE telegram_drafts ADD COLUMN IF NOT EXISTS caption text;
|
||||
ALTER TABLE telegram_drafts DROP CONSTRAINT IF EXISTS telegram_drafts_status_check;
|
||||
ALTER TABLE telegram_drafts ADD CONSTRAINT telegram_drafts_status_check
|
||||
CHECK (status IN ('collecting','awaiting_recipe','awaiting_compose_text','dispatched','discarded'));
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Alternativen/Regenerierung: mehrere Ergebnisse zum selben Ursprung gruppieren.
|
||||
-- variant_of zeigt auf das „Wurzel"-Item (die erste Fassung); Alternativen teilen es.
|
||||
ALTER TABLE items ADD COLUMN IF NOT EXISTS variant_of uuid REFERENCES items(id) ON DELETE SET NULL;
|
||||
CREATE INDEX IF NOT EXISTS items_variant_idx ON items(variant_of);
|
||||
@@ -0,0 +1,21 @@
|
||||
-- Mehrere Auslieferungsziele (FTP/SFTP-Quellen), z. B. eine zweite Picdrop-Galerie
|
||||
-- oder ein anderer FTP-Server. Rezepte/Presets können ein Ziel fest verdrahten.
|
||||
CREATE TABLE IF NOT EXISTS delivery_targets (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name text NOT NULL,
|
||||
protocol text CHECK (protocol IN ('ftps','sftp')),
|
||||
host text,
|
||||
port int,
|
||||
username text,
|
||||
password_enc text,
|
||||
base_path text,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Preset → festes Ziel (NULL = Standard-Picdrop aus den Einstellungen).
|
||||
ALTER TABLE recipes ADD COLUMN IF NOT EXISTS delivery_target_id uuid
|
||||
REFERENCES delivery_targets(id) ON DELETE SET NULL;
|
||||
|
||||
-- Ordner → festes Ziel (optional).
|
||||
ALTER TABLE folders ADD COLUMN IF NOT EXISTS delivery_target_id uuid
|
||||
REFERENCES delivery_targets(id) ON DELETE SET NULL;
|
||||
@@ -0,0 +1,19 @@
|
||||
-- Backup für beliebige Ziele, Metadaten-Beileger, API-Token für externen Zugriff (MCP).
|
||||
|
||||
-- Jedes Zusatzziel kann als Backup-Ziel markiert werden (erhält ALLE Ergebnisse).
|
||||
ALTER TABLE delivery_targets ADD COLUMN IF NOT EXISTS is_backup bool NOT NULL DEFAULT false;
|
||||
|
||||
-- Metadaten als begleitende .md-Datei mitschicken (Prompt, Dateiname, Modell …).
|
||||
ALTER TABLE settings ADD COLUMN IF NOT EXISTS metadata_sidecar bool NOT NULL DEFAULT false;
|
||||
|
||||
-- API-Token für programmatischen Zugriff (Klarbild-MCP / Automationen).
|
||||
ALTER TABLE settings ADD COLUMN IF NOT EXISTS api_token text;
|
||||
|
||||
-- Backup-Status je Ziel und Position (für „auf mehreren Zielen gesichert").
|
||||
CREATE TABLE IF NOT EXISTS item_backups (
|
||||
item_id uuid REFERENCES items(id) ON DELETE CASCADE,
|
||||
target text NOT NULL, -- 'nas' | delivery_target-UUID
|
||||
status text NOT NULL DEFAULT 'mirrored' CHECK (status IN ('mirrored','failed')),
|
||||
at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (item_id, target)
|
||||
);
|
||||
@@ -0,0 +1,18 @@
|
||||
-- Zugriffsrechte, private Sessions, NSFW-Schalter.
|
||||
|
||||
-- Sichtbarkeit der Bibliothek + Datenschutz.
|
||||
ALTER TABLE settings ADD COLUMN IF NOT EXISTS library_visibility text NOT NULL DEFAULT 'own'
|
||||
CHECK (library_visibility IN ('own','shared')); -- own = jede/r sieht nur eigene; shared = alle sehen alles
|
||||
ALTER TABLE settings ADD COLUMN IF NOT EXISTS anonymous_generations bool NOT NULL DEFAULT false;
|
||||
ALTER TABLE settings ADD COLUMN IF NOT EXISTS private_allowed bool NOT NULL DEFAULT false;
|
||||
ALTER TABLE settings ADD COLUMN IF NOT EXISTS allow_nsfw bool NOT NULL DEFAULT false;
|
||||
|
||||
-- Pro Nutzer: private Sessions erzwingen.
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS private_forced bool NOT NULL DEFAULT false;
|
||||
|
||||
-- Private Aufträge: nur erzeugen, danach vergessen (nicht in Bibliothek, kein Delivery/Backup,
|
||||
-- kein gespeicherter Prompt, Quellen sofort gelöscht, Ergebnis nach kurzer Zeit entfernt).
|
||||
ALTER TABLE jobs ADD COLUMN IF NOT EXISTS private bool NOT NULL DEFAULT false;
|
||||
|
||||
-- Modelle als NSFW-fähig markieren (nur bei aktivem allow_nsfw wähl-/sichtbar).
|
||||
ALTER TABLE models ADD COLUMN IF NOT EXISTS nsfw bool NOT NULL DEFAULT false;
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Farbmarkierungen wie bei Picdrop (Rot / Orange / Grün / Final).
|
||||
ALTER TABLE items ADD COLUMN IF NOT EXISTS color_tag text
|
||||
CHECK (color_tag IN ('red','orange','green','final'));
|
||||
CREATE INDEX IF NOT EXISTS items_color_tag_idx ON items(color_tag);
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Metadaten-Beileger (.md) pro Upload-Quelle steuerbar statt global.
|
||||
ALTER TABLE settings ADD COLUMN IF NOT EXISTS picdrop_metadata_sidecar bool NOT NULL DEFAULT false;
|
||||
ALTER TABLE settings ADD COLUMN IF NOT EXISTS nas_metadata_sidecar bool NOT NULL DEFAULT false;
|
||||
ALTER TABLE delivery_targets ADD COLUMN IF NOT EXISTS metadata_sidecar bool NOT NULL DEFAULT false;
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Aufträge dürfen jetzt auch „failed" sein (alle Positionen fehlgeschlagen) → rot in der Übersicht.
|
||||
ALTER TABLE jobs DROP CONSTRAINT IF EXISTS jobs_status_check;
|
||||
ALTER TABLE jobs ADD CONSTRAINT jobs_status_check
|
||||
CHECK (status IN ('queued','running','paused','done','cancelled','failed'));
|
||||
|
||||
-- Ausgabeformat der Bilddatei: global (settings) + optionaler Rezept-Override.
|
||||
-- „png" = verlustfrei (Standard, nötig für Transparenz), „jpg" = klein/Picdrop-freundlich.
|
||||
ALTER TABLE settings ADD COLUMN IF NOT EXISTS output_ext text NOT NULL DEFAULT 'png'
|
||||
CHECK (output_ext IN ('png','jpg'));
|
||||
ALTER TABLE recipes ADD COLUMN IF NOT EXISTS output_ext text
|
||||
CHECK (output_ext IN ('png','jpg'));
|
||||
@@ -0,0 +1,14 @@
|
||||
-- Druckbogen-Vorlagen („Kita-Satz Felix", „8× Passbild") -------------------
|
||||
CREATE TABLE IF NOT EXISTS print_presets (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name text NOT NULL,
|
||||
created_by uuid REFERENCES users(id) ON DELETE SET NULL,
|
||||
config jsonb NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS print_presets_name_idx ON print_presets(name);
|
||||
|
||||
-- Telegram: Zustand „wartet auf Druck-Auswahl" -----------------------------
|
||||
ALTER TABLE telegram_drafts DROP CONSTRAINT IF EXISTS telegram_drafts_status_check;
|
||||
ALTER TABLE telegram_drafts ADD CONSTRAINT telegram_drafts_status_check
|
||||
CHECK (status IN ('collecting','awaiting_recipe','awaiting_compose_text','awaiting_print','dispatched','discarded'));
|
||||
Generated
+547
@@ -20,6 +20,7 @@
|
||||
"basic-ftp": "^5.0.5",
|
||||
"grammy": "^1.30.0",
|
||||
"heic-convert": "^2.1.0",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pg": "^8.13.1",
|
||||
"pg-boss": "^10.1.5",
|
||||
"react": "^19.0.0",
|
||||
@@ -31,6 +32,7 @@
|
||||
"@types/pg": "^8.11.10",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"tsx": "^4.23.12",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
},
|
||||
@@ -1763,6 +1765,24 @@
|
||||
"integrity": "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@pdf-lib/standard-fonts": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz",
|
||||
"integrity": "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pako": "^1.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@pdf-lib/upng": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@pdf-lib/upng/-/upng-1.0.1.tgz",
|
||||
"integrity": "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pako": "^1.0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@phc/format": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@phc/format/-/format-1.0.0.tgz",
|
||||
@@ -6135,6 +6155,12 @@
|
||||
"integrity": "sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pako": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
||||
"license": "(MIT AND Zlib)"
|
||||
},
|
||||
"node_modules/parse-latin": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/parse-latin/-/parse-latin-7.0.0.tgz",
|
||||
@@ -6196,6 +6222,24 @@
|
||||
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/pdf-lib": {
|
||||
"version": "1.17.1",
|
||||
"resolved": "https://registry.npmjs.org/pdf-lib/-/pdf-lib-1.17.1.tgz",
|
||||
"integrity": "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@pdf-lib/standard-fonts": "^1.0.0",
|
||||
"@pdf-lib/upng": "^1.0.1",
|
||||
"pako": "^1.0.11",
|
||||
"tslib": "^1.11.1"
|
||||
}
|
||||
},
|
||||
"node_modules/pdf-lib/node_modules/tslib": {
|
||||
"version": "1.14.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
|
||||
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/pg": {
|
||||
"version": "8.22.0",
|
||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz",
|
||||
@@ -7430,6 +7474,509 @@
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.23.12",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.12.tgz",
|
||||
"integrity": "sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esbuild": "~0.28.0"
|
||||
},
|
||||
"bin": {
|
||||
"tsx": "dist/cli.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "~2.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
|
||||
"integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/android-arm": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz",
|
||||
"integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/android-x64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz",
|
||||
"integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz",
|
||||
"integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz",
|
||||
"integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz",
|
||||
"integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz",
|
||||
"integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz",
|
||||
"integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz",
|
||||
"integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz",
|
||||
"integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/esbuild": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
|
||||
"integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
|
||||
"devOptional": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.28.2",
|
||||
"@esbuild/android-arm": "0.28.2",
|
||||
"@esbuild/android-arm64": "0.28.2",
|
||||
"@esbuild/android-x64": "0.28.2",
|
||||
"@esbuild/darwin-arm64": "0.28.2",
|
||||
"@esbuild/darwin-x64": "0.28.2",
|
||||
"@esbuild/freebsd-arm64": "0.28.2",
|
||||
"@esbuild/freebsd-x64": "0.28.2",
|
||||
"@esbuild/linux-arm": "0.28.2",
|
||||
"@esbuild/linux-arm64": "0.28.2",
|
||||
"@esbuild/linux-ia32": "0.28.2",
|
||||
"@esbuild/linux-loong64": "0.28.2",
|
||||
"@esbuild/linux-mips64el": "0.28.2",
|
||||
"@esbuild/linux-ppc64": "0.28.2",
|
||||
"@esbuild/linux-riscv64": "0.28.2",
|
||||
"@esbuild/linux-s390x": "0.28.2",
|
||||
"@esbuild/linux-x64": "0.28.2",
|
||||
"@esbuild/netbsd-arm64": "0.28.2",
|
||||
"@esbuild/netbsd-x64": "0.28.2",
|
||||
"@esbuild/openbsd-arm64": "0.28.2",
|
||||
"@esbuild/openbsd-x64": "0.28.2",
|
||||
"@esbuild/openharmony-arm64": "0.28.2",
|
||||
"@esbuild/sunos-x64": "0.28.2",
|
||||
"@esbuild/win32-arm64": "0.28.2",
|
||||
"@esbuild/win32-ia32": "0.28.2",
|
||||
"@esbuild/win32-x64": "0.28.2"
|
||||
}
|
||||
},
|
||||
"node_modules/tweetnacl": {
|
||||
"version": "0.14.5",
|
||||
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz",
|
||||
|
||||
+6
-3
@@ -8,21 +8,23 @@
|
||||
"build": "astro build",
|
||||
"start": "node ./dist/server/entry.mjs",
|
||||
"migrate": "node --loader tsx ./scripts/migrate.mjs",
|
||||
"astro": "astro"
|
||||
"astro": "astro",
|
||||
"test": "node --import tsx --test tests/*.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/node": "^9.1.3",
|
||||
"@astrojs/react": "^4.2.1",
|
||||
"@fontsource/space-grotesk": "^5.1.0",
|
||||
"@fontsource/space-mono": "^5.1.0",
|
||||
"@aws-sdk/client-s3": "^3.700.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.700.0",
|
||||
"@fontsource/space-grotesk": "^5.1.0",
|
||||
"@fontsource/space-mono": "^5.1.0",
|
||||
"archiver": "^7.0.1",
|
||||
"argon2": "^0.41.1",
|
||||
"astro": "^5.5.0",
|
||||
"basic-ftp": "^5.0.5",
|
||||
"grammy": "^1.30.0",
|
||||
"heic-convert": "^2.1.0",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pg": "^8.13.1",
|
||||
"pg-boss": "^10.1.5",
|
||||
"react": "^19.0.0",
|
||||
@@ -34,6 +36,7 @@
|
||||
"@types/pg": "^8.11.10",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"tsx": "^4.23.12",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
|
||||
+273
-8
@@ -8,20 +8,57 @@ export default function AdminApp() {
|
||||
const [users, setUsers] = useState<any[]>([]);
|
||||
const [tgLinks, setTgLinks] = useState<any[]>([]);
|
||||
const [toast, setToast] = useState<string | null>(null);
|
||||
const [orKey, setOrKey] = useState(''); const [pdPw, setPdPw] = useState('');
|
||||
const [orKey, setOrKey] = useState(''); const [pdPw, setPdPw] = useState(''); const [nasPw, setNasPw] = useState('');
|
||||
const [storage, setStorage] = useState<any>(null);
|
||||
const [targets, setTargets] = useState<any[]>([]);
|
||||
const [nt, setNt] = useState<any>({ protocol: 'sftp' });
|
||||
const notify = (t: string) => { setToast(t); setTimeout(() => setToast(null), 2600); };
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const [a, b, c, d, e] = await Promise.all([
|
||||
const [a, b, c, d, e, f, g] = await Promise.all([
|
||||
fetch('/api/admin/settings').then((r) => r.json()),
|
||||
fetch('/api/admin/stats').then((r) => r.json()),
|
||||
fetch('/api/admin/models?available=1').then((r) => r.json()),
|
||||
fetch('/api/admin/users').then((r) => r.json()),
|
||||
fetch('/api/admin/telegram/links').then((r) => r.json()).catch(() => ({ links: [] })),
|
||||
fetch('/api/admin/storage').then((r) => r.json()).catch(() => ({ stats: null })),
|
||||
fetch('/api/admin/delivery-targets').then((r) => r.json()).catch(() => ({ targets: [] })),
|
||||
]);
|
||||
setS(a.settings || {}); setStats(b); setModels(c.models || []); setAvail(c.available || []);
|
||||
setUsers(d.users || []); setTgLinks(e.links || []);
|
||||
setUsers(d.users || []); setTgLinks(e.links || []); setStorage(f.stats || null); setTargets(g.targets || []);
|
||||
}, []);
|
||||
const addTarget = async () => {
|
||||
if (!nt.name?.trim()) { notify('Name fehlt.'); return; }
|
||||
await fetch('/api/admin/delivery-targets', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(nt) });
|
||||
setNt({ protocol: 'sftp' }); notify('Ziel angelegt.'); load();
|
||||
};
|
||||
const delTarget = async (id: string) => { if (!confirm('Ziel löschen?')) return; await fetch(`/api/admin/delivery-targets?id=${id}`, { method: 'DELETE' }); load(); };
|
||||
const toggleBackup = async (t: any) => {
|
||||
await fetch('/api/admin/delivery-targets', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: t.id, is_backup: !t.is_backup }) });
|
||||
load();
|
||||
};
|
||||
const toggleTargetSidecar = async (t: any) => {
|
||||
await fetch('/api/admin/delivery-targets', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: t.id, metadata_sidecar: !t.metadata_sidecar }) });
|
||||
load();
|
||||
};
|
||||
const backupAllTargets = async () => {
|
||||
if (!confirm('Alle fertigen Bilder auf alle Backup-Ziele (NAS + markierte Ziele) sichern?')) return;
|
||||
notify('Sichere … (kann dauern)');
|
||||
const r = await fetch('/api/admin/delivery-targets', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'backup_all' }) }).then((x) => x.json());
|
||||
notify(r.items != null ? `${r.mirrored} Kopien auf Backup-Ziele${r.failed ? `, ${r.failed} Fehler` : ''}.` : (r.error || 'OK'));
|
||||
load();
|
||||
};
|
||||
const genToken = async () => {
|
||||
await fetch('/api/admin/settings', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ generate_api_token: true }) });
|
||||
notify('API-Token erzeugt.'); load();
|
||||
};
|
||||
const testTarget = async (id: string) => {
|
||||
notify('Teste Ziel …');
|
||||
const r = await fetch('/api/admin/delivery-targets', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'test', id }) }).then((x) => x.json());
|
||||
notify(r.message || (r.ok ? 'OK' : 'Fehler'));
|
||||
};
|
||||
const fmtBytes = (n: number | null) => n == null ? '—'
|
||||
: n > 1e9 ? `${(n / 1e9).toFixed(2)} GB` : n > 1e6 ? `${(n / 1e6).toFixed(1)} MB` : `${Math.round(n / 1e3)} KB`;
|
||||
|
||||
const pairCode = async (userId: string) => {
|
||||
const r = await fetch('/api/admin/telegram/pairing-code', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ userId }) }).then((x) => x.json());
|
||||
@@ -39,18 +76,75 @@ export default function AdminApp() {
|
||||
picdrop_host: s.picdrop_host, picdrop_protocol: s.picdrop_protocol, picdrop_port: s.picdrop_port,
|
||||
picdrop_user: s.picdrop_user, picdrop_base_path: s.picdrop_base_path, picdrop_default_gallery: s.picdrop_default_gallery,
|
||||
default_dpi: s.default_dpi, default_crop_mode: s.default_crop_mode, concurrency: s.concurrency,
|
||||
output_ext: s.output_ext || 'png',
|
||||
cricut_sheet_cm: s.cricut_sheet_cm, monthly_budget: s.monthly_budget, n8n_webhook_url: s.n8n_webhook_url,
|
||||
keep_sources: !!s.keep_sources, make_thumbnails: s.make_thumbnails !== false, retention_days: s.retention_days,
|
||||
picdrop_metadata_sidecar: !!s.picdrop_metadata_sidecar, nas_metadata_sidecar: !!s.nas_metadata_sidecar,
|
||||
library_visibility: s.library_visibility || 'own', anonymous_generations: !!s.anonymous_generations,
|
||||
private_allowed: !!s.private_allowed, allow_nsfw: !!s.allow_nsfw,
|
||||
nas_enabled: !!s.nas_enabled, nas_host: s.nas_host, nas_protocol: s.nas_protocol, nas_port: s.nas_port,
|
||||
nas_user: s.nas_user, nas_base_path: s.nas_base_path,
|
||||
};
|
||||
if (orKey.trim()) body.openrouter_key = orKey.trim();
|
||||
if (pdPw.trim()) body.picdrop_password = pdPw.trim();
|
||||
if (nasPw.trim()) body.nas_password = nasPw.trim();
|
||||
await fetch('/api/admin/settings', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||
setOrKey(''); setPdPw(''); notify('Gespeichert.'); load();
|
||||
setOrKey(''); setPdPw(''); setNasPw(''); notify('Gespeichert.'); load();
|
||||
};
|
||||
const testPicdrop = async () => {
|
||||
notify('Teste …');
|
||||
const r = await fetch('/api/admin/test-picdrop', { method: 'POST' }).then((x) => x.json());
|
||||
notify(r.message || (r.ok ? 'OK' : 'Fehler'));
|
||||
};
|
||||
const [pdList, setPdList] = useState<any>(null);
|
||||
const lsPicdrop = async (path?: string) => {
|
||||
notify('Lese Picdrop-Ordner …');
|
||||
const r = await fetch('/api/admin/picdrop-ls', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path }) }).then((x) => x.json());
|
||||
setPdList(r); notify(r.error ? r.error : `${(r.entries || []).length} Einträge in ${r.path}`);
|
||||
};
|
||||
const cleanupPicdrop = async () => {
|
||||
const gallery = s.picdrop_default_gallery || 'POSTER LEA';
|
||||
notify('Räume Reste auf …');
|
||||
const r = await fetch('/api/admin/picdrop-ls', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'cleanup_tmp', gallery }) }).then((x) => x.json());
|
||||
notify(r.error ? r.error : `${r.cleaned ?? 0} Reste in „${gallery}" entfernt.`);
|
||||
};
|
||||
const testNas = async () => {
|
||||
notify('Teste NAS …');
|
||||
const r = await fetch('/api/admin/test-nas', { method: 'POST' }).then((x) => x.json());
|
||||
notify(r.message || (r.ok ? 'OK' : 'Fehler'));
|
||||
};
|
||||
const netcheck = async () => {
|
||||
if (!s.nas_host) { notify('Erst Host eintragen und speichern.'); return; }
|
||||
notify('Prüfe Erreichbarkeit …');
|
||||
const r = await fetch('/api/admin/netcheck', { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ host: s.nas_host, port: s.nas_port || 22 }) }).then((x) => x.json());
|
||||
const dnsMsg = r.resolve ? (r.resolve.ok ? `DNS ✓ (${r.resolve.address})` : `DNS ✕ (${r.resolve.error})`) : '';
|
||||
const tcpMsg = r.tcp ? (r.tcp.ok ? `Port erreichbar in ${r.tcp.ms}ms ✓` : `nicht erreichbar: ${r.tcp.error}`) : (r.error || 'Fehler');
|
||||
notify(`${dnsMsg}${dnsMsg ? ' · ' : ''}${tcpMsg}`);
|
||||
};
|
||||
const storageAction = async (action: string, confirmMsg?: string) => {
|
||||
if (confirmMsg && !confirm(confirmMsg)) return;
|
||||
notify('Räume auf …');
|
||||
const r = await fetch('/api/admin/storage', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action }) }).then((x) => x.json());
|
||||
notify(r.deleted != null ? `${r.deleted} alte Positionen entfernt.` : r.purged != null ? `${r.purged} Quelldateien gelöscht.` : r.made != null ? `${r.made} Vorschaubilder erzeugt.` : (r.error || 'OK'));
|
||||
load();
|
||||
};
|
||||
const mirrorAll = async () => {
|
||||
if (!s.nas_enabled) { notify('NAS erst aktivieren & speichern.'); return; }
|
||||
if (!confirm('Alle fertigen Bilder aufs NAS sichern?')) return;
|
||||
notify('Sichere aufs NAS … (kann dauern)');
|
||||
const r = await fetch('/api/admin/storage', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'mirror_all' }) }).then((x) => x.json());
|
||||
notify(r.total != null ? `${r.mirrored}/${r.total} aufs NAS gesichert${r.failed ? `, ${r.failed} fehlgeschlagen` : ''}.` : (r.error || 'Fehler'));
|
||||
load();
|
||||
};
|
||||
const resetAll = async () => {
|
||||
const t = prompt('ACHTUNG: Löscht ALLE Bilder, Aufträge und Ordner unwiderruflich (Nutzer, Einstellungen, Modelle & Rezepte bleiben).\n\nZum Bestätigen RESET eingeben:');
|
||||
if (t !== 'RESET') { if (t != null) notify('Abgebrochen — nicht bestätigt.'); return; }
|
||||
notify('Setze zurück …');
|
||||
const r = await fetch('/api/admin/reset', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ confirm: 'RESET' }) }).then((x) => x.json());
|
||||
notify(r.ok ? `Zurückgesetzt: ${r.deleted_items} Bilder entfernt.` : (r.error || 'Fehler'));
|
||||
load();
|
||||
};
|
||||
const addModel = async (m: any) => {
|
||||
await fetch('/api/admin/models', { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ model_id: m.model_id, label: m.name, supports_alpha: m.supports_alpha, active: true }) });
|
||||
@@ -65,6 +159,10 @@ export default function AdminApp() {
|
||||
await fetch('/api/admin/users', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id, password: pw }) });
|
||||
notify('Passwort gesetzt.');
|
||||
};
|
||||
const setUserPrivate = async (id: string, v: boolean) => {
|
||||
await fetch('/api/admin/users', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id, private_forced: v }) });
|
||||
load();
|
||||
};
|
||||
const addUser = async () => {
|
||||
const username = prompt('Benutzername?'); if (!username) return;
|
||||
const password = prompt('Passwort?'); if (!password) return;
|
||||
@@ -107,12 +205,21 @@ export default function AdminApp() {
|
||||
<div className="feld"><label>Parallelität</label><input className="input" type="number" value={s.concurrency ?? 2} onChange={(e) => field('concurrency', e.target.value)} /></div>
|
||||
</div>
|
||||
<div className="feld"><label>Cricut-Bogenmaß (cm)</label><input className="input" value={s.cricut_sheet_cm ?? ''} onChange={(e) => field('cricut_sheet_cm', e.target.value)} /></div>
|
||||
<div className="feld"><label>Standard-Dateiformat</label>
|
||||
<select className="input" value={s.output_ext ?? 'png'} onChange={(e) => field('output_ext', e.target.value)}>
|
||||
<option value="png">PNG (verlustfrei, groß)</option>
|
||||
<option value="jpg">JPG (klein, Picdrop-freundlich)</option>
|
||||
</select>
|
||||
<div className="fein">Gilt für neue Umwandlungen ohne eigene Format-Wahl. Freigestellte Motive bleiben immer PNG.</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="karte">
|
||||
<div className="kopfzeile"><span className="mono-label">Picdrop</span>
|
||||
<button className="mini" onClick={testPicdrop}>Verbindung testen</button></div>
|
||||
<div className="reihe"><button className="mini" onClick={() => lsPicdrop()}>Ordner anzeigen</button>
|
||||
<button className="mini" onClick={cleanupPicdrop}>Reste aufräumen</button>
|
||||
<button className="mini" onClick={testPicdrop}>Verbindung testen</button></div></div>
|
||||
<div className="steuer">
|
||||
<div className="zwei">
|
||||
<div className="feld"><label>Host</label><input className="input" value={s.picdrop_host ?? ''} onChange={(e) => field('picdrop_host', e.target.value)} /></div>
|
||||
@@ -130,7 +237,141 @@ export default function AdminApp() {
|
||||
<div className="feld"><label>Basisordner (SFTP-Wurzel)</label><input className="input" value={s.picdrop_base_path ?? '/'} onChange={(e) => field('picdrop_base_path', e.target.value)} /></div>
|
||||
<div className="feld"><label>Standard-Galerie</label><input className="input" placeholder="POSTER LEA" value={s.picdrop_default_gallery ?? ''} onChange={(e) => field('picdrop_default_gallery', e.target.value)} /></div>
|
||||
</div>
|
||||
<div className="fein">Bilder landen in <b>Basisordner / Galerie</b> — z. B. <code>/ + POSTER LEA</code>. Pro Ordner lässt sich später eine eigene Galerie mappen.</div>
|
||||
<label className="schalt"><input type="checkbox" checked={!!s.picdrop_metadata_sidecar} onChange={(e) => field('picdrop_metadata_sidecar', e.target.checked)} />
|
||||
<span><b>Metadaten (.md) an Picdrop mitschicken</b><em>Nur aktivieren, wenn Picdrop die Textdateien verarbeiten soll — sonst aus lassen.</em></span></label>
|
||||
<div className="fein">Bilder landen in <b>Basisordner / Galerie</b> — z. B. <code>/ + POSTER LEA</code>. „Ordner anzeigen" listet, was per SFTP tatsächlich wo liegt.</div>
|
||||
{pdList && (
|
||||
<div className="pdlist">
|
||||
<div className="fein"><b>{pdList.path}</b>{pdList.error ? ` — ${pdList.error}` : ''}</div>
|
||||
{(pdList.entries || []).map((e: any, i: number) => (
|
||||
<div key={i} className="pdrow">
|
||||
<button className="link2" onClick={() => lsPicdrop((pdList.path.replace(/\/$/, '')) + '/' + e.name)}>{e.type === 'd' || e.type === 2 ? '📁' : '📄'} {e.name}</button>
|
||||
<span className="fein">{e.size ? `${Math.round(e.size / 1024)} KB` : ''}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="karte">
|
||||
<div className="kopfzeile"><span className="mono-label">Speicher & Aufräumen</span>
|
||||
{storage && <span className="fein">{fmtBytes(storage.bytes)} · {storage.results} Ergebnisse · {storage.sources} Quellen · {storage.thumbs} Vorschau</span>}</div>
|
||||
<div className="steuer">
|
||||
<label className="schalt"><input type="checkbox" checked={s.make_thumbnails !== false} onChange={(e) => field('make_thumbnails', e.target.checked)} />
|
||||
<span><b>Vorschaubilder erzeugen</b><em>Kleine, sparsame Bilder für die Bibliotheksvorschau.</em></span></label>
|
||||
<label className="schalt"><input type="checkbox" checked={!s.keep_sources} onChange={(e) => field('keep_sources', !e.target.checked)} />
|
||||
<span><b>Quellbilder nach Bearbeitung löschen</b><em>Spart Platz — Originale werden nach dem Ergebnis entfernt.</em></span></label>
|
||||
<div className="fein">Metadaten-Beileger (.md mit Prompt/Modell/Format) lassen sich jetzt <b>pro Ziel</b> ein-/ausschalten — bei Picdrop, beim NAS und bei jedem FTP-Ziel einzeln (siehe die jeweilige Karte).</div>
|
||||
<div className="feld"><label>Aufbewahrung (Tage)</label>
|
||||
<input className="input" type="number" min={0} placeholder="leer = unbegrenzt" value={s.retention_days ?? ''} onChange={(e) => field('retention_days', e.target.value)} />
|
||||
<div className="fein">Ältere Bilder werden automatisch (täglich) entfernt. Leer = nichts löschen.</div></div>
|
||||
<div className="reihe">
|
||||
<button className="mini" onClick={() => storageAction('purge_sources', 'Alle Quellbilder fertiger Positionen löschen? Ergebnisse bleiben erhalten.')}>Quellen jetzt löschen</button>
|
||||
<button className="mini" onClick={() => storageAction('rebuild_thumbs')}>Vorschaubilder nachgenerieren</button>
|
||||
{s.retention_days ? <button className="mini" onClick={() => storageAction('retention', `Positionen älter als ${s.retention_days} Tage jetzt löschen?`)}>Retention jetzt anwenden</button> : null}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="karte">
|
||||
<div className="kopfzeile"><span className="mono-label">Synology-NAS (zweite Sicherung)</span>
|
||||
<div className="reihe"><button className="mini" onClick={netcheck}>Erreichbarkeit</button>
|
||||
<button className="mini" onClick={testNas}>Verbindung testen</button></div></div>
|
||||
<div className="steuer">
|
||||
<label className="schalt"><input type="checkbox" checked={!!s.nas_enabled} onChange={(e) => field('nas_enabled', e.target.checked)} />
|
||||
<span><b>Ergebnisse zusätzlich aufs NAS spiegeln</b><em>Jedes fertige Bild wird nach <code>klarbild/JJJJ-MM/</code> gesichert.</em></span></label>
|
||||
<div className="zwei">
|
||||
<div className="feld"><label>Host</label><input className="input" placeholder="z. B. nas.local oder DDNS" value={s.nas_host ?? ''} onChange={(e) => field('nas_host', e.target.value)} /></div>
|
||||
<div className="feld"><label>Port</label><input className="input" type="number" value={s.nas_port ?? 22} onChange={(e) => field('nas_port', e.target.value)} /></div>
|
||||
</div>
|
||||
<div className="zwei">
|
||||
<div className="feld"><label>Protokoll</label>
|
||||
<select className="input" value={s.nas_protocol ?? 'sftp'} onChange={(e) => field('nas_protocol', e.target.value)}>
|
||||
<option value="sftp">SFTP (22)</option><option value="ftps">FTPS (21)</option></select></div>
|
||||
<div className="feld"><label>Benutzer</label><input className="input" value={s.nas_user ?? ''} onChange={(e) => field('nas_user', e.target.value)} /></div>
|
||||
</div>
|
||||
<div className="feld"><label>Passwort {s.nas_password_set && <em>(gesetzt)</em>}</label>
|
||||
<input className="input" type="password" placeholder={s.nas_password_set ? '••••••' : ''} value={nasPw} onChange={(e) => setNasPw(e.target.value)} /></div>
|
||||
<div className="feld"><label>Basisordner auf dem NAS</label><input className="input" placeholder="/Klarbild" value={s.nas_base_path ?? ''} onChange={(e) => field('nas_base_path', e.target.value)} /></div>
|
||||
<label className="schalt"><input type="checkbox" checked={!!s.nas_metadata_sidecar} onChange={(e) => field('nas_metadata_sidecar', e.target.checked)} />
|
||||
<span><b>Metadaten (.md) aufs NAS mitschicken</b><em>Begleitende Textdatei (Prompt, Modell, Format …) neben jedem gesicherten Bild.</em></span></label>
|
||||
<button className="mini" onClick={mirrorAll}>Alle vorhandenen Bilder aufs NAS sichern</button>
|
||||
<div className="fein">Ergebnisse landen unter <code>Basisordner / JJJJ-MM /</code>. Zugangsdaten werden verschlüsselt gespeichert.</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="karte">
|
||||
<div className="kopfzeile"><span className="mono-label">Weitere Ausgabeziele (FTP/SFTP)</span>
|
||||
<button className="mini" onClick={backupAllTargets}>Alle Bilder sichern</button></div>
|
||||
<div className="steuer">
|
||||
<div className="fein">Zusätzliche FTP/SFTP-Ziele — z. B. eine zweite Picdrop-Galerie oder ein anderer Server.
|
||||
Ein Preset/Ordner kann fest auf ein Ziel zeigen; im Studio unter „Wohin?" wählbar (auch NAS).
|
||||
„Backup"-Ziele erhalten automatisch <b>alle</b> Ergebnisse (wie das NAS).</div>
|
||||
<div className="liste">
|
||||
{targets.map((t) => (
|
||||
<div key={t.id} className="zeile">
|
||||
<div><b>{t.name}</b>{t.is_backup && <span className="pille">Backup</span>}{t.metadata_sidecar && <span className="pille alpha">.md</span>} <span className="fein">{t.protocol}://{t.username}@{t.host}:{t.port} · {t.base_path || '/'}{t.password_set ? '' : ' · ⚠︎ kein Passwort'}</span></div>
|
||||
<div className="reihe">
|
||||
<button className="mini" onClick={() => toggleBackup(t)}>{t.is_backup ? 'Backup aus' : 'Als Backup'}</button>
|
||||
<button className="mini" onClick={() => toggleTargetSidecar(t)}>{t.metadata_sidecar ? '.md aus' : '.md an'}</button>
|
||||
<button className="mini" onClick={() => testTarget(t.id)}>Test</button>
|
||||
<button className="mini" onClick={() => delTarget(t.id)}>✕</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{targets.length === 0 && <div className="fein">Noch keine zusätzlichen Ziele.</div>}
|
||||
</div>
|
||||
<div className="zwei">
|
||||
<div className="feld"><label>Name</label><input className="input" placeholder="z. B. The Frame FTP" value={nt.name ?? ''} onChange={(e) => setNt({ ...nt, name: e.target.value })} /></div>
|
||||
<div className="feld"><label>Protokoll</label>
|
||||
<select className="input" value={nt.protocol} onChange={(e) => setNt({ ...nt, protocol: e.target.value })}>
|
||||
<option value="sftp">SFTP</option><option value="ftps">FTPS</option></select></div>
|
||||
</div>
|
||||
<div className="zwei">
|
||||
<div className="feld"><label>Host</label><input className="input" value={nt.host ?? ''} onChange={(e) => setNt({ ...nt, host: e.target.value })} /></div>
|
||||
<div className="feld"><label>Port</label><input className="input" type="number" value={nt.port ?? ''} onChange={(e) => setNt({ ...nt, port: e.target.value })} /></div>
|
||||
</div>
|
||||
<div className="zwei">
|
||||
<div className="feld"><label>Benutzer</label><input className="input" value={nt.username ?? ''} onChange={(e) => setNt({ ...nt, username: e.target.value })} /></div>
|
||||
<div className="feld"><label>Passwort</label><input className="input" type="password" value={nt.password ?? ''} onChange={(e) => setNt({ ...nt, password: e.target.value })} /></div>
|
||||
</div>
|
||||
<div className="feld"><label>Basisordner</label><input className="input" placeholder="/" value={nt.base_path ?? ''} onChange={(e) => setNt({ ...nt, base_path: e.target.value })} /></div>
|
||||
<label className="schalt"><input type="checkbox" checked={!!nt.is_backup} onChange={(e) => setNt({ ...nt, is_backup: e.target.checked })} />
|
||||
<span><b>Als Backup-Ziel</b><em>Erhält automatisch alle Ergebnisse.</em></span></label>
|
||||
<label className="schalt"><input type="checkbox" checked={!!nt.metadata_sidecar} onChange={(e) => setNt({ ...nt, metadata_sidecar: e.target.checked })} />
|
||||
<span><b>Metadaten (.md) mitschicken</b><em>Begleitende Textdatei je Bild.</em></span></label>
|
||||
<button className="mini" onClick={addTarget}>Ziel hinzufügen</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="karte">
|
||||
<div className="kopfzeile"><span className="mono-label">Automatisierung / MCP-Zugriff</span></div>
|
||||
<div className="steuer">
|
||||
<div className="fein">API-Token für externen/programmatischen Zugriff (z. B. Klarbild-MCP: „nimm diese Bilder und verarbeite sie"). Als <code>Authorization: Bearer <Token></code> an <code>/api/uploads</code> und <code>/api/jobs</code>.</div>
|
||||
{s.api_token
|
||||
? <div className="feld"><label>API-Token</label><input className="input" readOnly value={s.api_token} onFocus={(e) => e.target.select()} /></div>
|
||||
: <div className="fein">Noch kein Token erzeugt.</div>}
|
||||
<button className="mini" onClick={genToken}>{s.api_token ? 'Neuen Token erzeugen' : 'Token erzeugen'}</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="karte">
|
||||
<div className="kopfzeile"><span className="mono-label">Rechte & Datenschutz</span></div>
|
||||
<div className="steuer">
|
||||
<div className="feld"><label>Wer sieht welche Generierungen?</label>
|
||||
<div className="schalter">
|
||||
<button className={s.library_visibility !== 'shared' ? 'an' : ''} onClick={() => field('library_visibility', 'own')}>Nur eigene</button>
|
||||
<button className={s.library_visibility === 'shared' ? 'an' : ''} onClick={() => field('library_visibility', 'shared')}>Alle sehen alles</button>
|
||||
</div>
|
||||
<div className="fein">Admins sehen immer alles. „Nur eigene": jede/r sieht ausschließlich die selbst erzeugten Bilder.</div>
|
||||
</div>
|
||||
<label className="schalt"><input type="checkbox" checked={!!s.anonymous_generations} onChange={(e) => field('anonymous_generations', e.target.checked)} />
|
||||
<span><b>Generierungen anonym</b><em>Wer ein Bild erzeugt hat, wird für Nicht-Admins ausgeblendet.</em></span></label>
|
||||
<label className="schalt"><input type="checkbox" checked={!!s.private_allowed} onChange={(e) => field('private_allowed', e.target.checked)} />
|
||||
<span><b>Private Sessions erlauben</b><em>Nutzer bekommen im Studio den Schalter „Private Session" (nur erzeugen, danach vergessen).</em></span></label>
|
||||
<label className="schalt"><input type="checkbox" checked={!!s.allow_nsfw} onChange={(e) => field('allow_nsfw', e.target.checked)} />
|
||||
<span><b>NSFW-Modelle erlauben</b><em>Als NSFW markierte Modelle werden wähl-/sichtbar. Verantwortung liegt beim Betreiber; Inhalte mit Minderjährigen sind ausnahmslos untersagt.</em></span></label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -141,9 +382,10 @@ export default function AdminApp() {
|
||||
<div className="liste">
|
||||
{models.map((m) => (
|
||||
<div key={m.id} className="zeile">
|
||||
<div><b>{m.label}</b>{m.is_default && <span className="pille">Standard</span>}{m.supports_alpha && <span className="pille alpha">transparent</span>}<div className="fein">{m.model_id}</div></div>
|
||||
<div><b>{m.label}</b>{m.is_default && <span className="pille">Standard</span>}{m.supports_alpha && <span className="pille alpha">transparent</span>}{m.nsfw && <span className="pille nsfw">NSFW</span>}<div className="fein">{m.model_id}</div></div>
|
||||
<div className="reihe">
|
||||
{!m.is_default && <button className="mini" onClick={() => modelAction(m.id, 'default')}>Standard</button>}
|
||||
<button className="mini" onClick={() => modelAction(m.id, 'nsfw')}>{m.nsfw ? 'NSFW aus' : 'NSFW'}</button>
|
||||
<button className="mini" onClick={() => modelAction(m.id, 'delete')}>✕</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -162,8 +404,9 @@ export default function AdminApp() {
|
||||
<div className="liste">
|
||||
{users.map((u) => (
|
||||
<div key={u.id} className="zeile">
|
||||
<div><b>{u.display_name || u.username}</b> <span className="fein">{u.username} · {u.role}</span></div>
|
||||
<div><b>{u.display_name || u.username}</b>{u.private_forced && <span className="pille">privat</span>} <span className="fein">{u.username} · {u.role}</span></div>
|
||||
<div className="reihe">
|
||||
<button className="mini" onClick={() => setUserPrivate(u.id, !u.private_forced)}>{u.private_forced ? 'Privat aus' : 'Privat erzwingen'}</button>
|
||||
<button className="mini" onClick={() => pairCode(u.id)}>Telegram koppeln</button>
|
||||
<button className="mini" onClick={() => setPw(u.id)}>Passwort setzen</button>
|
||||
</div>
|
||||
@@ -186,6 +429,15 @@ export default function AdminApp() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="karte gefahr">
|
||||
<div className="kopfzeile"><span className="mono-label">Zurücksetzen</span></div>
|
||||
<div className="steuer">
|
||||
<div className="fein">Löscht <b>alle Bilder, Aufträge und Ordner</b> unwiderruflich, um sauber neu zu starten.
|
||||
Nutzer, Einstellungen, Modelle und Rezepte bleiben erhalten. Tipp: vorher „Alle Bilder aufs NAS sichern".</div>
|
||||
<button className="knopf warn" onClick={resetAll}>Bibliothek & Aufträge zurücksetzen</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{toast && <div className="toast">{toast}</div>}
|
||||
<AdminStyles />
|
||||
</div>
|
||||
@@ -204,6 +456,10 @@ function AdminStyles() {
|
||||
.input{width:100%;background:#fff;border:1px solid var(--line);border-radius:3px;padding:9px 11px;font-family:inherit;font-size:14px;color:var(--ink);}
|
||||
.fein{font-size:11.5px;color:var(--soft);line-height:1.5;}
|
||||
.knopf{border:none;border-radius:3px;padding:12px;cursor:pointer;background:var(--accent);color:#fff;font-family:inherit;font-weight:600;font-size:15px;}
|
||||
.knopf.warn{background:var(--err);}
|
||||
.karte.gefahr{border-color:color-mix(in oklab,var(--err) 40%,var(--line));}
|
||||
.karte.gefahr .mono-label{color:var(--err);}
|
||||
.steuer code{font-family:var(--font-mono);font-size:11px;background:var(--paper);border:1px solid var(--line);border-radius:3px;padding:1px 5px;}
|
||||
.zahlen{display:grid;grid-template-columns:repeat(2,1fr);gap:10px;padding:16px;}
|
||||
.zahlen div{background:var(--paper);border:1px solid var(--line);border-radius:3px;padding:11px;}
|
||||
.zahlen b{display:block;font-size:22px;line-height:1;}.zahlen span{font-size:11.5px;color:var(--soft);}
|
||||
@@ -213,9 +469,18 @@ function AdminStyles() {
|
||||
.zeile b{font-size:14px;}
|
||||
.pille{font-family:var(--font-mono);font-size:9px;letter-spacing:.1em;text-transform:uppercase;background:var(--accent-bg);color:var(--accent);padding:2px 7px;border-radius:20px;margin-left:6px;}
|
||||
.pille.alpha{background:var(--paper);color:var(--soft);}
|
||||
.pille.nsfw{background:var(--err);color:#fff;}
|
||||
.schalt{display:flex;gap:10px;align-items:flex-start;cursor:pointer;}
|
||||
.schalt input{margin-top:3px;width:16px;height:16px;accent-color:var(--accent);flex:0 0 auto;}
|
||||
.schalt b{display:block;font-size:14px;}
|
||||
.schalt em{display:block;font-style:normal;font-size:11.5px;color:var(--soft);margin-top:1px;line-height:1.4;}
|
||||
.schalt code{font-family:var(--font-mono);font-size:11px;}
|
||||
.avail{display:flex;flex-wrap:wrap;gap:6px;margin-top:10px;}
|
||||
.reihe{display:flex;gap:6px;}
|
||||
.mini{background:var(--card);border:1px solid var(--line);border-radius:3px;padding:6px 10px;cursor:pointer;font-family:inherit;font-size:12px;color:var(--ink);}
|
||||
.toast{position:fixed;left:50%;bottom:26px;transform:translateX(-50%);background:var(--ink);color:#FBFBF7;padding:10px 18px;border-radius:3px;font-size:13.5px;z-index:60;}
|
||||
.pdlist{background:var(--paper);border:1px solid var(--line);border-radius:4px;padding:10px;margin-top:8px;max-height:260px;overflow:auto;}
|
||||
.pdrow{display:flex;justify-content:space-between;align-items:center;gap:8px;padding:3px 0;}
|
||||
.link2{background:none;border:none;cursor:pointer;color:var(--accent);font-family:var(--font-mono);font-size:12px;text-align:left;padding:0;}
|
||||
`}</style>;
|
||||
}
|
||||
|
||||
+241
-40
@@ -1,18 +1,41 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
interface Item { id: string; filename: string; output_px: string; has_alpha: boolean;
|
||||
folder_id: string | null; by_name: string; tasks: string[]; delivery_status: string; model_used: string; }
|
||||
folder_id: string | null; by_name: string; tasks: string[]; delivery_status: string; nas_status: string;
|
||||
model_used: string; variant_of: string | null; mode: string; thumb_path: string | null; prompt_used: string | null;
|
||||
color_tag: string | null; created_at: string; }
|
||||
|
||||
const TAGS = [
|
||||
{ id: 'red', label: 'Rot', color: '#E5484D' },
|
||||
{ id: 'orange', label: 'Orange', color: '#E8830C' },
|
||||
{ id: 'green', label: 'Grün', color: '#46A758' },
|
||||
{ id: 'final', label: 'Final', color: '#3A3733' },
|
||||
];
|
||||
const tagColor = (t: string | null) => TAGS.find((x) => x.id === t)?.color || null;
|
||||
|
||||
export default function LibraryApp() {
|
||||
const [items, setItems] = useState<Item[]>([]);
|
||||
const [folders, setFolders] = useState<any[]>([]);
|
||||
const [models, setModels] = useState<any[]>([]);
|
||||
const modelLabel = (id: string) => models.find((m) => m.model_id === id)?.label || null;
|
||||
const [sel, setSel] = useState<Set<string>>(new Set());
|
||||
const [folderFilter, setFolderFilter] = useState<string | null>(null);
|
||||
const [pick, setPick] = useState<Set<string>>(new Set());
|
||||
const [variantSel, setVariantSel] = useState<Record<string, string>>({});
|
||||
const [compare, setCompare] = useState<Item | null>(null);
|
||||
const [promptOf, setPromptOf] = useState<Item | null>(null);
|
||||
const [saveView, setSaveView] = useState<Item | null>(null);
|
||||
const [tagMenu, setTagMenu] = useState<string | null>(null);
|
||||
const [tagFilter, setTagFilter] = useState<string | null>(null);
|
||||
const [toast, setToast] = useState<string | null>(null);
|
||||
const notify = (t: string) => { setToast(t); setTimeout(() => setToast(null), 2400); };
|
||||
|
||||
// Modellname: Admin-Label, sonst lesbarer Rest der Modell-ID.
|
||||
const modelName = (id: string | null) => {
|
||||
if (!id) return null;
|
||||
const m = models.find((x) => x.model_id === id);
|
||||
if (m) return m.label;
|
||||
return id.split('/').pop()!.replace(/-/g, ' ');
|
||||
};
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const [i, f, m] = await Promise.all([
|
||||
fetch('/api/items').then((r) => r.json()).catch(() => ({ items: [] })),
|
||||
@@ -25,67 +48,191 @@ export default function LibraryApp() {
|
||||
|
||||
const rename = async (id: string, filename: string) =>
|
||||
fetch(`/api/items/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filename }) });
|
||||
const setTag = async (id: string, color_tag: string | null) => {
|
||||
setItems((x) => x.map((y) => y.id === id ? { ...y, color_tag } : y));
|
||||
setTagMenu(null);
|
||||
await fetch(`/api/items/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ color_tag }) });
|
||||
};
|
||||
const setFolder = async (id: string, folder_id: string | null) => {
|
||||
await fetch(`/api/items/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ folder_id }) });
|
||||
load();
|
||||
};
|
||||
const del = async (id: string) => { await fetch(`/api/items/${id}`, { method: 'DELETE' }); setItems((x) => x.filter((y) => y.id !== id)); };
|
||||
const delSelected = async () => { for (const id of sel) await fetch(`/api/items/${id}`, { method: 'DELETE' }); setSel(new Set()); load(); notify('Gelöscht.'); };
|
||||
const delSelected = async () => { for (const id of pick) await fetch(`/api/items/${id}`, { method: 'DELETE' }); setPick(new Set()); load(); notify('Gelöscht.'); };
|
||||
const deliverSelected = async () => {
|
||||
notify('Sende an Picdrop …');
|
||||
const r = await fetch('/api/deliver', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ itemIds: [...sel] }) }).then((x) => x.json());
|
||||
setSel(new Set()); load(); notify(`${r.delivered}/${r.total} nach Picdrop ausgeliefert.`);
|
||||
const r = await fetch('/api/deliver', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ itemIds: [...pick] }) }).then((x) => x.json());
|
||||
setPick(new Set()); load(); notify(`${r.delivered}/${r.total} nach Picdrop ausgeliefert.`);
|
||||
};
|
||||
const toggle = (id: string) => setSel((s) => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n; });
|
||||
const toggle = (id: string) => setPick((s) => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n; });
|
||||
const moveSelected = async (folder_id: string | null) => {
|
||||
for (const id of pick) await fetch(`/api/items/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ folder_id }) });
|
||||
setPick(new Set()); load(); notify(folder_id ? 'In Ordner verschoben.' : 'Aus Ordner entfernt.');
|
||||
};
|
||||
const zipSelected = async () => {
|
||||
notify('Packe ZIP …');
|
||||
const res = await fetch('/api/items/zip', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ itemIds: [...pick] }) });
|
||||
if (!res.ok) { notify('ZIP fehlgeschlagen.'); return; }
|
||||
const blob = await res.blob();
|
||||
const a = document.createElement('a'); a.href = URL.createObjectURL(blob);
|
||||
a.download = `klarbild_${new Date().toISOString().slice(0, 10)}.zip`; a.click();
|
||||
URL.revokeObjectURL(a.href); notify('ZIP geladen.');
|
||||
};
|
||||
const alternative = async (id: string) => {
|
||||
notify('Erzeuge Alternative …');
|
||||
const r = await fetch(`/api/items/${id}/alternative`, { method: 'POST' }).then((x) => x.json());
|
||||
if (r.ok) notify('Alternative wird erzeugt — erscheint gleich hier.'); else notify(r.error || 'Fehler.');
|
||||
setTimeout(load, 2500);
|
||||
};
|
||||
// Ergebnis als neue Vorlage ins Studio übernehmen und weiterbearbeiten.
|
||||
const reuse = (id: string) => { window.location.href = `/?reuse=${id}`; };
|
||||
|
||||
const newFolder = async () => {
|
||||
const name = prompt('Name des Ordners?'); if (!name) return;
|
||||
await fetch('/api/folders', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) });
|
||||
const gallery = prompt('Picdrop-Galerie für diesen Ordner? (leer = Standard)') || '';
|
||||
await fetch('/api/folders', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, picdrop_gallery: gallery.trim() || null }) });
|
||||
load();
|
||||
};
|
||||
const delFolder = async (id: string) => {
|
||||
if (!confirm('Ordner löschen? Die Bilder bleiben erhalten.')) return;
|
||||
await fetch(`/api/folders?id=${id}`, { method: 'DELETE' });
|
||||
if (folderFilter === id) setFolderFilter(null);
|
||||
load();
|
||||
};
|
||||
const renameFolder = async (o: any) => {
|
||||
const name = prompt('Ordner umbenennen:', o.name); if (name == null) return;
|
||||
const gallery = prompt('Picdrop-Galerie (leer = Standard):', o.picdrop_gallery || '');
|
||||
await fetch('/api/folders', { method: 'PATCH', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id: o.id, name: name.trim() || o.name, picdrop_gallery: (gallery || '').trim() || null }) });
|
||||
load();
|
||||
};
|
||||
|
||||
// Nach Ordner + Markierung filtern, dann Alternativen unter ihrem Ursprung gruppieren.
|
||||
const filtered = items
|
||||
.filter((v) => !folderFilter || v.folder_id === folderFilter)
|
||||
.filter((v) => !tagFilter || v.color_tag === tagFilter);
|
||||
const groupMap = new Map<string, Item[]>();
|
||||
for (const it of filtered) {
|
||||
const root = it.variant_of || it.id;
|
||||
if (!groupMap.has(root)) groupMap.set(root, []);
|
||||
groupMap.get(root)!.push(it);
|
||||
}
|
||||
const groups = [...groupMap.values()].map((g) => g.slice().sort((a, b) => +new Date(a.created_at) - +new Date(b.created_at)));
|
||||
groups.sort((a, b) => +new Date(b[b.length - 1].created_at) - +new Date(a[a.length - 1].created_at));
|
||||
const shown = (g: Item[]) => g.find((x) => x.id === variantSel[g[0].id]) || g[g.length - 1];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="kopfzeile">
|
||||
<span className="mono-label">Bibliothek{items.length ? ` · ${items.length}` : ''}</span>
|
||||
<span className="mono-label">Bibliothek{filtered.length ? ` · ${filtered.length}` : ''}</span>
|
||||
<div className="reihe">
|
||||
{sel.size > 0 && <>
|
||||
<span className="fein">{sel.size} gewählt</span>
|
||||
{pick.size > 0 && <>
|
||||
<span className="fein">{pick.size} gewählt</span>
|
||||
<button className="mini stark" onClick={deliverSelected}>Nach Picdrop</button>
|
||||
<button className="mini" onClick={zipSelected}>ZIP laden</button>
|
||||
<select className="mini-select" value="" onChange={(e) => { if (e.target.value) moveSelected(e.target.value === '__none' ? null : e.target.value); }}>
|
||||
<option value="" disabled>In Ordner …</option>
|
||||
{folders.map((o) => <option key={o.id} value={o.id}>{o.name}</option>)}
|
||||
<option value="__none">Kein Ordner</option>
|
||||
</select>
|
||||
<button className="mini" onClick={delSelected}>Löschen</button>
|
||||
</>}
|
||||
<button className="mini" onClick={newFolder}>Neuer Ordner</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<div className="leer"><span className="regmark" /><h3>Noch nichts erstellt</h3>
|
||||
<p>Lade im Studio ein paar Screenshots hoch — die Ergebnisse sammeln sich hier.</p></div>
|
||||
{/* Ordner-Filter */}
|
||||
<div className="ordnerleiste">
|
||||
<button className={`chip ${!folderFilter ? 'an' : ''}`} onClick={() => setFolderFilter(null)}>Alle · {items.length}</button>
|
||||
{folders.map((o) => (
|
||||
<span key={o.id} className={`chip-wrap ${folderFilter === o.id ? 'an' : ''}`}>
|
||||
<button className="chip" onClick={() => setFolderFilter(folderFilter === o.id ? null : o.id)}>
|
||||
{o.name} · {o.count}{o.picdrop_gallery ? ' →' : ''}
|
||||
</button>
|
||||
{folderFilter === o.id && <>
|
||||
<button className="chip-x" title="Ordner umbenennen" onClick={() => renameFolder(o)}>✎</button>
|
||||
<button className="chip-x del" title="Ordner löschen" onClick={() => delFolder(o.id)}>✕</button>
|
||||
</>}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="fein hinweis">Ordner gruppieren die Bibliothek und können je Ordner eine eigene Picdrop-Galerie bekommen (Pfeil = eigene Galerie hinterlegt).</div>
|
||||
|
||||
{/* Farbmarkierungen filtern */}
|
||||
<div className="ordnerleiste tagbar">
|
||||
<button className={`chip ${!tagFilter ? 'an' : ''}`} onClick={() => setTagFilter(null)}>Alle Markierungen</button>
|
||||
{TAGS.map((t) => (
|
||||
<button key={t.id} className={`chip flagchip ${tagFilter === t.id ? 'an' : ''}`} onClick={() => setTagFilter(tagFilter === t.id ? null : t.id)}>
|
||||
<span className="flag" style={{ color: t.color }}>⚑</span> {t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<div className="leer"><span className="regmark" /><h3>{folderFilter ? 'Ordner ist leer' : 'Noch nichts erstellt'}</h3>
|
||||
<p>{folderFilter ? 'Weise Bildern über das Ordner-Menü diesen Ordner zu.' : 'Lege im Studio los — die Ergebnisse sammeln sich hier.'}</p></div>
|
||||
) : (
|
||||
<div className="galerie">
|
||||
{items.map((v) => (
|
||||
<article key={v.id} className={`kachel ${sel.has(v.id) ? 'gewaehlt' : ''}`}>
|
||||
<div className="kachel-bild" onClick={() => toggle(v.id)}>
|
||||
<img src={`/api/items/${v.id}/file`} alt="" loading="lazy" />
|
||||
<span className="haken">{sel.has(v.id) ? '✓' : ''}</span>
|
||||
</div>
|
||||
<input className="name" defaultValue={v.filename || ''} onBlur={(e) => rename(v.id, e.target.value)} />
|
||||
<div className="meta">{(v.tasks || []).join(' · ')}{v.output_px ? ` · ${v.output_px}px` : ''}{v.has_alpha ? ' · transparent' : ''}{modelLabel(v.model_used) ? ` · ${modelLabel(v.model_used)}` : ''}
|
||||
{v.delivery_status === 'delivered' && <span className="dbadge ok">Picdrop ✓</span>}
|
||||
{v.delivery_status === 'pending' && <span className="dbadge">Picdrop …</span>}
|
||||
{v.delivery_status === 'failed' && <span className="dbadge err">Picdrop ✕</span>}
|
||||
</div>
|
||||
<div className="reihe knapp">
|
||||
<button className="mini" onClick={() => setCompare(v)}>Vorher/Nachher</button>
|
||||
<a className="mini" href={`/api/items/${v.id}/file?download=1`}>Laden</a>
|
||||
<select className="mini-select" value={v.folder_id || ''} onChange={(e) => setFolder(v.id, e.target.value || null)}>
|
||||
<option value="">Kein Ordner</option>
|
||||
{folders.map((o) => <option key={o.id} value={o.id}>{o.name}</option>)}
|
||||
</select>
|
||||
<button className="mini" onClick={() => del(v.id)}>✕</button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
{groups.map((g) => {
|
||||
const v = shown(g);
|
||||
const multi = g.length > 1;
|
||||
return (
|
||||
<article key={g[0].id} className={`kachel ${pick.has(v.id) ? 'gewaehlt' : ''}`}>
|
||||
<div className="kachel-bild" onClick={() => toggle(v.id)}>
|
||||
<img src={`/api/items/${v.id}/file?thumb=1`} alt="" loading="lazy" />
|
||||
<span className="haken">{pick.has(v.id) ? '✓' : ''}</span>
|
||||
{(v.mode === 'compose' || v.mode === 'generate') &&
|
||||
<span className="modetag">{v.mode === 'compose' ? 'kombiniert' : 'erzeugt'}</span>}
|
||||
{v.color_tag && <span className="flagbadge" style={{ color: tagColor(v.color_tag)! }}>⚑</span>}
|
||||
{multi && <span className="fassung">Fassung {g.indexOf(v) + 1}/{g.length}</span>}
|
||||
</div>
|
||||
|
||||
{multi && (
|
||||
<div className="varianten">
|
||||
{g.map((x, idx) => (
|
||||
<button key={x.id} className={`vthumb ${x.id === v.id ? 'an' : ''}`}
|
||||
onClick={() => setVariantSel((s) => ({ ...s, [g[0].id]: x.id }))} title={`Fassung ${idx + 1}`}>
|
||||
<img src={`/api/items/${x.id}/file?thumb=1`} alt="" loading="lazy" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<input className="name" defaultValue={v.filename || ''} key={v.id} onBlur={(e) => rename(v.id, e.target.value)} />
|
||||
<div className="meta">{(v.tasks || []).join(' · ')}{v.output_px ? ` · ${v.output_px}px` : ''}{v.has_alpha ? ' · transparent' : ''}{modelName(v.model_used) ? ` · ${modelName(v.model_used)}` : ''}
|
||||
{v.delivery_status === 'delivered' && <span className="dbadge ok">Picdrop ✓</span>}
|
||||
{v.delivery_status === 'pending' && <span className="dbadge">Picdrop …</span>}
|
||||
{v.delivery_status === 'failed' && <span className="dbadge err">Picdrop ✕</span>}
|
||||
{v.nas_status === 'mirrored' && <span className="dbadge ok">NAS ✓</span>}
|
||||
{v.nas_status === 'failed' && <span className="dbadge err">NAS ✕</span>}
|
||||
</div>
|
||||
<div className="reihe knapp">
|
||||
<div className="tagwrap">
|
||||
<button className="mini flagbtn" style={v.color_tag ? { color: tagColor(v.color_tag)!, borderColor: tagColor(v.color_tag)! } : {}}
|
||||
onClick={() => setTagMenu(tagMenu === v.id ? null : v.id)} title="Markierung">⚑</button>
|
||||
{tagMenu === v.id && (
|
||||
<div className="tagmenu" onMouseLeave={() => setTagMenu(null)}>
|
||||
<button onClick={() => setTag(v.id, null)}><span className="flag none">⚑</span> Keine</button>
|
||||
{TAGS.map((t) => (
|
||||
<button key={t.id} onClick={() => setTag(v.id, t.id)}><span className="flag" style={{ color: t.color }}>⚑</span> {t.label}</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{v.prompt_used && <button className="mini" onClick={() => setPromptOf(v)}>Prompt</button>}
|
||||
{v.mode !== 'generate' && <button className="mini" onClick={() => setCompare(v)}>Vorher/Nachher</button>}
|
||||
<button className="mini stark2" onClick={() => setSaveView(v)} title="Groß öffnen, dann sichern (iPhone: In Fotos sichern)">Laden</button>
|
||||
<button className="mini" onClick={() => reuse(v.id)} title="Ergebnis im Studio weiterbearbeiten">Weiter</button>
|
||||
<button className="mini" onClick={() => alternative(v.id)} title="Weitere Fassung erzeugen">+ Alt.</button>
|
||||
<select className="mini-select" value={v.folder_id || ''} onChange={(e) => setFolder(v.id, e.target.value || null)}>
|
||||
<option value="">Kein Ordner</option>
|
||||
{folders.map((o) => <option key={o.id} value={o.id}>{o.name}</option>)}
|
||||
</select>
|
||||
<button className="mini" onClick={() => del(v.id)}>✕</button>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -97,7 +244,30 @@ export default function LibraryApp() {
|
||||
<input type="range" min={0} max={100} defaultValue={50}
|
||||
onChange={(e) => { const o = document.getElementById('ov'); if (o) o.style.clipPath = `inset(0 0 0 ${e.target.value}%)`; }} />
|
||||
</div>
|
||||
<div className="lupe-fuss">{compare.filename} · {compare.output_px}px — Regler: links Original, rechts Ergebnis</div>
|
||||
<div className="lupe-fuss">{compare.filename} · {compare.output_px}px — Regler: links {compare.mode === 'compose' ? 'Vorlage' : 'Original'}, rechts Ergebnis</div>
|
||||
</div>
|
||||
)}
|
||||
{saveView && (
|
||||
<div className="lupe" onClick={() => setSaveView(null)}>
|
||||
<div className="savewrap" onClick={(e) => e.stopPropagation()}>
|
||||
<img className="saveimg blur" src={`/api/items/${saveView.id}/file?thumb=1`} alt="" />
|
||||
<img className="saveimg full" src={`/api/items/${saveView.id}/file?preview=1`} alt={saveView.filename || ''} />
|
||||
</div>
|
||||
<div className="save-fuss" onClick={(e) => e.stopPropagation()}>
|
||||
<span>📱 iPhone/iPad: lange auf das Bild tippen → <b>„In Fotos sichern"</b>.</span>
|
||||
<a className="mini stark" href={`/api/items/${saveView.id}/file?download=1`}>Original als Datei (.png)</a>
|
||||
<button className="mini" onClick={() => setSaveView(null)}>Schließen</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{promptOf && (
|
||||
<div className="lupe" onClick={() => setPromptOf(null)}>
|
||||
<div className="promptbox" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="pb-kopf"><b>{promptOf.filename}</b>
|
||||
<button className="mini" onClick={() => { navigator.clipboard?.writeText(promptOf.prompt_used || ''); notify('Prompt kopiert.'); }}>Kopieren</button></div>
|
||||
<pre className="pb-text">{promptOf.prompt_used}</pre>
|
||||
<div className="fein">{promptOf.model_used ? `Modell: ${modelName(promptOf.model_used)} · ` : ''}{promptOf.output_px}px</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{toast && <div className="toast">{toast}</div>}
|
||||
@@ -108,11 +278,17 @@ export default function LibraryApp() {
|
||||
|
||||
function LibStyles() {
|
||||
return <style>{`
|
||||
.kopfzeile{display:flex;justify-content:space-between;align-items:center;gap:10px;margin-bottom:14px;flex-wrap:wrap;}
|
||||
.kopfzeile{display:flex;justify-content:space-between;align-items:center;gap:10px;margin-bottom:12px;flex-wrap:wrap;}
|
||||
.reihe{display:flex;gap:8px;align-items:center;flex-wrap:wrap;}.reihe.knapp{margin-top:8px;}
|
||||
.fein{font-size:11.5px;color:var(--soft);}
|
||||
.hinweis{margin:0 0 14px;line-height:1.5;}
|
||||
.mini{background:var(--card);border:1px solid var(--line);border-radius:3px;padding:6px 11px;cursor:pointer;font-family:inherit;font-size:12.5px;color:var(--ink);text-decoration:none;}
|
||||
.hidden{display:none;}
|
||||
.ordnerleiste{display:flex;gap:7px;flex-wrap:wrap;align-items:center;margin-bottom:8px;}
|
||||
.chip-wrap{display:inline-flex;align-items:center;border-radius:20px;}
|
||||
.chip{background:var(--card);border:1px solid var(--line);border-radius:20px;padding:6px 13px;cursor:pointer;font-family:var(--font-mono);font-size:11px;letter-spacing:.04em;color:var(--soft);white-space:nowrap;}
|
||||
.chip.an,.chip-wrap.an .chip{border-color:var(--accent);background:var(--accent-bg);color:var(--accent);}
|
||||
.chip-x{border:none;background:none;color:var(--accent);cursor:pointer;font-size:12px;padding:0 4px;}
|
||||
.chip-x.del{color:var(--err);padding:0 8px 0 2px;}
|
||||
.leer{padding:60px 24px;text-align:center;display:flex;flex-direction:column;align-items:center;gap:10px;}
|
||||
.leer h3{margin:0;font-size:17px;}.leer p{margin:0;color:var(--soft);max-width:38ch;}
|
||||
.galerie{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:12px;align-items:start;}
|
||||
@@ -125,9 +301,31 @@ function LibStyles() {
|
||||
.kachel-bild:active img{transform:scale(.98);}
|
||||
.haken{position:absolute;top:6px;left:6px;width:21px;height:21px;border-radius:50%;border:1.5px solid #fff;background:rgba(22,21,15,.35);color:#fff;display:flex;align-items:center;justify-content:center;font-size:12px;}
|
||||
.kachel.gewaehlt .haken{background:var(--accent);}
|
||||
.modetag{position:absolute;top:6px;right:6px;background:rgba(59,91,219,.92);color:#fff;font-family:var(--font-mono);font-size:9px;letter-spacing:.06em;text-transform:uppercase;padding:2px 7px;border-radius:20px;}
|
||||
.flagbadge{position:absolute;top:5px;left:6px;font-size:19px;line-height:1;text-shadow:0 1px 2px rgba(0,0,0,.35);}
|
||||
.tagbar{margin:0 0 12px;}
|
||||
.flagchip .flag{font-size:13px;vertical-align:-1px;}
|
||||
.flag.none{color:var(--soft);}
|
||||
.tagwrap{position:relative;display:inline-block;}
|
||||
.flagbtn{font-size:14px;line-height:1;padding:5px 9px;}
|
||||
.tagmenu{position:absolute;bottom:calc(100% + 4px);left:0;z-index:20;background:var(--card);border:1px solid var(--line);border-radius:6px;box-shadow:0 8px 24px rgba(0,0,0,.16);padding:5px;display:flex;flex-direction:column;min-width:130px;}
|
||||
.tagmenu button{display:flex;align-items:center;gap:8px;background:none;border:none;text-align:left;padding:7px 9px;cursor:pointer;font-family:inherit;font-size:13px;border-radius:4px;color:var(--ink);}
|
||||
.tagmenu button:hover{background:var(--paper);}
|
||||
.tagmenu .flag{font-size:15px;}
|
||||
.savewrap{position:relative;max-width:94vw;max-height:78vh;display:flex;}
|
||||
.saveimg{max-width:94vw;max-height:78vh;border-radius:4px;display:block;}
|
||||
.saveimg.blur{filter:blur(8px);position:absolute;inset:0;width:100%;height:100%;object-fit:contain;}
|
||||
.saveimg.full{position:relative;z-index:1;}
|
||||
.save-fuss{display:flex;flex-direction:column;align-items:center;gap:10px;color:#EAEAE3;font-size:13.5px;text-align:center;max-width:90vw;}
|
||||
.fassung{position:absolute;bottom:6px;right:6px;background:rgba(22,21,15,.7);color:#fff;font-family:var(--font-mono);font-size:9px;padding:2px 7px;border-radius:20px;}
|
||||
.varianten{display:flex;gap:5px;margin-top:6px;overflow-x:auto;}
|
||||
.vthumb{flex:0 0 auto;width:40px;height:40px;border:1px solid var(--line);border-radius:3px;overflow:hidden;padding:0;background:none;cursor:pointer;}
|
||||
.vthumb.an{border-color:var(--accent);box-shadow:0 0 0 1px var(--accent);}
|
||||
.vthumb img{width:100%;height:100%;object-fit:cover;display:block;}
|
||||
.name{margin-top:8px;width:100%;font-size:12.5px;padding:6px 8px;font-family:var(--font-mono);border:1px solid var(--line);border-radius:3px;background:#fff;}
|
||||
.meta{font-size:11px;color:var(--soft);margin-top:5px;}
|
||||
.mini.stark{background:var(--accent);border-color:var(--accent);color:#fff;font-weight:600;}
|
||||
.mini.stark2{border-color:var(--accent);color:var(--accent);font-weight:600;}
|
||||
.dbadge{display:inline-block;margin-left:6px;font-family:var(--font-mono);font-size:9px;letter-spacing:.08em;text-transform:uppercase;background:var(--paper);color:var(--soft);padding:1px 6px;border-radius:20px;}
|
||||
.dbadge.ok{background:var(--accent-bg);color:var(--accent);}
|
||||
.dbadge.err{color:var(--err);}
|
||||
@@ -138,6 +336,9 @@ function LibStyles() {
|
||||
.vergleich .oben{position:absolute;inset:0;clip-path:inset(0 0 0 50%);}
|
||||
.vergleich input[type=range]{position:absolute;left:0;right:0;bottom:-34px;width:100%;}
|
||||
.lupe-fuss{color:#EAEAE3;font-family:var(--font-mono);font-size:12px;margin-top:30px;text-align:center;}
|
||||
.promptbox{background:var(--card);border-radius:var(--radius);max-width:min(92vw,620px);width:100%;padding:16px;display:flex;flex-direction:column;gap:10px;}
|
||||
.pb-kopf{display:flex;justify-content:space-between;align-items:center;gap:10px;}
|
||||
.pb-text{white-space:pre-wrap;word-break:break-word;font-family:var(--font-mono);font-size:12.5px;line-height:1.5;background:var(--paper);border:1px solid var(--line);border-radius:4px;padding:12px;max-height:50vh;overflow:auto;margin:0;}
|
||||
.toast{position:fixed;left:50%;bottom:26px;transform:translateX(-50%);background:var(--ink);color:#FBFBF7;padding:10px 18px;border-radius:3px;font-size:13.5px;z-index:60;}
|
||||
`}</style>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,808 @@
|
||||
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import { PAPERS, PHOTO_SIZES, parseSizeMm, labelMm, mmToPx, paperById, photoById } from '../lib/paper';
|
||||
import {
|
||||
layout, cutMarks, recommendedGap, dpiCheck,
|
||||
type SheetSpec, type PlaceSpec, type MarkMode,
|
||||
} from '../lib/printlayout';
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
Druck — Bilder ohne KI auf exakte Maße bringen und mehrere davon mit
|
||||
Schnittmarken auf einen Bogen setzen. Alles rechnet in Millimetern;
|
||||
Pixel entstehen erst beim Rendern auf dem Server.
|
||||
-------------------------------------------------------------------------- */
|
||||
|
||||
type SrcRef = { kind: 'upload'; path: string } | { kind: 'item'; id: string };
|
||||
interface CropRel { x: number; y: number; w: number; h: number }
|
||||
|
||||
interface Cell {
|
||||
id: string;
|
||||
name: string;
|
||||
preview: string; // Data-URL (Upload) oder /api/items/…/file
|
||||
src?: SrcRef;
|
||||
uploading?: boolean;
|
||||
error?: string;
|
||||
natW: number; natH: number; // echte Quellpixel (für den dpi-Hinweis)
|
||||
sizeId: string; // Schlüssel aus PHOTO_SIZES oder 'custom'
|
||||
customSize: string;
|
||||
landscape: boolean;
|
||||
count: number;
|
||||
allowRotate: boolean;
|
||||
crop: CropRel;
|
||||
}
|
||||
|
||||
const uid = () => (crypto.randomUUID ? crypto.randomUUID() : String(Math.random()).slice(2));
|
||||
|
||||
/** Größter Ausschnitt mit dem Zielseitenverhältnis, mittig („Bild füllen"). */
|
||||
function coverCrop(natW: number, natH: number, aspect: number, around?: CropRel): CropRel {
|
||||
const imgA = natW / natH;
|
||||
let w = 1, h = 1;
|
||||
if (imgA > aspect) { h = 1; w = (aspect / imgA); } else { w = 1; h = (imgA / aspect); }
|
||||
const cx = around ? around.x + around.w / 2 : 0.5;
|
||||
const cy = around ? around.y + around.h / 2 : 0.5;
|
||||
return clampCrop({ x: cx - w / 2, y: cy - h / 2, w, h });
|
||||
}
|
||||
function clampCrop(c: CropRel): CropRel {
|
||||
const w = Math.min(1, Math.max(0.02, c.w));
|
||||
const h = Math.min(1, Math.max(0.02, c.h));
|
||||
return { w, h, x: Math.min(1 - w, Math.max(0, c.x)), y: Math.min(1 - h, Math.max(0, c.y)) };
|
||||
}
|
||||
|
||||
const sizeOfCell = (c: Cell): { w: number; h: number } | null => {
|
||||
const base = c.sizeId === 'custom' ? parseSizeMm(c.customSize) : photoById(c.sizeId);
|
||||
if (!base) return null;
|
||||
const s = { w: base.w, h: base.h };
|
||||
return c.landscape ? { w: s.h, h: s.w } : s;
|
||||
};
|
||||
|
||||
export default function PrintApp() {
|
||||
const [cells, setCells] = useState<Cell[]>([]);
|
||||
const [paperId, setPaperId] = useState('A4');
|
||||
const [paperCustom, setPaperCustom] = useState('');
|
||||
const [paperLandscape, setPaperLandscape] = useState(false);
|
||||
const [marginMm, setMarginMm] = useState(5);
|
||||
const [autoGap, setAutoGap] = useState(true);
|
||||
const [gapMm, setGapMm] = useState(12);
|
||||
const [bleedMm, setBleedMm] = useState(0);
|
||||
const [marks, setMarks] = useState<MarkMode>('corner');
|
||||
const [markLen, setMarkLen] = useState(4);
|
||||
const [markOff, setMarkOff] = useState(3);
|
||||
const [dpi, setDpi] = useState(300);
|
||||
const [ext, setExt] = useState<'jpg' | 'png'>('jpg');
|
||||
const [center, setCenter] = useState(true);
|
||||
const [footer, setFooter] = useState(true);
|
||||
const [deliverTo, setDeliverTo] = useState<string>(''); // '' = nur herunterladen
|
||||
const [gallery, setGallery] = useState('');
|
||||
const [targets, setTargets] = useState<any[]>([]);
|
||||
|
||||
const [editing, setEditing] = useState<string | null>(null);
|
||||
const [picker, setPicker] = useState(false);
|
||||
const [presets, setPresets] = useState<any[]>([]);
|
||||
const [presetSel, setPresetSel] = useState('');
|
||||
const [busy, setBusy] = useState<null | 'pdf' | 'single'>(null);
|
||||
const [toast, setToast] = useState<string | null>(null);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const notify = (t: string) => { setToast(t); setTimeout(() => setToast(null), 3000); };
|
||||
|
||||
/* ---------------- Bogen ---------------- */
|
||||
const paper = useMemo(() => {
|
||||
const p = paperId === 'custom' ? parseSizeMm(paperCustom) : paperById(paperId);
|
||||
if (!p) return null;
|
||||
return paperLandscape ? { w: p.h, h: p.w } : { w: p.w, h: p.h };
|
||||
}, [paperId, paperCustom, paperLandscape]);
|
||||
|
||||
const gapEff = autoGap ? Math.max(recommendedGap(marks, bleedMm, markLen, markOff), 2) : gapMm;
|
||||
|
||||
const sheet: SheetSpec | null = paper
|
||||
? { wMm: paper.w, hMm: paper.h, marginMm, gapMm: gapEff, bleedMm, center }
|
||||
: null;
|
||||
|
||||
const specs: PlaceSpec[] = useMemo(() => cells.flatMap((c) => {
|
||||
const s = sizeOfCell(c);
|
||||
if (!s || c.error || !c.src) return [];
|
||||
return [{ id: c.id, wMm: s.w, hMm: s.h, count: c.count, allowRotate: c.allowRotate }];
|
||||
}), [cells]);
|
||||
|
||||
const plan = useMemo(() => (sheet && specs.length ? layout(specs, sheet) : null), [sheet, specs]);
|
||||
const marksPerPage = useMemo(() => (plan && sheet)
|
||||
? plan.pages.map((pg) => cutMarks(pg, sheet, { mode: marks, lengthMm: markLen, offsetMm: markOff, bleedMm }))
|
||||
: [], [plan, sheet, marks, markLen, markOff, bleedMm]);
|
||||
|
||||
/* ---------------- Upload ---------------- */
|
||||
const addFiles = useCallback(async (files: FileList | File[]) => {
|
||||
const arr = Array.from(files)
|
||||
.filter((f) => f.type.startsWith('image/') || /\.(heic|heif)$/i.test(f.name))
|
||||
.slice(0, 30);
|
||||
for (const f of arr) {
|
||||
const id = uid();
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const url = reader.result as string;
|
||||
const probe = new Image();
|
||||
probe.onload = () => setCells((p) => p.map((x) => x.id === id
|
||||
? { ...x, preview: url, natW: probe.naturalWidth, natH: probe.naturalHeight,
|
||||
crop: coverCrop(probe.naturalWidth, probe.naturalHeight, aspectOf(x)) }
|
||||
: x));
|
||||
probe.src = url;
|
||||
};
|
||||
reader.readAsDataURL(f);
|
||||
setCells((p) => [...p, newCell(id, f.name)]);
|
||||
const fd = new FormData(); fd.append('files', f);
|
||||
try {
|
||||
const j = await fetch('/api/uploads', { method: 'POST', body: fd }).then((r) => r.json());
|
||||
const info = j.files?.[0];
|
||||
setCells((p) => p.map((x) => x.id === id ? {
|
||||
...x, uploading: false, error: info?.error,
|
||||
src: info?.source_path ? { kind: 'upload', path: info.source_path } : undefined,
|
||||
natW: info?.width || x.natW, natH: info?.height || x.natH,
|
||||
} : x));
|
||||
} catch {
|
||||
setCells((p) => p.map((x) => x.id === id ? { ...x, uploading: false, error: 'Upload fehlgeschlagen' } : x));
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onPaste = (e: ClipboardEvent) => {
|
||||
const imgs = Array.from(e.clipboardData?.items || []).filter((i) => i.type.startsWith('image/'));
|
||||
if (imgs.length) addFiles(imgs.map((i) => i.getAsFile()!).filter(Boolean));
|
||||
};
|
||||
window.addEventListener('paste', onPaste);
|
||||
return () => window.removeEventListener('paste', onPaste);
|
||||
}, [addFiles]);
|
||||
|
||||
useEffect(() => {
|
||||
loadPresets();
|
||||
fetch('/api/delivery-targets').then((r) => r.json()).then((j) => setTargets(j.targets || [])).catch(() => {});
|
||||
}, []);
|
||||
const loadPresets = () => fetch('/api/print/presets').then((r) => r.json())
|
||||
.then((j) => setPresets(j.presets || [])).catch(() => {});
|
||||
|
||||
const addFromLibrary = (it: any) => {
|
||||
const [w, h] = String(it.output_px || '').split(/[x×]/).map((n: string) => parseInt(n, 10) || 0);
|
||||
const id = uid();
|
||||
const c: Cell = {
|
||||
...newCell(id, it.filename || 'Bild'),
|
||||
uploading: false,
|
||||
src: { kind: 'item', id: it.id },
|
||||
preview: `/api/items/${it.id}/file?preview=1`,
|
||||
natW: w || 2000, natH: h || 3000,
|
||||
};
|
||||
c.crop = coverCrop(c.natW, c.natH, aspectOf(c));
|
||||
setCells((p) => [...p, c]);
|
||||
};
|
||||
|
||||
const patch = (id: string, up: Partial<Cell>) => setCells((p) => p.map((c) => {
|
||||
if (c.id !== id) return c;
|
||||
const next = { ...c, ...up };
|
||||
// Formatwechsel → Ausschnitt auf das neue Seitenverhältnis nachziehen.
|
||||
if (up.sizeId !== undefined || up.customSize !== undefined || up.landscape !== undefined) {
|
||||
const a = aspectOf(next);
|
||||
if (a) next.crop = coverCrop(next.natW, next.natH, a, c.crop);
|
||||
}
|
||||
return next;
|
||||
}));
|
||||
|
||||
/* ---------------- Ausgabe ---------------- */
|
||||
const body = () => ({
|
||||
paper: paperId === 'custom' ? { size: paperCustom } : { id: paperId },
|
||||
landscape: paperLandscape,
|
||||
marginMm, gapMm: gapEff, bleedMm, center, dpi, ext, footer,
|
||||
...(deliverTo ? { deliver: { target: deliverTo === 'picdrop' ? '' : deliverTo, gallery: gallery.trim() || undefined } } : {}),
|
||||
marks: { mode: marks, lengthMm: markLen, offsetMm: markOff },
|
||||
cells: cells.filter((c) => c.src && !c.error && sizeOfCell(c)).map((c) => {
|
||||
const s = sizeOfCell(c)!;
|
||||
return { id: c.id, src: c.src, crop: c.crop, wMm: s.w, hMm: s.h, count: c.count, allowRotate: c.allowRotate };
|
||||
}),
|
||||
});
|
||||
|
||||
const makePdf = async () => {
|
||||
if (!plan?.pages.length) return;
|
||||
setBusy('pdf');
|
||||
try {
|
||||
const res = await fetch('/api/print/sheet', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body()),
|
||||
});
|
||||
if (!res.ok) { notify((await res.json().catch(() => ({}))).error || 'Fehlgeschlagen.'); return; }
|
||||
const blob = await res.blob();
|
||||
download(blob, `klarbild-druckbogen-${new Date().toISOString().slice(0, 10)}.pdf`);
|
||||
const dmsgRaw = res.headers.get('X-Klarbild-Delivery-Msg');
|
||||
const dmsg = dmsgRaw ? ` ${decodeURIComponent(dmsgRaw)}` : '';
|
||||
notify(`PDF erzeugt · ${res.headers.get('X-Klarbild-Pages') || '?'} Bogen. Beim Drucken „Tatsächliche Größe / 100 %" wählen.${dmsg}`);
|
||||
} catch { notify('Netzwerkfehler.'); } finally { setBusy(null); }
|
||||
};
|
||||
|
||||
const makeSingle = async (c: Cell) => {
|
||||
const s = sizeOfCell(c); if (!s || !c.src) return;
|
||||
setBusy('single');
|
||||
try {
|
||||
const res = await fetch('/api/print/single', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ src: c.src, crop: c.crop, wMm: s.w, hMm: s.h, dpi, ext, name: c.name }),
|
||||
});
|
||||
if (!res.ok) { notify((await res.json().catch(() => ({}))).error || 'Fehlgeschlagen.'); return; }
|
||||
const blob = await res.blob();
|
||||
download(blob, `${(c.name || 'bild').replace(/\.[^.]+$/, '')}_${labelMm(s.w, s.h).replace(/[^0-9]+/g, 'x')}.${ext}`);
|
||||
const real = res.headers.get('X-Klarbild-Real-Dpi');
|
||||
notify(res.headers.get('X-Klarbild-Dpi-Ok') === '0'
|
||||
? `Heruntergeladen — Achtung: die Quelle reicht nur für ca. ${real} dpi.`
|
||||
: `Heruntergeladen · ${res.headers.get('X-Klarbild-Px')} px bei ${dpi} dpi.`);
|
||||
} catch { notify('Netzwerkfehler.'); } finally { setBusy(null); }
|
||||
};
|
||||
|
||||
const savePreset = async () => {
|
||||
const name = prompt('Name der Vorlage? (z. B. „Kita-Satz Felix")');
|
||||
if (!name?.trim()) return;
|
||||
const config = {
|
||||
paperId, paperCustom, paperLandscape, marginMm, autoGap, gapMm, bleedMm,
|
||||
marks, markLen, markOff, dpi, ext, center, footer, deliverTo, gallery,
|
||||
formats: cells.map((c) => ({ sizeId: c.sizeId, customSize: c.customSize, landscape: c.landscape, count: c.count, allowRotate: c.allowRotate })),
|
||||
};
|
||||
await fetch('/api/print/presets', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: name.trim(), config }),
|
||||
});
|
||||
notify('Vorlage gespeichert.'); loadPresets();
|
||||
};
|
||||
|
||||
const applyPreset = (p: any) => {
|
||||
const c = p?.config; if (!c) return;
|
||||
setPaperId(c.paperId ?? 'A4'); setPaperCustom(c.paperCustom ?? ''); setPaperLandscape(!!c.paperLandscape);
|
||||
setMarginMm(c.marginMm ?? 5); setAutoGap(c.autoGap !== false); setGapMm(c.gapMm ?? 12);
|
||||
setBleedMm(c.bleedMm ?? 0); setMarks(c.marks ?? 'corner'); setMarkLen(c.markLen ?? 4); setMarkOff(c.markOff ?? 3);
|
||||
setDpi(c.dpi ?? 300); setExt(c.ext === 'png' ? 'png' : 'jpg'); setCenter(c.center !== false); setFooter(c.footer !== false);
|
||||
setDeliverTo(c.deliverTo ?? ''); setGallery(c.gallery ?? '');
|
||||
// Formate anwenden. Der typische Fall ist ein Bild und mehrere Größen
|
||||
// („Kita-Satz": 1× 13×18, 2× 9×13, 8× Passbild) — dann wird das Bild geklont.
|
||||
if (Array.isArray(c.formats) && c.formats.length) {
|
||||
setCells((prev) => {
|
||||
if (!prev.length) return prev;
|
||||
const apply = (cell: Cell, f: any): Cell => {
|
||||
const next = { ...cell, id: cell.id, sizeId: f.sizeId, customSize: f.customSize || '',
|
||||
landscape: !!f.landscape, count: f.count || 1, allowRotate: f.allowRotate !== false };
|
||||
const a = aspectOf(next); if (a) next.crop = coverCrop(next.natW, next.natH, a, cell.crop);
|
||||
return next;
|
||||
};
|
||||
if (prev.length === 1 && c.formats.length > 1)
|
||||
return c.formats.map((f: any, i: number) => apply({ ...prev[0], id: i === 0 ? prev[0].id : uid() }, f));
|
||||
return prev.map((cell, i) => apply(cell, c.formats[Math.min(i, c.formats.length - 1)]));
|
||||
});
|
||||
}
|
||||
notify(Array.isArray(c.formats) && c.formats.length > 1
|
||||
? 'Vorlage geladen — das Bild wurde für jedes Format übernommen.'
|
||||
: 'Vorlage geladen.');
|
||||
};
|
||||
|
||||
const deletePreset = async (id: string) => {
|
||||
if (!id || !confirm('Vorlage löschen?')) return;
|
||||
await fetch(`/api/print/presets?id=${id}`, { method: 'DELETE' });
|
||||
setPresetSel(''); loadPresets();
|
||||
};
|
||||
|
||||
const editCell = cells.find((c) => c.id === editing) || null;
|
||||
const ready = cells.filter((c) => c.src && !c.error && sizeOfCell(c));
|
||||
const totalPieces = ready.reduce((n, c) => n + c.count, 0);
|
||||
|
||||
return (
|
||||
<div className="druck">
|
||||
{/* ---------- 1 · Bilder ---------- */}
|
||||
<section className="karte">
|
||||
<div className="kopfzeile">
|
||||
<span className="mono-label">1 · Bilder</span>
|
||||
<div className="kopf-tools">
|
||||
<button className="link" onClick={() => setPicker(true)}>Aus Bibliothek</button>
|
||||
{cells.length > 0 && <button className="link" onClick={() => setCells([])}>Alle entfernen</button>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="buehne">
|
||||
{cells.length === 0 ? (
|
||||
<div className={`drop ${dragging ? 'drag' : ''}`}
|
||||
onClick={() => fileRef.current?.click()}
|
||||
onDragOver={(e) => { e.preventDefault(); setDragging(true); }}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={(e) => { e.preventDefault(); setDragging(false); addFiles(e.dataTransfer.files); }}>
|
||||
<span className="regmark" />
|
||||
<p>Bilder hierher ziehen, <span className="kbd">⌘V</span> einfügen oder klicken.<br />
|
||||
Kein KI-Schritt — nur zuschneiden, skalieren, setzen.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="zellen">
|
||||
{cells.map((c) => <CellCard key={c.id} cell={c} dpi={dpi}
|
||||
onPatch={(u) => patch(c.id, u)}
|
||||
onEdit={() => setEditing(c.id)}
|
||||
onSingle={() => makeSingle(c)}
|
||||
onRemove={() => setCells((p) => p.filter((x) => x.id !== c.id))} />)}
|
||||
<button className="zelle-add" onClick={() => fileRef.current?.click()}>+ Bild</button>
|
||||
</div>
|
||||
)}
|
||||
<input ref={fileRef} type="file" accept="image/*,.heic,.heif" multiple hidden
|
||||
onChange={(e) => e.target.files && addFiles(e.target.files)} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="raster">
|
||||
{/* ---------- 2 · Bogen ---------- */}
|
||||
<section className="karte">
|
||||
<div className="kopfzeile"><span className="mono-label">2 · Bogen & Schnitt</span>
|
||||
<button className="link" onClick={savePreset}>Als Vorlage sichern</button>
|
||||
</div>
|
||||
<div className="steuer">
|
||||
<div className="feld">
|
||||
<label>Vorlagen</label>
|
||||
<div className="reihe">
|
||||
<select className="select" value={presetSel}
|
||||
onChange={(e) => { setPresetSel(e.target.value); applyPreset(presets.find((p) => p.id === e.target.value)); }}>
|
||||
<option value="">Vorlage wählen …</option>
|
||||
{presets.map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||||
</select>
|
||||
{presetSel && <button className="pbtn" onClick={() => deletePreset(presetSel)}>✕</button>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="feld">
|
||||
<label>Papierformat</label>
|
||||
<select className="select" value={paperId} onChange={(e) => setPaperId(e.target.value)}>
|
||||
{groupBy(PAPERS, 'group').map(([g, list]) => (
|
||||
<optgroup key={g} label={g}>
|
||||
{list.map((p) => <option key={p.id} value={p.id}>{p.label} · {p.w} × {p.h} mm</option>)}
|
||||
</optgroup>
|
||||
))}
|
||||
<option value="custom">Eigenes Papiermaß …</option>
|
||||
</select>
|
||||
{paperId === 'custom' && (
|
||||
<input className="input" value={paperCustom} onChange={(e) => setPaperCustom(e.target.value)}
|
||||
placeholder="z. B. 32,9x48,3 (cm) oder 329x483mm" />
|
||||
)}
|
||||
<div className="schalter">
|
||||
<button className={!paperLandscape ? 'an' : ''} onClick={() => setPaperLandscape(false)}>Hoch</button>
|
||||
<button className={paperLandscape ? 'an' : ''} onClick={() => setPaperLandscape(true)}>Quer</button>
|
||||
</div>
|
||||
{paper && <div className="fein">Bogen {labelMm(paper.w, paper.h)} — die PDF-Seite hat exakt dieses Maß.</div>}
|
||||
</div>
|
||||
|
||||
<div className="feld">
|
||||
<label>Schnitthilfen</label>
|
||||
<div className="schalter">
|
||||
<button className={marks === 'none' ? 'an' : ''} onClick={() => setMarks('none')}>Keine</button>
|
||||
<button className={marks === 'corner' ? 'an' : ''} onClick={() => setMarks('corner')}>Eckmarken</button>
|
||||
<button className={marks === 'grid' ? 'an' : ''} onClick={() => setMarks('grid')}>Durchgehend</button>
|
||||
</div>
|
||||
<div className="fein">
|
||||
{marks === 'corner' ? 'Feine Marken an den vier Ecken jedes Bildes, außerhalb des Endformats — wie in Photoshop/InDesign.'
|
||||
: marks === 'grid' ? 'Durchgehende Hilfslinien über den ganzen Bogen — für Schneidelineal und Schlagschere.'
|
||||
: 'Keine Linien — Kanten selbst anlegen.'}
|
||||
</div>
|
||||
{marks === 'corner' && (
|
||||
<div className="reihe zwei">
|
||||
<label className="mini">Länge (mm)
|
||||
<input className="input" type="number" min={1} max={20} step={0.5} value={markLen}
|
||||
onChange={(e) => setMarkLen(Number(e.target.value))} /></label>
|
||||
<label className="mini">Abstand (mm)
|
||||
<input className="input" type="number" min={0} max={20} step={0.5} value={markOff}
|
||||
onChange={(e) => setMarkOff(Number(e.target.value))} /></label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="feld">
|
||||
<label>Ränder & Abstände</label>
|
||||
<div className="schalter zart">
|
||||
<button className={marginMm === 0 ? 'an' : ''} onClick={() => setMarginMm(0)}>Randlos</button>
|
||||
<button className={marginMm === 5 ? 'an' : ''} onClick={() => setMarginMm(5)}>Standard 5 mm</button>
|
||||
<button className={marginMm === 10 ? 'an' : ''} onClick={() => setMarginMm(10)}>Sicher 10 mm</button>
|
||||
</div>
|
||||
<div className="reihe zwei">
|
||||
<label className="mini">Rand zum Papier (mm)
|
||||
<input className="input" type="number" min={0} max={40} step={0.5} value={marginMm}
|
||||
onChange={(e) => setMarginMm(Number(e.target.value))} /></label>
|
||||
<label className="mini">Beschnittzugabe (mm)
|
||||
<input className="input" type="number" min={0} max={10} step={0.5} value={bleedMm}
|
||||
onChange={(e) => setBleedMm(Number(e.target.value))} /></label>
|
||||
</div>
|
||||
<label className="check">
|
||||
<input type="checkbox" checked={autoGap} onChange={(e) => setAutoGap(e.target.checked)} />
|
||||
<span>Abstand automatisch ({gapEff.toFixed(1)} mm — passend zu den Marken)</span>
|
||||
</label>
|
||||
{!autoGap && (
|
||||
<input className="input" type="number" min={0} max={60} step={0.5} value={gapMm}
|
||||
onChange={(e) => setGapMm(Number(e.target.value))} />
|
||||
)}
|
||||
<label className="check">
|
||||
<input type="checkbox" checked={center} onChange={(e) => setCenter(e.target.checked)} />
|
||||
<span>Auf dem Bogen zentrieren</span>
|
||||
</label>
|
||||
<div className="fein">Randlos druckbare Drucker: Rand 0 setzen. Sonst 3–5 mm — sonst schneidet der Drucker Marken weg.</div>
|
||||
</div>
|
||||
|
||||
<div className="feld">
|
||||
<label>Qualität</label>
|
||||
<div className="reihe zwei">
|
||||
<label className="mini">Auflösung (dpi)
|
||||
<select className="select" value={dpi} onChange={(e) => setDpi(Number(e.target.value))}>
|
||||
<option value={150}>150 — Entwurf</option>
|
||||
<option value={300}>300 — Fotodruck (Standard)</option>
|
||||
<option value={600}>600 — sehr fein</option>
|
||||
</select></label>
|
||||
<label className="mini">Dateiformat im PDF
|
||||
<select className="select" value={ext} onChange={(e) => setExt(e.target.value as any)}>
|
||||
<option value="jpg">JPG — kleiner</option>
|
||||
<option value="png">PNG — verlustfrei</option>
|
||||
</select></label>
|
||||
</div>
|
||||
<label className="check">
|
||||
<input type="checkbox" checked={footer} onChange={(e) => setFooter(e.target.checked)} />
|
||||
<span>Fußzeile mit Maßen & Druckhinweis</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="feld">
|
||||
<label>Wohin?</label>
|
||||
<select className="select" value={deliverTo} onChange={(e) => setDeliverTo(e.target.value)}>
|
||||
<option value="">Nur herunterladen</option>
|
||||
<option value="picdrop">Zusätzlich an Picdrop (Standard)</option>
|
||||
<option value="nas">Zusätzlich aufs NAS</option>
|
||||
{targets.map((t) => <option key={t.id} value={t.id}>Zusätzlich an „{t.name}"</option>)}
|
||||
</select>
|
||||
{deliverTo && (
|
||||
<input className="input" style={{ marginTop: 7 }} value={gallery} onChange={(e) => setGallery(e.target.value)}
|
||||
placeholder="Galerie/Ordner (leer = Standard)" />
|
||||
)}
|
||||
<div className="fein">Der fertige Bogen wird als PDF geladen — auf Wunsch zusätzlich ins gewohnte Ziel gelegt.</div>
|
||||
</div>
|
||||
|
||||
<button className="knopf" disabled={!plan?.pages.length || busy === 'pdf'} onClick={makePdf}>
|
||||
{busy === 'pdf' ? <><span className="spin" />PDF wird gebaut …</> : 'Druck-PDF erzeugen'}
|
||||
</button>
|
||||
<div className="fein mitte">Im Druckdialog <b>„Tatsächliche Größe" / 100 %</b> wählen — nicht „an Seitengröße anpassen".</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ---------- 3 · Vorschau ---------- */}
|
||||
<section className="karte">
|
||||
<div className="kopfzeile">
|
||||
<span className="mono-label">3 · Vorschau</span>
|
||||
{plan && <span className="fein nopad">{plan.pages.length} Bogen · {totalPieces} Bilder</span>}
|
||||
</div>
|
||||
<div className="vorschau">
|
||||
{!sheet ? <p className="leer">Bitte ein gültiges Papiermaß angeben.</p>
|
||||
: !plan || !plan.pages.length ? <p className="leer">Noch nichts zu zeigen — Bilder hinzufügen und Formate wählen.</p>
|
||||
: plan.pages.map((pg, i) => (
|
||||
<div key={i} className="bogen-halter">
|
||||
<div className="bogen" style={{ aspectRatio: `${sheet.wMm} / ${sheet.hMm}` }}>
|
||||
{pg.placements.map((p, k) => {
|
||||
const c = cells.find((x) => x.id === p.specId);
|
||||
if (!c) return null;
|
||||
return (
|
||||
<div key={k} className="slot" style={{
|
||||
left: `${(p.x / sheet.wMm) * 100}%`, top: `${(p.y / sheet.hMm) * 100}%`,
|
||||
width: `${(p.w / sheet.wMm) * 100}%`, height: `${(p.h / sheet.hMm) * 100}%`,
|
||||
}}>
|
||||
<div className="rotor" style={p.rotated ? {
|
||||
width: `${(p.h / p.w) * 100}%`, height: `${(p.w / p.h) * 100}%`,
|
||||
transform: 'translate(-50%, -50%) rotate(90deg)',
|
||||
} : undefined}>
|
||||
<img src={c.preview} alt="" style={cropStyle(c.crop)} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<svg className="marken" viewBox={`0 0 ${sheet.wMm} ${sheet.hMm}`} preserveAspectRatio="none">
|
||||
{(marksPerPage[i] || []).map((l, k) => (
|
||||
<line key={k} x1={l.x1} y1={l.y1} x2={l.x2} y2={l.y2}
|
||||
stroke="#16150F" strokeWidth={0.25} vectorEffect="non-scaling-stroke" />
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
<div className="bogen-fuss">Bogen {i + 1} · {pg.placements.length} Bilder</div>
|
||||
</div>
|
||||
))}
|
||||
{plan?.unplaced?.length ? (
|
||||
<p className="warnung">Nicht platzierbar: {plan.unplaced.map((u) => {
|
||||
const c = cells.find((x) => x.id === u.specId);
|
||||
return `${c?.name || u.specId} (${u.reason})`;
|
||||
}).join(', ')}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{editCell && <CropModal cell={editCell} onClose={() => setEditing(null)}
|
||||
onChange={(crop) => patch(editCell.id, { crop })} />}
|
||||
{picker && <LibraryPicker onClose={() => setPicker(false)} onPick={(it) => { addFromLibrary(it); setPicker(false); }} />}
|
||||
{toast && <div className="toast">{toast}</div>}
|
||||
<PrintStyles />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- Bausteine ---------------- */
|
||||
|
||||
function newCell(id: string, name: string): Cell {
|
||||
return {
|
||||
id, name, preview: '', uploading: true, natW: 1000, natH: 1500,
|
||||
sizeId: 'S10x15', customSize: '', landscape: false, count: 1, allowRotate: true,
|
||||
crop: { x: 0, y: 0, w: 1, h: 1 },
|
||||
};
|
||||
}
|
||||
function aspectOf(c: Cell): number {
|
||||
const s = sizeOfCell(c);
|
||||
return s ? s.w / s.h : 1;
|
||||
}
|
||||
function cropStyle(c: CropRel): React.CSSProperties {
|
||||
return {
|
||||
position: 'absolute',
|
||||
width: `${100 / c.w}%`, height: `${100 / c.h}%`,
|
||||
left: `${(-c.x / c.w) * 100}%`, top: `${(-c.y / c.h) * 100}%`,
|
||||
objectFit: 'fill',
|
||||
};
|
||||
}
|
||||
function groupBy<T extends { group: string }>(list: T[], _k: 'group'): [string, T[]][] {
|
||||
const m = new Map<string, T[]>();
|
||||
for (const x of list) { if (!m.has(x.group)) m.set(x.group, []); m.get(x.group)!.push(x); }
|
||||
return [...m.entries()];
|
||||
}
|
||||
function download(blob: Blob, name: string) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url; a.download = name; document.body.appendChild(a); a.click(); a.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 4000);
|
||||
}
|
||||
|
||||
function CellCard({ cell, dpi, onPatch, onEdit, onSingle, onRemove }: {
|
||||
cell: Cell; dpi: number;
|
||||
onPatch: (u: Partial<Cell>) => void; onEdit: () => void; onSingle: () => void; onRemove: () => void;
|
||||
}) {
|
||||
const s = sizeOfCell(cell);
|
||||
const check = s ? dpiCheck(cell.crop.w * cell.natW, s.w, dpi) : null;
|
||||
return (
|
||||
<div className={`zelle ${cell.error ? 'err' : ''}`}>
|
||||
<button className="zelle-x" onClick={onRemove} aria-label="Entfernen">✕</button>
|
||||
<div className="zelle-bild" style={{ aspectRatio: s ? `${s.w} / ${s.h}` : '3 / 4' }}
|
||||
onClick={() => !cell.uploading && onEdit()}>
|
||||
{cell.preview
|
||||
? <img src={cell.preview} alt="" style={cropStyle(cell.crop)} />
|
||||
: <span className="lade" />}
|
||||
{!cell.uploading && <span className="zelle-lupe">Zuschnitt</span>}
|
||||
</div>
|
||||
<div className="zelle-steuer">
|
||||
<select className="select mini-select" value={cell.sizeId} onChange={(e) => onPatch({ sizeId: e.target.value })}>
|
||||
{groupBy(PHOTO_SIZES, 'group').map(([g, list]) => (
|
||||
<optgroup key={g} label={g}>
|
||||
{list.map((p) => <option key={p.id} value={p.id}>{p.label}</option>)}
|
||||
</optgroup>
|
||||
))}
|
||||
<option value="custom">Eigenes Maß …</option>
|
||||
</select>
|
||||
{cell.sizeId === 'custom' && (
|
||||
<input className="input mini-input" value={cell.customSize} onChange={(e) => onPatch({ customSize: e.target.value })}
|
||||
placeholder="12x15 · 35x45mm · 4:3/15" />
|
||||
)}
|
||||
<div className="zelle-reihe">
|
||||
<div className="schalter winzig">
|
||||
<button className={!cell.landscape ? 'an' : ''} onClick={() => onPatch({ landscape: false })}>Hoch</button>
|
||||
<button className={cell.landscape ? 'an' : ''} onClick={() => onPatch({ landscape: true })}>Quer</button>
|
||||
</div>
|
||||
<div className="anzahl">
|
||||
<button onClick={() => onPatch({ count: Math.max(1, cell.count - 1) })}>−</button>
|
||||
<input type="number" min={1} max={200} value={cell.count}
|
||||
onChange={(e) => onPatch({ count: Math.min(200, Math.max(1, Number(e.target.value) || 1)) })} />
|
||||
<button onClick={() => onPatch({ count: Math.min(200, cell.count + 1) })}>+</button>
|
||||
</div>
|
||||
</div>
|
||||
<label className="check winzig">
|
||||
<input type="checkbox" checked={cell.allowRotate} onChange={(e) => onPatch({ allowRotate: e.target.checked })} />
|
||||
<span>darf gedreht platziert werden</span>
|
||||
</label>
|
||||
{cell.error ? <div className="fein warn">{cell.error}</div> : s && (
|
||||
<div className={`fein ${check && !check.ok ? 'warn' : ''}`}>
|
||||
{labelMm(s.w, s.h)} = {mmToPx(s.w, dpi)} × {mmToPx(s.h, dpi)} px
|
||||
{check && (check.ok ? ' · Auflösung reicht' : ` · nur ca. ${check.dpi} dpi`)}
|
||||
</div>
|
||||
)}
|
||||
<button className="pbtn breit" disabled={!cell.src} onClick={onSingle}>Einzeln herunterladen</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Zuschnitt: schieben und zoomen, Seitenverhältnis bleibt fest am Zielformat. */
|
||||
function CropModal({ cell, onChange, onClose }: { cell: Cell; onChange: (c: CropRel) => void; onClose: () => void }) {
|
||||
const s = sizeOfCell(cell);
|
||||
const aspect = s ? s.w / s.h : 1;
|
||||
const base = coverCrop(cell.natW, cell.natH, aspect);
|
||||
const [crop, setCrop] = useState<CropRel>(cell.crop);
|
||||
const boxRef = useRef<HTMLDivElement>(null);
|
||||
const drag = useRef<{ x: number; y: number; crop: CropRel } | null>(null);
|
||||
|
||||
const zoom = Math.min(20, Math.max(1, base.w / crop.w));
|
||||
const setZoom = (z: number) => {
|
||||
const zz = Math.min(20, Math.max(1, z));
|
||||
const cx = crop.x + crop.w / 2, cy = crop.y + crop.h / 2;
|
||||
const w = base.w / zz, h = base.h / zz;
|
||||
setCrop(clampCrop({ x: cx - w / 2, y: cy - h / 2, w, h }));
|
||||
};
|
||||
|
||||
const onDown = (e: React.PointerEvent) => {
|
||||
(e.target as Element).setPointerCapture?.(e.pointerId);
|
||||
drag.current = { x: e.clientX, y: e.clientY, crop };
|
||||
};
|
||||
const onMove = (e: React.PointerEvent) => {
|
||||
if (!drag.current || !boxRef.current) return;
|
||||
const r = boxRef.current.getBoundingClientRect();
|
||||
const dx = (e.clientX - drag.current.x) / r.width;
|
||||
const dy = (e.clientY - drag.current.y) / r.height;
|
||||
const c = drag.current.crop;
|
||||
setCrop(clampCrop({ ...c, x: c.x - dx * c.w, y: c.y - dy * c.h }));
|
||||
};
|
||||
const onUp = () => { drag.current = null; };
|
||||
|
||||
useEffect(() => {
|
||||
const esc = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||
window.addEventListener('keydown', esc);
|
||||
return () => window.removeEventListener('keydown', esc);
|
||||
}, [onClose]);
|
||||
|
||||
const apply = () => { onChange(crop); onClose(); };
|
||||
|
||||
return (
|
||||
<div className="modal" onClick={onClose}>
|
||||
<div className="modal-inhalt" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="kopfzeile">
|
||||
<span className="mono-label">Zuschnitt · {s ? labelMm(s.w, s.h) : ''}</span>
|
||||
<button className="link" onClick={onClose}>Schließen</button>
|
||||
</div>
|
||||
<div className="crop-buehne">
|
||||
<div ref={boxRef} className="crop-box" style={{ aspectRatio: `${aspect}` }}
|
||||
onPointerDown={onDown} onPointerMove={onMove} onPointerUp={onUp} onPointerCancel={onUp}
|
||||
onWheel={(e) => { setZoom(zoom * (e.deltaY < 0 ? 1.08 : 1 / 1.08)); }}>
|
||||
<img src={cell.preview} alt="" draggable={false} style={cropStyle(crop)} />
|
||||
<div className="crop-gitter" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="crop-steuer">
|
||||
<label className="mini breit">Zoom
|
||||
<input type="range" min={1} max={6} step={0.01} value={Math.min(6, zoom)}
|
||||
onChange={(e) => setZoom(Number(e.target.value))} />
|
||||
</label>
|
||||
<div className="reihe">
|
||||
<button className="pbtn" onClick={() => setCrop(base)}>Bild füllen</button>
|
||||
<button className="pbtn" onClick={() => setCrop(clampCrop({ ...crop, x: (1 - crop.w) / 2, y: (1 - crop.h) / 2 }))}>Zentrieren</button>
|
||||
<button className="knopf schmal" onClick={apply}>Übernehmen</button>
|
||||
</div>
|
||||
<div className="fein">Ziehen zum Verschieben, Mausrad oder Regler zum Zoomen. Das Seitenverhältnis bleibt exakt am Zielformat.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LibraryPicker({ onPick, onClose }: { onPick: (it: any) => void; onClose: () => void }) {
|
||||
const [items, setItems] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
useEffect(() => {
|
||||
fetch('/api/items').then((r) => r.json())
|
||||
.then((j) => setItems(j.items || [])).catch(() => {}).finally(() => setLoading(false));
|
||||
}, []);
|
||||
return (
|
||||
<div className="modal" onClick={onClose}>
|
||||
<div className="modal-inhalt breit" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="kopfzeile">
|
||||
<span className="mono-label">Aus der Bibliothek wählen</span>
|
||||
<button className="link" onClick={onClose}>Schließen</button>
|
||||
</div>
|
||||
<div className="picker">
|
||||
{loading ? <p className="leer">Wird geladen …</p>
|
||||
: !items.length ? <p className="leer">Noch keine Bilder in der Bibliothek.</p>
|
||||
: items.map((it) => (
|
||||
<button key={it.id} className="pick" onClick={() => onPick(it)} title={it.filename}>
|
||||
<img src={`/api/items/${it.id}/file?thumb=1`} alt="" loading="lazy" />
|
||||
<span>{it.filename}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PrintStyles() {
|
||||
return <style>{`
|
||||
.druck{display:flex;flex-direction:column;gap:16px;}
|
||||
.raster{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:18px;align-items:start;}
|
||||
@media(max-width:900px){.raster{grid-template-columns:1fr;}}
|
||||
.karte{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);}
|
||||
.kopfzeile{display:flex;justify-content:space-between;align-items:center;gap:10px;padding:12px 16px;border-bottom:1px solid var(--line);}
|
||||
.kopf-tools{display:flex;gap:12px;}
|
||||
.link{background:none;border:none;cursor:pointer;color:var(--accent);font-family:var(--font-mono);font-size:10px;letter-spacing:.14em;text-transform:uppercase;padding:0;}
|
||||
.buehne{padding:16px;}
|
||||
.drop{width:100%;min-height:180px;border:1.5px dashed var(--line);border-radius:var(--radius-sm);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px;text-align:center;padding:26px;cursor:pointer;}
|
||||
.drop.drag{border-color:var(--accent);background:var(--accent-bg);}
|
||||
.drop p{margin:0;color:var(--soft);font-size:14px;line-height:1.55;}
|
||||
.kbd{font-family:var(--font-mono);font-size:12px;background:var(--paper);border:1px solid var(--line);border-radius:3px;padding:1px 6px;}
|
||||
|
||||
.zellen{display:grid;grid-template-columns:repeat(auto-fill,minmax(190px,1fr));gap:12px;}
|
||||
.zelle{position:relative;border:1px solid var(--line);border-radius:var(--radius-sm);background:#fff;padding:9px;display:flex;flex-direction:column;gap:8px;}
|
||||
.zelle.err{border-color:var(--err);}
|
||||
.zelle-x{position:absolute;top:5px;right:5px;z-index:3;width:22px;height:22px;border:none;border-radius:50%;background:rgba(22,21,15,.72);color:#fff;cursor:pointer;font-size:13px;line-height:1;}
|
||||
.zelle-bild{position:relative;overflow:hidden;border:1px solid var(--line);border-radius:3px;cursor:zoom-in;
|
||||
background:repeating-conic-gradient(oklch(90% 0.006 95) 0% 25%, oklch(96% 0.004 95) 0% 50%) 50%/14px 14px;}
|
||||
.zelle-bild img{display:block;}
|
||||
.zelle-lupe{position:absolute;left:0;right:0;bottom:0;background:rgba(22,21,15,.62);color:#fff;font-family:var(--font-mono);font-size:9px;letter-spacing:.14em;text-transform:uppercase;text-align:center;padding:3px 0;}
|
||||
.lade{position:absolute;inset:0;background:var(--paper);opacity:.6;}
|
||||
.zelle-steuer{display:flex;flex-direction:column;gap:6px;}
|
||||
.zelle-reihe{display:flex;gap:6px;align-items:center;}
|
||||
.zelle-add{border:1.5px dashed var(--line);background:none;border-radius:var(--radius-sm);cursor:pointer;font-size:14px;color:var(--soft);min-height:120px;font-family:inherit;}
|
||||
|
||||
.anzahl{display:flex;align-items:center;border:1px solid var(--line);border-radius:3px;overflow:hidden;background:#fff;}
|
||||
.anzahl button{width:28px;height:30px;border:none;background:none;cursor:pointer;font-size:15px;color:var(--ink);}
|
||||
.anzahl input{width:44px;height:30px;border:none;text-align:center;font-family:var(--font-mono);font-size:13px;outline:none;-moz-appearance:textfield;}
|
||||
.anzahl input::-webkit-outer-spin-button,.anzahl input::-webkit-inner-spin-button{-webkit-appearance:none;margin:0;}
|
||||
|
||||
.steuer{padding:16px;display:flex;flex-direction:column;gap:15px;}
|
||||
.feld label{display:block;font-family:var(--font-mono);font-size:10px;letter-spacing:.16em;text-transform:uppercase;color:var(--soft);margin-bottom:7px;}
|
||||
.input,.select{width:100%;background:#fff;border:1px solid var(--line);border-radius:3px;padding:9px 11px;font-family:inherit;font-size:14px;color:var(--ink);outline:none;}
|
||||
.mini-select,.mini-input{padding:6px 8px;font-size:12.5px;}
|
||||
.input:focus,.select:focus{border-color:var(--accent);}
|
||||
.reihe{display:flex;gap:6px;align-items:center;margin-top:6px;}
|
||||
.reihe .select{flex:1;}
|
||||
.reihe.zwei{display:grid;grid-template-columns:1fr 1fr;gap:8px;}
|
||||
.mini{display:block;font-family:var(--font-mono);font-size:9.5px;letter-spacing:.12em;text-transform:uppercase;color:var(--soft);}
|
||||
.mini .input,.mini .select{margin-top:4px;}
|
||||
.mini.breit{width:100%;}
|
||||
.mini input[type=range]{width:100%;accent-color:var(--accent);margin-top:6px;}
|
||||
.fein{font-size:11.5px;color:var(--soft);margin-top:6px;line-height:1.5;}
|
||||
.fein.nopad{margin:0;}
|
||||
.fein.mitte{text-align:center;}
|
||||
.fein.warn{color:var(--err);}
|
||||
.check{display:flex;gap:8px;align-items:flex-start;margin-top:8px;font-size:12.5px;color:var(--ink);text-transform:none;letter-spacing:0;font-family:inherit;}
|
||||
.check input{width:15px;height:15px;accent-color:var(--accent);margin-top:2px;flex:0 0 auto;}
|
||||
.check.winzig{font-size:11px;color:var(--soft);}
|
||||
.schalter{display:flex;gap:6px;margin-top:8px;}
|
||||
.schalter button{flex:1;background:#fff;border:1px solid var(--line);border-radius:3px;padding:8px;cursor:pointer;font-family:inherit;font-size:13px;color:var(--soft);}
|
||||
.schalter button.an{border-color:var(--accent);background:var(--accent-bg);color:var(--ink);font-weight:600;}
|
||||
.schalter.zart button{font-size:12px;padding:6px 4px;}
|
||||
.schalter.winzig{margin:0;flex:1;}
|
||||
.schalter.winzig button{padding:5px 4px;font-size:11.5px;}
|
||||
.pbtn{background:#fff;border:1px solid var(--line);border-radius:3px;padding:8px 10px;cursor:pointer;font-family:inherit;font-size:12px;color:var(--ink);white-space:nowrap;}
|
||||
.pbtn.breit{width:100%;}
|
||||
.pbtn:disabled{opacity:.5;cursor:not-allowed;}
|
||||
.knopf{width:100%;border:none;border-radius:3px;padding:13px;cursor:pointer;background:var(--accent);color:#fff;font-family:inherit;font-weight:600;font-size:15px;}
|
||||
.knopf.schmal{width:auto;padding:9px 16px;font-size:13.5px;}
|
||||
.knopf:disabled{background:#C9C8BF;cursor:not-allowed;}
|
||||
.spin{width:13px;height:13px;display:inline-block;border:2px solid #fff;border-top-color:transparent;border-radius:50%;vertical-align:-2px;margin-right:9px;animation:sp 1s linear infinite;}
|
||||
@keyframes sp{to{transform:rotate(360deg);}}
|
||||
|
||||
.vorschau{padding:16px;display:flex;flex-direction:column;gap:16px;}
|
||||
.leer{color:var(--soft);font-size:13px;margin:0;text-align:center;padding:26px 0;}
|
||||
.bogen-halter{display:flex;flex-direction:column;gap:6px;}
|
||||
.bogen{position:relative;width:100%;background:#fff;border:1px solid var(--line);box-shadow:var(--shadow);overflow:hidden;}
|
||||
.slot{position:absolute;overflow:hidden;}
|
||||
.rotor{position:absolute;left:50%;top:50%;width:100%;height:100%;transform:translate(-50%,-50%);overflow:hidden;}
|
||||
.rotor img{position:absolute;}
|
||||
.marken{position:absolute;inset:0;width:100%;height:100%;pointer-events:none;}
|
||||
.bogen-fuss{font-family:var(--font-mono);font-size:9.5px;letter-spacing:.14em;text-transform:uppercase;color:var(--soft);}
|
||||
.warnung{color:var(--err);font-size:12px;margin:0;}
|
||||
|
||||
.modal{position:fixed;inset:0;background:rgba(22,21,15,.55);display:flex;align-items:center;justify-content:center;z-index:70;padding:16px;}
|
||||
.modal-inhalt{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);width:min(560px,100%);max-height:90vh;overflow:auto;}
|
||||
.modal-inhalt.breit{width:min(880px,100%);}
|
||||
.crop-buehne{padding:16px;display:flex;justify-content:center;background:var(--paper);}
|
||||
.crop-box{position:relative;width:100%;max-width:420px;max-height:56vh;overflow:hidden;background:#fff;border:1px solid var(--line);cursor:grab;touch-action:none;user-select:none;}
|
||||
.crop-box:active{cursor:grabbing;}
|
||||
.crop-box img{position:absolute;pointer-events:none;}
|
||||
.crop-gitter{position:absolute;inset:0;pointer-events:none;
|
||||
background:linear-gradient(to right,transparent calc(33.33% - .5px),rgba(255,255,255,.55) 33.33%,rgba(255,255,255,.55) calc(33.33% + .5px),transparent calc(33.33% + .5px),transparent calc(66.66% - .5px),rgba(255,255,255,.55) 66.66%,rgba(255,255,255,.55) calc(66.66% + .5px),transparent calc(66.66% + .5px)),
|
||||
linear-gradient(to bottom,transparent calc(33.33% - .5px),rgba(255,255,255,.55) 33.33%,rgba(255,255,255,.55) calc(33.33% + .5px),transparent calc(33.33% + .5px),transparent calc(66.66% - .5px),rgba(255,255,255,.55) 66.66%,rgba(255,255,255,.55) calc(66.66% + .5px),transparent calc(66.66% + .5px));
|
||||
box-shadow:inset 0 0 0 1px rgba(27,59,224,.35);}
|
||||
.crop-steuer{padding:14px 16px;display:flex;flex-direction:column;gap:8px;}
|
||||
|
||||
.picker{padding:14px;display:grid;grid-template-columns:repeat(auto-fill,minmax(110px,1fr));gap:10px;}
|
||||
.pick{border:1px solid var(--line);background:#fff;border-radius:3px;padding:0;cursor:pointer;overflow:hidden;display:flex;flex-direction:column;font-family:inherit;}
|
||||
.pick img{width:100%;aspect-ratio:1;object-fit:cover;display:block;}
|
||||
.pick span{font-size:10px;color:var(--soft);padding:4px 5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
||||
.pick:hover{border-color:var(--accent);}
|
||||
|
||||
.toast{position:fixed;left:50%;bottom:26px;transform:translateX(-50%);background:var(--ink);color:#FBFBF7;padding:10px 18px;border-radius:3px;font-size:13.5px;z-index:80;max-width:min(92vw,520px);text-align:center;line-height:1.45;}
|
||||
`}</style>;
|
||||
}
|
||||
@@ -5,10 +5,22 @@ const STATUS_DE: Record<string, string> = {
|
||||
skipped: 'übersprungen', paused: 'angehalten', cancelled: 'abgebrochen',
|
||||
};
|
||||
|
||||
/** Zeitpunkt hübsch: heute → „HH:MM", sonst „TT.MM. HH:MM". */
|
||||
function fmtTime(iso: string | null): string {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return '';
|
||||
const hm = d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
|
||||
const today = new Date();
|
||||
const sameDay = d.getFullYear() === today.getFullYear() && d.getMonth() === today.getMonth() && d.getDate() === today.getDate();
|
||||
return sameDay ? hm : `${d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' })} ${hm}`;
|
||||
}
|
||||
|
||||
export default function QueueApp({ jobId }: { jobId: string | null }) {
|
||||
const [job, setJob] = useState<any>(null);
|
||||
const [items, setItems] = useState<any[]>([]);
|
||||
const [jobs, setJobs] = useState<any[]>([]);
|
||||
const [saveView, setSaveView] = useState<any | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (jobId) {
|
||||
@@ -39,7 +51,8 @@ export default function QueueApp({ jobId }: { jobId: string | null }) {
|
||||
<a key={j.id} className="jobrow" href={`/warteschlange?job=${j.id}`}>
|
||||
<span className={`punkt ${j.status}`} />
|
||||
<b>{j.done_count}/{j.total}</b>
|
||||
<span className="fein">{STATUS_DE[j.status] || j.status} · {j.by_name || ''}</span>
|
||||
<span className="fein">{STATUS_DE[j.status] || j.status}{j.by_name ? ` · ${j.by_name}` : ''}
|
||||
{j.finished_at && ['done', 'failed', 'cancelled'].includes(j.status) ? ` · fertig ${fmtTime(j.finished_at)}` : ''}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
@@ -58,12 +71,20 @@ export default function QueueApp({ jobId }: { jobId: string | null }) {
|
||||
return (
|
||||
<div className="q">
|
||||
<div className="kopfzeile">
|
||||
<span className="mono-label">Auftrag · {STATUS_DE[job?.status] || job?.status}</span>
|
||||
<span className="mono-label">Auftrag · {STATUS_DE[job?.status] || job?.status}
|
||||
{job?.finished_at && ['done', 'failed', 'cancelled'].includes(job?.status) ? ` · beendet ${fmtTime(job.finished_at)}` : ''}</span>
|
||||
<div className="reihe">
|
||||
{job?.status === 'running' && <button className="mini" onClick={() => act(`/api/jobs/${jobId}/pause`)}>Anhalten</button>}
|
||||
{job?.status === 'paused' && <button className="mini" onClick={() => act(`/api/jobs/${jobId}/resume`)}>Fortsetzen</button>}
|
||||
{failed > 0 && <button className="mini" onClick={() => act(`/api/jobs/${jobId}/retry-failed`)}>Fehlgeschlagene erneut</button>}
|
||||
{['queued', 'running', 'paused'].includes(job?.status) && <button className="mini" onClick={() => act(`/api/jobs/${jobId}/cancel`)}>Abbrechen</button>}
|
||||
{['done', 'failed', 'cancelled'].includes(job?.status) && (
|
||||
<button className="mini" onClick={async () => {
|
||||
if (!confirm('Diesen Auftrag löschen?')) return;
|
||||
await fetch(`/api/jobs/${jobId}/delete`, { method: 'POST' });
|
||||
location.href = '/warteschlange';
|
||||
}}>Löschen</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -87,12 +108,22 @@ export default function QueueApp({ jobId }: { jobId: string | null }) {
|
||||
{it.error_message && <span className="fein err">{it.error_message}</span>}
|
||||
</div>
|
||||
<div className="iact">
|
||||
{it.status === 'done' && <a className="mini" href={`/api/items/${it.id}/file?download=1`}>Laden</a>}
|
||||
{it.status === 'done' && <button className="mini" onClick={() => setSaveView(it)}>Laden</button>}
|
||||
{it.status === 'failed' && <button className="mini" onClick={() => act(`/api/items/${it.id}/retry`)}>Erneut</button>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{saveView && (
|
||||
<div className="lupe" onClick={() => setSaveView(null)}>
|
||||
<img className="saveimg" src={`/api/items/${saveView.id}/file?preview=1`} alt="" onClick={(e) => e.stopPropagation()} />
|
||||
<div className="save-fuss" onClick={(e) => e.stopPropagation()}>
|
||||
<span>📱 iPhone/iPad: lange auf das Bild tippen → „In Fotos sichern".</span>
|
||||
<a className="mini" href={`/api/items/${saveView.id}/file?download=1`}>Original als Datei (.png)</a>
|
||||
<button className="mini" onClick={() => setSaveView(null)}>Schließen</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<QStyles />
|
||||
</div>
|
||||
);
|
||||
@@ -122,6 +153,10 @@ function QStyles() {
|
||||
.punkt.done{background:var(--ok);}
|
||||
.punkt.failed{background:var(--err);}
|
||||
.punkt.queued{background:var(--mark);}
|
||||
.lupe{position:fixed;inset:0;background:rgba(22,21,15,.9);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:16px;padding:20px;z-index:50;}
|
||||
.saveimg{max-width:94vw;max-height:78vh;border-radius:4px;display:block;}
|
||||
.save-fuss{display:flex;flex-direction:column;align-items:center;gap:10px;color:#EAEAE3;font-size:13.5px;text-align:center;}
|
||||
.save-fuss .mini{text-decoration:none;}
|
||||
.joblist{display:flex;flex-direction:column;}
|
||||
.jobrow{display:flex;align-items:center;gap:10px;padding:11px 16px;border-bottom:1px solid var(--line);text-decoration:none;color:var(--ink);}
|
||||
p.fein{padding:16px;}
|
||||
|
||||
+347
-143
@@ -22,8 +22,19 @@ const FORMATS: { id: string; label: string; cm?: [number, number]; screen?: [num
|
||||
{ id: 'theframe', label: 'The Frame (16:9)', screen: [3840, 2160] },
|
||||
{ id: 'hochformat', label: 'Hochformat (9:16)', screen: [2160, 3840] },
|
||||
{ id: 'sticker', label: 'Sticker (freies Maß)', cm: [5, 5] },
|
||||
{ id: 'custom', label: 'Eigenes Maß (cm)' },
|
||||
];
|
||||
|
||||
/** Freitext-Maß → Formatschlüssel + cm. „25x35" → 25×35 cm; „5" → 5×5 cm Sticker. */
|
||||
function parseCustomFmt(s: string): { key: string; cm: [number, number] | null } {
|
||||
const t = (s || '').trim().replace(',', '.');
|
||||
const wh = /^(\d+(?:\.\d+)?)\s*[x×*]\s*(\d+(?:\.\d+)?)$/i.exec(t);
|
||||
if (wh) return { key: `${wh[1]}x${wh[2]}`, cm: [parseFloat(wh[1]), parseFloat(wh[2])] };
|
||||
const n = /^(\d+(?:\.\d+)?)$/.exec(t);
|
||||
if (n) return { key: `sticker${n[1]}`, cm: [parseFloat(n[1]), parseFloat(n[1])] };
|
||||
return { key: '', cm: null };
|
||||
}
|
||||
|
||||
const TASKS = [
|
||||
{ id: 'clean', name: 'Bereinigen', hint: 'Rahmen, Wand und Shop-Oberfläche entfernen.' },
|
||||
{ id: 'cutout', name: 'Freistellen', hint: 'Nur das Motiv, Hintergrund transparent.' },
|
||||
@@ -31,19 +42,37 @@ const TASKS = [
|
||||
{ id: 'contour', name: 'Kontur', hint: 'Weißen Stickerrand anlegen (nur mit Freistellen).' },
|
||||
];
|
||||
|
||||
type Mode = 'each' | 'compose' | 'generate';
|
||||
const MODES: { id: Mode; name: string; hint: string }[] = [
|
||||
{ id: 'each', name: 'Bereinigen', hint: 'Screenshots bereinigen, freistellen, aufs Format bringen.' },
|
||||
{ id: 'compose', name: 'Umwandeln', hint: 'Ein Bild umwandeln (z. B. Foto → Ölgemälde) oder mehrere kombinieren — mit Text.' },
|
||||
{ id: 'generate', name: 'Neu erzeugen', hint: 'Ein komplett neues Bild allein aus Text.' },
|
||||
];
|
||||
|
||||
const px = (cm: number, dpi = 300) => Math.round((cm / 2.54) * dpi);
|
||||
|
||||
interface Pic { id: string; src: string; name: string; source_path?: string; error?: string; uploading?: boolean }
|
||||
|
||||
export default function StudioApp({ recipes }: { recipes: any[] }) {
|
||||
const [presets, setPresets] = useState<any[]>(recipes || []);
|
||||
const [mode, setMode] = useState<Mode>('each');
|
||||
const [pics, setPics] = useState<Pic[]>([]);
|
||||
const [tasks, setTasks] = useState<string[]>(['clean', 'format']);
|
||||
const [format, setFormat] = useState('30x40');
|
||||
const [portrait, setPortrait] = useState(true);
|
||||
const [crop, setCrop] = useState<'crop' | 'extend'>('crop');
|
||||
const [contourMm, setContourMm] = useState(3);
|
||||
const [customFmt, setCustomFmt] = useState(''); // Freitext-Maß für „Eigenes Maß"/„Sticker"
|
||||
const [outputExt, setOutputExt] = useState<'' | 'png' | 'jpg'>(''); // '' = globale Standard-Einstellung
|
||||
const [custom, setCustom] = useState('');
|
||||
const [desc, setDesc] = useState('');
|
||||
const [delivery, setDelivery] = useState<'library' | 'picdrop' | 'both'>('library');
|
||||
const [gallery, setGallery] = useState('');
|
||||
const [targetId, setTargetId] = useState('');
|
||||
const [targets, setTargets] = useState<any[]>([]);
|
||||
const [presetSel, setPresetSel] = useState('');
|
||||
const [me, setMe] = useState<any>({});
|
||||
const [priv, setPriv] = useState(false);
|
||||
const [models, setModels] = useState<any[]>([]);
|
||||
const [modelKey, setModelKey] = useState<string>('');
|
||||
const [dragging, setDragging] = useState(false);
|
||||
@@ -55,11 +84,22 @@ export default function StudioApp({ recipes }: { recipes: any[] }) {
|
||||
const hasCutout = tasks.includes('cutout');
|
||||
const hasFormat = tasks.includes('format');
|
||||
const theframeConflict = hasCutout && (format === 'theframe' || format === 'hochformat');
|
||||
// Format ist in compose/generate immer verfügbar (ohne „Format"-Aufgabe).
|
||||
const showFormat = mode === 'each' ? hasFormat : true;
|
||||
const wantsFormat = mode === 'each' ? hasFormat : format !== 'keep';
|
||||
|
||||
const isFreeFmt = format === 'custom' || format === 'sticker';
|
||||
const parsedFmt = isFreeFmt ? parseCustomFmt(customFmt) : { key: '', cm: null };
|
||||
// Für „Sticker" ohne Eingabe gilt weiter 5×5 cm; für „Eigenes Maß" braucht es eine Eingabe.
|
||||
const effCm: [number, number] | null = fmt?.screen ? null
|
||||
: parsedFmt.cm || (format === 'sticker' ? [5, 5] : (fmt?.cm || null));
|
||||
// Was tatsächlich als output_format gespeichert wird (z. B. „25x35", „sticker5").
|
||||
const effFormat = isFreeFmt ? (parsedFmt.key || (format === 'sticker' ? 'sticker' : '')) : format;
|
||||
|
||||
const zielPx: [number, number] | null = fmt?.screen
|
||||
? (fmt.screen as [number, number])
|
||||
: fmt?.cm
|
||||
? (portrait ? [px(fmt.cm[0]), px(fmt.cm[1])] : [px(fmt.cm[1]), px(fmt.cm[0])])
|
||||
: effCm
|
||||
? (portrait ? [px(effCm[0]), px(effCm[1])] : [px(effCm[1]), px(effCm[0])])
|
||||
: null;
|
||||
|
||||
const notify = (t: string) => { setToast(t); setTimeout(() => setToast(null), 2600); };
|
||||
@@ -87,12 +127,13 @@ export default function StudioApp({ recipes }: { recipes: any[] }) {
|
||||
|
||||
useEffect(() => {
|
||||
const onPaste = (e: ClipboardEvent) => {
|
||||
if (mode === 'generate') return;
|
||||
const imgs = Array.from(e.clipboardData?.items || []).filter((i) => i.type.startsWith('image/'));
|
||||
if (imgs.length) upload(imgs.map((i) => i.getAsFile()!).filter(Boolean));
|
||||
};
|
||||
window.addEventListener('paste', onPaste);
|
||||
return () => window.removeEventListener('paste', onPaste);
|
||||
}, [upload]);
|
||||
}, [upload, mode]);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/models').then((r) => r.json()).then((j) => {
|
||||
@@ -100,48 +141,113 @@ export default function StudioApp({ recipes }: { recipes: any[] }) {
|
||||
const def = ms.find((m: any) => m.is_default) || ms[0];
|
||||
if (def) setModelKey(def.model_id);
|
||||
}).catch(() => {});
|
||||
fetch('/api/delivery-targets').then((r) => r.json()).then((j) => setTargets(j.targets || [])).catch(() => {});
|
||||
fetch('/api/me').then((r) => r.json()).then((j) => { setMe(j); if (j.private_forced) setPriv(true); }).catch(() => {});
|
||||
loadPresets();
|
||||
}, []);
|
||||
const loadPresets = () => fetch('/api/recipes').then((r) => r.json()).then((j) => setPresets(j.recipes || [])).catch(() => {});
|
||||
const saveAsPreset = async () => {
|
||||
const name = prompt('Name des Presets?'); if (!name?.trim()) return;
|
||||
const order = ['clean', 'cutout', 'format', 'contour', 'deliver'];
|
||||
const tasksOut = mode === 'each' ? [...tasks].sort((a, b) => order.indexOf(a) - order.indexOf(b)) : (wantsFormat ? ['format'] : []);
|
||||
const body = {
|
||||
name: name.trim(), mode, tasks: tasksOut, output_format: wantsFormat ? effFormat : 'keep',
|
||||
orientation: portrait ? 'portrait' : 'landscape', crop_mode: crop, dpi: 300,
|
||||
contour_mm: tasks.includes('contour') ? contourMm : null,
|
||||
output_ext: outputExt || null,
|
||||
custom_instruction: mode === 'each' ? (custom || null) : null, model_key: modelKey || null,
|
||||
delivery, picdrop_gallery: gallery.trim() || null, delivery_target_id: targetId || null,
|
||||
};
|
||||
await fetch('/api/recipes', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||
notify('Preset gespeichert.'); loadPresets();
|
||||
};
|
||||
const deletePreset = async (id: string) => {
|
||||
if (!id || !confirm('Preset löschen?')) return;
|
||||
await fetch(`/api/recipes/${id}`, { method: 'DELETE' }); notify('Preset gelöscht.'); loadPresets();
|
||||
};
|
||||
|
||||
// Ergebnis aus der Bibliothek übernehmen: ?reuse=<itemId> lädt es als Vorlage.
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const reuseId = params.get('reuse');
|
||||
if (!reuseId) return;
|
||||
(async () => {
|
||||
const id = crypto.randomUUID();
|
||||
setPics((p) => [...p, { id, src: `/api/items/${reuseId}/file?thumb=1`, name: 'weiterbearbeiten.png', uploading: true }]);
|
||||
try {
|
||||
const j = await fetch(`/api/items/${reuseId}/reuse`, { method: 'POST' }).then((r) => r.json());
|
||||
setPics((p) => p.map((x) => x.id === id
|
||||
? { ...x, uploading: false, source_path: j.source_path, name: j.filename || x.name, error: j.error } : x));
|
||||
} catch {
|
||||
setPics((p) => p.map((x) => x.id === id ? { ...x, uploading: false, error: 'Konnte Bild nicht laden' } : x));
|
||||
}
|
||||
history.replaceState(null, '', '/');
|
||||
})();
|
||||
}, []);
|
||||
|
||||
function toggleTask(id: string) {
|
||||
setTasks((t) => {
|
||||
let next = t.includes(id) ? t.filter((x) => x !== id) : [...t, id];
|
||||
if (id === 'cutout' && !next.includes('cutout')) next = next.filter((x) => x !== 'contour'); // Kontur braucht Freistellen
|
||||
if (id === 'contour' && next.includes('contour') && !next.includes('cutout')) next = next; // wird per disabled verhindert
|
||||
if (id === 'cutout' && !next.includes('cutout')) next = next.filter((x) => x !== 'contour');
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function applyRecipe(r: any) {
|
||||
if (!r) return;
|
||||
if (['each', 'compose', 'generate'].includes(r.mode)) setMode(r.mode);
|
||||
setTasks(r.tasks || ['clean', 'format']);
|
||||
setFormat(r.output_format || 'keep');
|
||||
const of = r.output_format || 'keep';
|
||||
// Freies Maß aus dem Rezept zurückholen (kein bekannter Schlüssel = Eigenes Maß).
|
||||
if (of !== 'keep' && !FORMATS.some((f) => f.id === of)) {
|
||||
setFormat('custom');
|
||||
setCustomFmt(/^sticker[-_ ]?/i.test(of) ? of.replace(/^sticker[-_ ]?/i, '') : of);
|
||||
} else setFormat(of);
|
||||
setOutputExt(r.output_ext === 'jpg' || r.output_ext === 'png' ? r.output_ext : '');
|
||||
setPortrait((r.orientation || 'portrait') !== 'landscape');
|
||||
setCrop(r.crop_mode === 'extend' ? 'extend' : 'crop');
|
||||
setContourMm(Number(r.contour_mm) || 3);
|
||||
setCustom(r.custom_instruction || '');
|
||||
setDelivery(r.delivery || 'library');
|
||||
setGallery(r.picdrop_gallery || '');
|
||||
setTargetId(r.delivery_target_id || '');
|
||||
if (r.model_key) setModelKey(r.model_key);
|
||||
}
|
||||
|
||||
const ready = pics.filter((p) => p.source_path && !p.error);
|
||||
const canRun = ready.length > 0 && !busy && tasks.length > 0 && !theframeConflict &&
|
||||
!(tasks.includes('contour') && !hasCutout);
|
||||
// „Eigenes Maß" braucht eine gültige Eingabe, sonst kann nicht gestartet werden.
|
||||
const freeFmtInvalid = wantsFormat && format === 'custom' && !parsedFmt.cm;
|
||||
const canRun = !busy && !freeFmtInvalid && (
|
||||
mode === 'each' ? (ready.length > 0 && tasks.length > 0 && !theframeConflict && !(tasks.includes('contour') && !hasCutout))
|
||||
: mode === 'compose' ? (ready.length >= 1 && desc.trim().length > 0)
|
||||
: desc.trim().length > 0
|
||||
);
|
||||
|
||||
async function run() {
|
||||
if (!canRun) return;
|
||||
setBusy(true);
|
||||
const order = ['clean', 'cutout', 'format', 'contour', 'deliver'];
|
||||
const tasksOut = mode === 'each'
|
||||
? [...tasks].sort((a, b) => order.indexOf(a) - order.indexOf(b))
|
||||
: (wantsFormat ? ['format'] : []);
|
||||
const recipe = {
|
||||
tasks: [...tasks].sort((a, b) => ['clean', 'cutout', 'format', 'contour', 'deliver'].indexOf(a) - ['clean', 'cutout', 'format', 'contour', 'deliver'].indexOf(b)),
|
||||
output_format: hasFormat ? format : 'keep',
|
||||
tasks: tasksOut,
|
||||
output_format: wantsFormat ? effFormat : 'keep',
|
||||
orientation: portrait ? 'portrait' : 'landscape',
|
||||
crop_mode: crop, dpi: 300,
|
||||
contour_mm: tasks.includes('contour') ? contourMm : null,
|
||||
custom_instruction: custom || null, delivery, model_key: modelKey || null,
|
||||
output_ext: outputExt || null,
|
||||
custom_instruction: mode === 'each' ? (custom || null) : null,
|
||||
delivery, model_key: modelKey || null,
|
||||
picdrop_gallery: (delivery !== 'library' && gallery.trim()) ? gallery.trim() : null,
|
||||
delivery_target_id: (delivery !== 'library' && targetId) ? targetId : null,
|
||||
};
|
||||
const body: any = { recipe, mode, delivery, private: priv };
|
||||
if (mode !== 'each') body.prompt_text = desc.trim();
|
||||
body.sources = mode === 'generate' ? [] : ready.map((p) => ({ source_path: p.source_path, filename: p.name }));
|
||||
try {
|
||||
const res = await fetch('/api/jobs', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ recipe, sources: ready.map((p) => ({ source_path: p.source_path, filename: p.name })) }),
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
|
||||
});
|
||||
const j = await res.json();
|
||||
if (j.jobId) location.href = `/warteschlange?job=${j.jobId}`;
|
||||
@@ -149,136 +255,218 @@ export default function StudioApp({ recipes }: { recipes: any[] }) {
|
||||
} catch { notify('Netzwerkfehler.'); setBusy(false); }
|
||||
}
|
||||
|
||||
const uploadHint = mode === 'compose'
|
||||
? 'Ein Bild umwandeln oder mehrere kombinieren (z. B. Motiv + Foto von Frieda).'
|
||||
: 'Mehrere gleichzeitig, bis zu 100. PNG, JPG, WEBP, HEIC.';
|
||||
|
||||
return (
|
||||
<div className="studio">
|
||||
<section className="karte">
|
||||
<div className="kopfzeile">
|
||||
<span className="mono-label">Vorlagen{pics.length ? ` · ${pics.length}` : ''}</span>
|
||||
{pics.length > 0 && <button className="link" onClick={() => setPics([])}>alle entfernen</button>}
|
||||
</div>
|
||||
<div className="buehne">
|
||||
{pics.length === 0 ? (
|
||||
<div className={`drop ${dragging ? 'drag' : ''}`}
|
||||
onClick={() => fileRef.current?.click()}
|
||||
onDragOver={(e) => { e.preventDefault(); setDragging(true); }}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={(e) => { e.preventDefault(); setDragging(false); upload(e.dataTransfer.files); }}>
|
||||
<span className="regmark" />
|
||||
<p>Bilder hierher ziehen,<br />mit <span className="kbd">⌘/Strg + V</span> einfügen<br />oder zum Auswählen tippen.</p>
|
||||
<p className="fein">Mehrere gleichzeitig, bis zu 100. PNG, JPG, WEBP, HEIC.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mini-raster">
|
||||
{pics.map((b) => (
|
||||
<div key={b.id} className={`mini-bild ${b.error ? 'err' : ''}`}>
|
||||
{b.src && <img src={b.src} alt="" />}
|
||||
{b.uploading && <span className="lade" />}
|
||||
{b.error && <span className="badge">!</span>}
|
||||
<button onClick={() => setPics((x) => x.filter((y) => y.id !== b.id))}>×</button>
|
||||
</div>
|
||||
))}
|
||||
<button className="mini-add" onClick={() => fileRef.current?.click()}>+</button>
|
||||
</div>
|
||||
)}
|
||||
<input ref={fileRef} type="file" accept="image/*,.heic,.heif" multiple hidden
|
||||
onChange={(e) => e.target.files && upload(e.target.files)} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="karte">
|
||||
<div className="steuer">
|
||||
{recipes?.length > 0 && (
|
||||
<div className="feld">
|
||||
<label>Rezept laden</label>
|
||||
<select className="select" onChange={(e) => applyRecipe(recipes.find((r) => r.id === e.target.value))} defaultValue="">
|
||||
<option value="" disabled>Voreinstellung wählen …</option>
|
||||
{recipes.map((r) => <option key={r.id} value={r.id}>{r.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="feld">
|
||||
<label>Was soll passieren?</label>
|
||||
<div className="modi">
|
||||
{TASKS.map((m) => {
|
||||
const on = tasks.includes(m.id);
|
||||
const disabled = m.id === 'contour' && !hasCutout;
|
||||
return (
|
||||
<button key={m.id} disabled={disabled}
|
||||
className={`modus ${on ? 'an' : ''} ${disabled ? 'aus' : ''}`}
|
||||
onClick={() => toggleTask(m.id)}>
|
||||
<b>{m.name}{on ? ' ✓' : ''}</b><span>{m.hint}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasFormat && (
|
||||
<div className="feld">
|
||||
<label>Ausgabeformat</label>
|
||||
<select className="select" value={format} onChange={(e) => setFormat(e.target.value)}>
|
||||
{FORMATS.map((f) => <option key={f.id} value={f.id}>{f.label}</option>)}
|
||||
</select>
|
||||
{theframeConflict && <div className="fein warn">Freistellen + The Frame ergibt keinen Sinn — bitte eins wählen.</div>}
|
||||
{fmt?.cm && !fmt.screen && (
|
||||
<div className="schalter">
|
||||
<button className={portrait ? 'an' : ''} onClick={() => setPortrait(true)}>Hochformat</button>
|
||||
<button className={!portrait ? 'an' : ''} onClick={() => setPortrait(false)}>Querformat</button>
|
||||
</div>
|
||||
)}
|
||||
{zielPx && <div className="fein">Ergibt exakt {zielPx[0]} × {zielPx[1]} Pixel bei 300 dpi — druckfertig.</div>}
|
||||
{zielPx && (
|
||||
<div className="schalter zart">
|
||||
<button className={crop === 'crop' ? 'an' : ''} onClick={() => setCrop('crop')}>Zuschneiden</button>
|
||||
<button className={crop === 'extend' ? 'an' : ''} onClick={() => setCrop('extend')}>Ränder ergänzen</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tasks.includes('contour') && (
|
||||
<div className="feld">
|
||||
<label>Stickerrand (mm)</label>
|
||||
<input className="input" type="number" min={0} max={20} step={0.5}
|
||||
value={contourMm} onChange={(e) => setContourMm(Number(e.target.value))} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{models.length > 0 && (
|
||||
<div className="feld">
|
||||
<label>Qualität</label>
|
||||
<div className="schalter wrap">
|
||||
{models.map((m) => (
|
||||
<button key={m.model_id} className={modelKey === m.model_id ? 'an' : ''}
|
||||
onClick={() => setModelKey(m.model_id)} title={m.description || ''}>{m.label}</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="fein">{models.find((m) => m.model_id === modelKey)?.description || ''}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="feld">
|
||||
<label>Eigene Anweisung (optional)</label>
|
||||
<textarea className="area" rows={2} value={custom} onChange={(e) => setCustom(e.target.value)}
|
||||
placeholder="z. B. „Mach den Hintergrund heller, sonst alles lassen.“" />
|
||||
</div>
|
||||
|
||||
<div className="feld">
|
||||
<label>Wohin?</label>
|
||||
<div className="schalter">
|
||||
<button className={delivery === 'library' ? 'an' : ''} onClick={() => setDelivery('library')}>Bibliothek</button>
|
||||
<button className={delivery === 'picdrop' ? 'an' : ''} onClick={() => setDelivery('picdrop')}>Picdrop</button>
|
||||
<button className={delivery === 'both' ? 'an' : ''} onClick={() => setDelivery('both')}>Beides</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button className="knopf" disabled={!canRun} onClick={run}>
|
||||
{busy ? <><span className="spin" />Wird angelegt …</> : `Loslegen${ready.length ? ` · ${ready.length} Bild${ready.length > 1 ? 'er' : ''}` : ''}`}
|
||||
{/* Modus-Umschalter */}
|
||||
<div className="modus-leiste">
|
||||
{MODES.map((m) => (
|
||||
<button key={m.id} className={`modus-tab ${mode === m.id ? 'an' : ''}`} onClick={() => setMode(m.id)}>
|
||||
<b>{m.name}</b><span>{m.hint}</span>
|
||||
</button>
|
||||
<div className="fein mitte">Läuft serverseitig weiter — du kannst das Fenster schließen.</div>
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="raster">
|
||||
{mode !== 'generate' && (
|
||||
<section className="karte">
|
||||
<div className="kopfzeile">
|
||||
<span className="mono-label">{mode === 'compose' ? 'Bausteine' : 'Vorlagen'}{pics.length ? ` · ${pics.length}` : ''}</span>
|
||||
{pics.length > 0 && <button className="link" onClick={() => setPics([])}>alle entfernen</button>}
|
||||
</div>
|
||||
<div className="buehne">
|
||||
{pics.length === 0 ? (
|
||||
<div className={`drop ${dragging ? 'drag' : ''}`}
|
||||
onClick={() => fileRef.current?.click()}
|
||||
onDragOver={(e) => { e.preventDefault(); setDragging(true); }}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={(e) => { e.preventDefault(); setDragging(false); upload(e.dataTransfer.files); }}>
|
||||
<span className="regmark" />
|
||||
<p>Bilder hierher ziehen,<br />mit <span className="kbd">⌘/Strg + V</span> einfügen<br />oder zum Auswählen tippen.</p>
|
||||
<p className="fein">{uploadHint}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mini-raster">
|
||||
{pics.map((b) => (
|
||||
<div key={b.id} className={`mini-bild ${b.error ? 'err' : ''}`}>
|
||||
{b.src && <img src={b.src} alt="" />}
|
||||
{b.uploading && <span className="lade" />}
|
||||
{b.error && <span className="badge">!</span>}
|
||||
<button onClick={() => setPics((x) => x.filter((y) => y.id !== b.id))}>×</button>
|
||||
</div>
|
||||
))}
|
||||
<button className="mini-add" onClick={() => fileRef.current?.click()}>+</button>
|
||||
</div>
|
||||
)}
|
||||
<input ref={fileRef} type="file" accept="image/*,.heic,.heif" multiple hidden
|
||||
onChange={(e) => e.target.files && upload(e.target.files)} />
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="karte">
|
||||
<div className="steuer">
|
||||
<div className="feld">
|
||||
<label>Presets</label>
|
||||
<div className="reihe-presets">
|
||||
<select className="select" value={presetSel}
|
||||
onChange={(e) => { setPresetSel(e.target.value); applyRecipe(presets.find((r) => r.id === e.target.value)); }}>
|
||||
<option value="">Preset wählen …</option>
|
||||
{presets.map((r) => <option key={r.id} value={r.id}>{r.name}</option>)}
|
||||
</select>
|
||||
{presetSel && <button className="pbtn" title="Preset löschen" onClick={() => { deletePreset(presetSel); setPresetSel(''); }}>✕</button>}
|
||||
<button className="pbtn" title="Aktuelle Einstellungen als Preset speichern" onClick={saveAsPreset}>+ speichern</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(mode === 'compose' || mode === 'generate') && (
|
||||
<div className="feld">
|
||||
<label>{mode === 'compose' ? 'Was soll entstehen?' : 'Beschreibung des neuen Bildes'}</label>
|
||||
<textarea className="area gross" rows={4} value={desc} onChange={(e) => setDesc(e.target.value)}
|
||||
placeholder={mode === 'compose'
|
||||
? 'z. B. „Lass das Foto wie ein Ölgemälde aussehen.“ oder „…mit unserer Hündin Frieda am Beckenrand.“'
|
||||
: 'z. B. „Ein minimalistisches Poster mit einem Olivenzweig auf sandfarbenem Grund.“'} />
|
||||
<div className="fein">{mode === 'compose'
|
||||
? 'Ein Bild = umwandeln (z. B. Stil ändern). Mehrere Bilder = erstes ist Leitszene, weitere liefern Personen/Motive.'
|
||||
: 'Je genauer die Beschreibung, desto besser das Ergebnis.'}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === 'each' && (
|
||||
<div className="feld">
|
||||
<label>Was soll passieren?</label>
|
||||
<div className="modi">
|
||||
{TASKS.map((m) => {
|
||||
const on = tasks.includes(m.id);
|
||||
const disabled = m.id === 'contour' && !hasCutout;
|
||||
return (
|
||||
<button key={m.id} disabled={disabled}
|
||||
className={`modus ${on ? 'an' : ''} ${disabled ? 'aus' : ''}`}
|
||||
onClick={() => toggleTask(m.id)}>
|
||||
<b>{m.name}{on ? ' ✓' : ''}</b><span>{m.hint}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showFormat && (
|
||||
<div className="feld">
|
||||
<label>{mode === 'each' ? 'Ausgabeformat' : 'Format (optional)'}</label>
|
||||
<select className="select" value={format} onChange={(e) => setFormat(e.target.value)}>
|
||||
{FORMATS.map((f) => <option key={f.id} value={f.id}>{f.label}</option>)}
|
||||
</select>
|
||||
{isFreeFmt && (
|
||||
<>
|
||||
<input className="input" value={customFmt} onChange={(e) => setCustomFmt(e.target.value)}
|
||||
placeholder={format === 'sticker' ? 'z. B. 5 (=5×5 cm) oder 5x7' : 'z. B. 25x35 oder 5 für 5×5 cm'} />
|
||||
{freeFmtInvalid && <div className="fein warn">Bitte ein Maß angeben, z. B. „25x35" oder „5".</div>}
|
||||
</>
|
||||
)}
|
||||
{theframeConflict && <div className="fein warn">Freistellen + The Frame ergibt keinen Sinn — bitte eins wählen.</div>}
|
||||
{effCm && (
|
||||
<div className="schalter">
|
||||
<button className={portrait ? 'an' : ''} onClick={() => setPortrait(true)}>Hochformat</button>
|
||||
<button className={!portrait ? 'an' : ''} onClick={() => setPortrait(false)}>Querformat</button>
|
||||
</div>
|
||||
)}
|
||||
{zielPx && <div className="fein">Ergibt exakt {zielPx[0]} × {zielPx[1]} Pixel bei 300 dpi — druckfertig.</div>}
|
||||
{zielPx && mode !== 'generate' && (
|
||||
<div className="schalter zart">
|
||||
<button className={crop === 'crop' ? 'an' : ''} onClick={() => setCrop('crop')}>Zuschneiden</button>
|
||||
<button className={crop === 'extend' ? 'an' : ''} onClick={() => setCrop('extend')}>Erweitern</button>
|
||||
</div>
|
||||
)}
|
||||
{zielPx && mode !== 'generate' && crop === 'extend' && (
|
||||
<div className="fein">„Erweitern" erzeugt mehr Szene rund ums Motiv (Outpainting), statt zuzuschneiden — ideal für The Frame.</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="feld">
|
||||
<label>Dateiformat</label>
|
||||
<div className="schalter">
|
||||
<button className={outputExt === '' ? 'an' : ''} onClick={() => setOutputExt('')}>Standard</button>
|
||||
<button className={outputExt === 'png' ? 'an' : ''} onClick={() => setOutputExt('png')}>PNG</button>
|
||||
<button className={outputExt === 'jpg' ? 'an' : ''} onClick={() => setOutputExt('jpg')}>JPG</button>
|
||||
</div>
|
||||
<div className="fein">
|
||||
{outputExt === 'jpg' ? 'JPG — klein & Picdrop-freundlich. Freigestellte Motive bleiben trotzdem PNG (Transparenz).'
|
||||
: outputExt === 'png' ? 'PNG — verlustfrei, unterstützt Transparenz. Große Dateien.'
|
||||
: 'Standard aus den Admin-Einstellungen (global gesetzt).'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mode === 'each' && tasks.includes('contour') && (
|
||||
<div className="feld">
|
||||
<label>Stickerrand (mm)</label>
|
||||
<input className="input" type="number" min={0} max={20} step={0.5}
|
||||
value={contourMm} onChange={(e) => setContourMm(Number(e.target.value))} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{models.length > 0 && (
|
||||
<div className="feld">
|
||||
<label>Qualität</label>
|
||||
<div className="schalter wrap">
|
||||
{models.map((m) => (
|
||||
<button key={m.model_id} className={modelKey === m.model_id ? 'an' : ''}
|
||||
onClick={() => setModelKey(m.model_id)} title={m.description || ''}>{m.label}</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="fein">{models.find((m) => m.model_id === modelKey)?.description || ''}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === 'each' && (
|
||||
<div className="feld">
|
||||
<label>Eigene Anweisung (optional)</label>
|
||||
<textarea className="area" rows={2} value={custom} onChange={(e) => setCustom(e.target.value)}
|
||||
placeholder="z. B. „Mach den Hintergrund heller, sonst alles lassen.“" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="feld">
|
||||
<label>Wohin?</label>
|
||||
<div className="schalter">
|
||||
<button className={delivery === 'library' ? 'an' : ''} onClick={() => setDelivery('library')}>Bibliothek</button>
|
||||
<button className={delivery === 'picdrop' ? 'an' : ''} onClick={() => setDelivery('picdrop')}>Picdrop</button>
|
||||
<button className={delivery === 'both' ? 'an' : ''} onClick={() => setDelivery('both')}>Beides</button>
|
||||
</div>
|
||||
{delivery !== 'library' && (
|
||||
<div className="ziel">
|
||||
<select className="select" value={targetId} onChange={(e) => setTargetId(e.target.value)}>
|
||||
<option value="">Standard-Picdrop</option>
|
||||
<option value="nas">NAS</option>
|
||||
{targets.map((t) => <option key={t.id} value={t.id}>{t.name}</option>)}
|
||||
</select>
|
||||
<input className="input" value={gallery} onChange={(e) => setGallery(e.target.value)}
|
||||
placeholder="Galerie/Ordner (leer = Standard)" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(me.private_allowed || me.private_forced) && (
|
||||
<label className="privat">
|
||||
<input type="checkbox" checked={priv} disabled={me.private_forced} onChange={(e) => setPriv(e.target.checked)} />
|
||||
<span><b>Private Session {me.private_forced ? '(erzwungen)' : ''}</b>
|
||||
<em>Nur erzeugen, nichts merken: kein Eintrag in der Bibliothek, keine Auslieferung/Sicherung, kein gespeicherter Prompt. Ergebnis kurz danach abrufbar, dann gelöscht.</em></span>
|
||||
</label>
|
||||
)}
|
||||
|
||||
<button className="knopf" disabled={!canRun} onClick={run}>
|
||||
{busy ? <><span className="spin" />Wird angelegt …</>
|
||||
: mode === 'each' ? `Loslegen${ready.length ? ` · ${ready.length} Bild${ready.length > 1 ? 'er' : ''}` : ''}`
|
||||
: 'Bild erzeugen'}
|
||||
</button>
|
||||
<div className="fein mitte">Läuft serverseitig weiter — du kannst das Fenster schließen.</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{toast && <div className="toast">{toast}</div>}
|
||||
<StudioStyles />
|
||||
</div>
|
||||
@@ -287,8 +475,15 @@ export default function StudioApp({ recipes }: { recipes: any[] }) {
|
||||
|
||||
function StudioStyles() {
|
||||
return <style>{`
|
||||
.studio{display:grid;grid-template-columns:1.1fr .9fr;gap:18px;align-items:start;}
|
||||
@media(max-width:820px){.studio{grid-template-columns:1fr;}}
|
||||
.studio{display:flex;flex-direction:column;gap:16px;}
|
||||
.modus-leiste{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;}
|
||||
@media(max-width:560px){.modus-leiste{grid-template-columns:1fr;}}
|
||||
.modus-tab{text-align:left;background:var(--card);border:1px solid var(--line);border-radius:var(--radius-sm);padding:11px 13px;cursor:pointer;font-family:inherit;}
|
||||
.modus-tab b{display:block;font-size:14px;font-weight:600;}
|
||||
.modus-tab span{display:block;font-size:11px;color:var(--soft);margin-top:2px;line-height:1.4;}
|
||||
.modus-tab.an{border-color:var(--accent);background:var(--accent-bg);}
|
||||
.raster{display:grid;grid-template-columns:1.1fr .9fr;gap:18px;align-items:start;}
|
||||
@media(max-width:820px){.raster{grid-template-columns:1fr;}}
|
||||
.karte{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);}
|
||||
.kopfzeile{display:flex;justify-content:space-between;align-items:center;padding:12px 16px;border-bottom:1px solid var(--line);}
|
||||
.link{background:none;border:none;cursor:pointer;color:var(--accent);font-family:var(--font-mono);font-size:10px;letter-spacing:.14em;text-transform:uppercase;}
|
||||
@@ -305,12 +500,13 @@ function StudioStyles() {
|
||||
.mini-bild>button{position:absolute;top:3px;right:3px;width:20px;height:20px;border:none;border-radius:50%;background:rgba(22,21,15,.75);color:#fff;cursor:pointer;font-size:14px;line-height:1;}
|
||||
.mini-bild .badge{position:absolute;bottom:3px;left:3px;background:var(--err);color:#fff;width:18px;height:18px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700;}
|
||||
.lade{position:absolute;inset:0;background:var(--paper);opacity:.6;}
|
||||
.mini-add{aspect-ratio:3/4;border:1.5px dashed var(--line);background:none;border-radius:3px;cursor:pointer;font-size:22px;color:var(--soft);}
|
||||
.mini-add{aspect-ratio:1;border:1.5px dashed var(--line);background:none;border-radius:3px;cursor:pointer;font-size:22px;color:var(--soft);}
|
||||
.steuer{padding:16px;display:flex;flex-direction:column;gap:15px;}
|
||||
.feld label{display:block;font-family:var(--font-mono);font-size:10px;letter-spacing:.16em;text-transform:uppercase;color:var(--soft);margin-bottom:7px;}
|
||||
.input,.select,.area{width:100%;background:#fff;border:1px solid var(--line);border-radius:3px;padding:9px 11px;font-family:inherit;font-size:14px;color:var(--ink);outline:none;}
|
||||
.input:focus,.select:focus,.area:focus{border-color:var(--accent);}
|
||||
.area{font-size:13.5px;line-height:1.5;resize:vertical;}
|
||||
.area.gross{font-size:15px;line-height:1.55;}
|
||||
.fein{font-size:11.5px;color:var(--soft);margin-top:6px;line-height:1.5;}
|
||||
.fein.mitte{text-align:center;}
|
||||
.fein.warn{color:var(--err);}
|
||||
@@ -324,6 +520,14 @@ function StudioStyles() {
|
||||
.schalter button{flex:1;background:#fff;border:1px solid var(--line);border-radius:3px;padding:8px;cursor:pointer;font-family:inherit;font-size:13px;color:var(--soft);}
|
||||
.schalter button.an{border-color:var(--accent);background:var(--accent-bg);color:var(--ink);font-weight:600;}
|
||||
.schalter.zart button{font-size:12.5px;}
|
||||
.ziel{display:flex;flex-direction:column;gap:7px;margin-top:8px;}
|
||||
.reihe-presets{display:flex;gap:6px;align-items:center;}
|
||||
.reihe-presets .select{flex:1;}
|
||||
.pbtn{background:#fff;border:1px solid var(--line);border-radius:3px;padding:8px 10px;cursor:pointer;font-family:inherit;font-size:12px;color:var(--ink);white-space:nowrap;}
|
||||
.privat{display:flex;gap:10px;align-items:flex-start;cursor:pointer;background:var(--paper);border:1px solid var(--line);border-radius:4px;padding:11px;}
|
||||
.privat input{margin-top:3px;width:16px;height:16px;accent-color:var(--accent);flex:0 0 auto;}
|
||||
.privat b{display:block;font-size:13.5px;}
|
||||
.privat em{display:block;font-style:normal;font-size:11.5px;color:var(--soft);margin-top:2px;line-height:1.45;}
|
||||
.schalter.wrap{flex-wrap:wrap;}
|
||||
.schalter.wrap button{flex:1 1 auto;min-width:110px;}
|
||||
.knopf{width:100%;border:none;border-radius:3px;padding:13px;cursor:pointer;background:var(--accent);color:#fff;font-family:inherit;font-weight:600;font-size:15px;}
|
||||
|
||||
@@ -14,7 +14,9 @@ const active = (href: string) => href === '/' ? path === '/' : path.startsWith(h
|
||||
const nav = [
|
||||
{ href: '/', label: 'Studio', icon: 'studio' },
|
||||
{ href: '/bibliothek', label: 'Bibliothek', icon: 'library' },
|
||||
{ href: '/druck', label: 'Druck', icon: 'print' },
|
||||
{ href: '/warteschlange', label: 'Warteschlange', icon: 'queue' },
|
||||
{ href: '/anleitung', label: 'Hilfe', icon: 'help' },
|
||||
...(user?.role === 'admin' ? [{ href: '/admin', label: 'Admin', icon: 'admin' }] : []),
|
||||
];
|
||||
|
||||
@@ -24,6 +26,8 @@ const ICONS: Record<string, string> = {
|
||||
library: `<svg viewBox="0 0 24 24" ${S}><rect x="3.5" y="3.5" width="7" height="7" rx="1.5"/><rect x="13.5" y="3.5" width="7" height="7" rx="1.5"/><rect x="3.5" y="13.5" width="7" height="7" rx="1.5"/><rect x="13.5" y="13.5" width="7" height="7" rx="1.5"/></svg>`,
|
||||
queue: `<svg viewBox="0 0 24 24" ${S}><path d="M9 6h11M9 12h11M9 18h7"/><circle cx="4.5" cy="6" r="1.15" fill="currentColor" stroke="none"/><circle cx="4.5" cy="12" r="1.15" fill="currentColor" stroke="none"/><circle cx="4.5" cy="18" r="1.15" fill="currentColor" stroke="none"/></svg>`,
|
||||
admin: `<svg viewBox="0 0 24 24" ${S}><path d="M4 8h9M17 8h3M4 16h3M11 16h9"/><circle cx="15" cy="8" r="2.2"/><circle cx="9" cy="16" r="2.2"/></svg>`,
|
||||
print: `<svg viewBox="0 0 24 24" ${S}><path d="M7 9V4.5h10V9"/><rect x="3.5" y="9" width="17" height="7" rx="2"/><path d="M7 14h10v5.5H7z"/></svg>`,
|
||||
help: `<svg viewBox="0 0 24 24" ${S}><circle cx="12" cy="12" r="8.5"/><path d="M9.6 9.4a2.5 2.5 0 1 1 3.4 2.3c-.7.3-1 .8-1 1.6v.3"/><circle cx="12" cy="16.7" r="0.6" fill="currentColor" stroke="none"/></svg>`,
|
||||
};
|
||||
---
|
||||
<!doctype html>
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
// Generische Datensicherung: spiegelt fertige Ergebnisse auf alle Backup-Ziele
|
||||
// (NAS aus den Einstellungen + jedes delivery_target mit is_backup), optional mit
|
||||
// begleitender Metadaten-.md-Datei. Struktur je Ziel: <Basisordner>/JJJJ-MM/.
|
||||
import { one, query } from './db';
|
||||
import { getObject } from './storage';
|
||||
import { uploadBuffer, loadTargetConfig, type PicdropCfg } from './picdrop';
|
||||
import { loadNasConfig } from './nas';
|
||||
import { buildMetadataMd, sidecarName } from './metadata';
|
||||
|
||||
export interface BackupDest { key: string; label: string; cfg: PicdropCfg; sidecar: boolean; }
|
||||
|
||||
/** Alle aktiven Backup-Ziele einsammeln (inkl. Metadaten-Beileger-Flag pro Ziel). */
|
||||
export async function backupDestinations(): Promise<BackupDest[]> {
|
||||
const dests: BackupDest[] = [];
|
||||
const nas = await loadNasConfig();
|
||||
if (nas) {
|
||||
const s = await one<any>('SELECT nas_metadata_sidecar FROM settings WHERE id=1');
|
||||
dests.push({ key: 'nas', label: 'NAS', cfg: nas, sidecar: !!s?.nas_metadata_sidecar });
|
||||
}
|
||||
const targets = await query<any>(`SELECT id, name, metadata_sidecar FROM delivery_targets WHERE is_backup=true`);
|
||||
for (const t of targets) {
|
||||
const cfg = await loadTargetConfig(t.id);
|
||||
if (cfg) dests.push({ key: t.id, label: t.name, cfg, sidecar: !!t.metadata_sidecar });
|
||||
}
|
||||
return dests;
|
||||
}
|
||||
|
||||
/** Ein Item auf alle Backup-Ziele spiegeln. */
|
||||
export async function backupItem(itemId: string): Promise<{ mirrored: number; failed: number }> {
|
||||
const dests = await backupDestinations();
|
||||
if (!dests.length) return { mirrored: 0, failed: 0 };
|
||||
const it = await one<any>('SELECT id, result_path, filename, created_at FROM items WHERE id=$1', [itemId]);
|
||||
if (!it?.result_path) return { mirrored: 0, failed: 0 };
|
||||
|
||||
const buf = await getObject(it.result_path);
|
||||
const ym = new Date(it.created_at || Date.now()).toISOString().slice(0, 7);
|
||||
let md: Buffer | null = null;
|
||||
const anySidecar = dests.some((d) => d.sidecar);
|
||||
if (anySidecar) md = Buffer.from(await buildMetadataMd(itemId), 'utf8');
|
||||
|
||||
let mirrored = 0, failed = 0;
|
||||
for (const d of dests) {
|
||||
try {
|
||||
await uploadBuffer(d.cfg, ym, it.filename || `${it.id}.png`, buf);
|
||||
if (d.sidecar && md) await uploadBuffer(d.cfg, ym, sidecarName(it.filename), md);
|
||||
await query(`INSERT INTO item_backups (item_id, target, status) VALUES ($1,$2,'mirrored')
|
||||
ON CONFLICT (item_id, target) DO UPDATE SET status='mirrored', at=now()`, [itemId, d.key]);
|
||||
if (d.key === 'nas') await query(`UPDATE items SET nas_status='mirrored' WHERE id=$1`, [itemId]);
|
||||
mirrored++;
|
||||
} catch (e: any) {
|
||||
console.error('[backup]', d.label, itemId, e?.message || e);
|
||||
await query(`INSERT INTO item_backups (item_id, target, status) VALUES ($1,$2,'failed')
|
||||
ON CONFLICT (item_id, target) DO UPDATE SET status='failed', at=now()`, [itemId, d.key]);
|
||||
if (d.key === 'nas') await query(`UPDATE items SET nas_status='failed' WHERE id=$1`, [itemId]);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
return { mirrored, failed };
|
||||
}
|
||||
|
||||
/** Alle fertigen Bilder auf alle Backup-Ziele sichern (Nachsicherung). */
|
||||
export async function backupAll(): Promise<{ items: number; mirrored: number; failed: number }> {
|
||||
const dests = await backupDestinations();
|
||||
if (!dests.length) return { items: 0, mirrored: 0, failed: 0 };
|
||||
const rows = await query<{ id: string }>(
|
||||
`SELECT id FROM items WHERE status='done' AND result_path IS NOT NULL ORDER BY created_at`);
|
||||
let mirrored = 0, failed = 0;
|
||||
for (const it of rows) { const r = await backupItem(it.id); mirrored += r.mirrored; failed += r.failed; }
|
||||
return { items: rows.length, mirrored, failed };
|
||||
}
|
||||
+44
-5
@@ -1,6 +1,6 @@
|
||||
import { one, query } from './db';
|
||||
import { getObject } from './storage';
|
||||
import { loadConfig, uploadBuffer } from './picdrop';
|
||||
import { loadConfig, loadTargetConfig, uploadBuffer, type PicdropCfg } from './picdrop';
|
||||
|
||||
// Standard-Galerie wie initial gebrieft.
|
||||
const DEFAULT_GALLERY = process.env.PICDROP_DEFAULT_GALLERY || 'POSTER LEA';
|
||||
@@ -17,18 +17,57 @@ async function galleryFor(item: { folder_id: string | null; job_id: string }): P
|
||||
return s?.picdrop_default_gallery || DEFAULT_GALLERY;
|
||||
}
|
||||
|
||||
/** Liefert ein fertiges Item an die passende Picdrop-Galerie aus. */
|
||||
export async function cfgForKey(key: string | null | undefined): Promise<PicdropCfg | null> {
|
||||
if (!key) return null;
|
||||
if (key === 'nas') { const { loadNasConfig } = await import('./nas'); return (await loadNasConfig()) as unknown as PicdropCfg; }
|
||||
if (key === 'picdrop') return loadConfig();
|
||||
return loadTargetConfig(key); // UUID eines Zusatzziels
|
||||
}
|
||||
|
||||
/** Ziel-Zugang inkl. Quell-Schlüssel: 1) Ordner-Ziel 2) Rezept-Ziel 3) Standard-Picdrop. */
|
||||
async function targetFor(item: { folder_id: string | null; job_id: string }): Promise<{ cfg: PicdropCfg; key: string } | null> {
|
||||
if (item.folder_id) {
|
||||
const f = await one<{ delivery_target_id: string | null }>('SELECT delivery_target_id FROM folders WHERE id=$1', [item.folder_id]).catch(() => null);
|
||||
if (f?.delivery_target_id) { const c = await cfgForKey(f.delivery_target_id); if (c) return { cfg: c, key: f.delivery_target_id }; }
|
||||
}
|
||||
const j = await one<{ recipe_snapshot: any }>('SELECT recipe_snapshot FROM jobs WHERE id=$1', [item.job_id]);
|
||||
const tid = j?.recipe_snapshot?.delivery_target_id;
|
||||
if (tid) { const c = await cfgForKey(tid); if (c) return { cfg: c, key: tid }; }
|
||||
const c = await loadConfig();
|
||||
return c ? { cfg: c, key: 'picdrop' } : null;
|
||||
}
|
||||
|
||||
/** Ist der Metadaten-Beileger für diese Quelle aktiv? */
|
||||
async function sidecarForKey(key: string): Promise<boolean> {
|
||||
if (key === 'picdrop') { const s = await one<any>('SELECT picdrop_metadata_sidecar FROM settings WHERE id=1'); return !!s?.picdrop_metadata_sidecar; }
|
||||
if (key === 'nas') { const s = await one<any>('SELECT nas_metadata_sidecar FROM settings WHERE id=1'); return !!s?.nas_metadata_sidecar; }
|
||||
const t = await one<any>('SELECT metadata_sidecar FROM delivery_targets WHERE id=$1', [key]);
|
||||
return !!t?.metadata_sidecar;
|
||||
}
|
||||
|
||||
/** Liefert ein fertiges Item an die passende Picdrop-Galerie aus.
|
||||
* Ausgeliefert wird das gespeicherte Ergebnis **im gewählten Format** (PNG/JPG laut
|
||||
* globaler bzw. Rezept-Einstellung) — keine erzwungene Umwandlung mehr. */
|
||||
export async function deliverItem(itemId: string): Promise<{ ok: boolean; message: string }> {
|
||||
const it = await one<any>('SELECT id, result_path, filename, folder_id, job_id FROM items WHERE id=$1', [itemId]);
|
||||
if (!it?.result_path) return { ok: false, message: 'Kein Ergebnis vorhanden.' };
|
||||
const cfg = await loadConfig();
|
||||
if (!cfg) { await query(`UPDATE items SET delivery_status='failed' WHERE id=$1`, [itemId]); return { ok: false, message: 'Picdrop nicht konfiguriert.' }; }
|
||||
const target = await targetFor(it);
|
||||
if (!target) { await query(`UPDATE items SET delivery_status='failed' WHERE id=$1`, [itemId]); return { ok: false, message: 'Kein Auslieferungsziel konfiguriert.' }; }
|
||||
const { cfg, key } = target;
|
||||
|
||||
await query(`UPDATE items SET delivery_status='pending' WHERE id=$1`, [itemId]);
|
||||
try {
|
||||
const gallery = await galleryFor(it);
|
||||
const buf = await getObject(it.result_path);
|
||||
await uploadBuffer(cfg, gallery, it.filename || `${it.id}.png`, buf);
|
||||
const name = it.filename || `${it.id}.png`;
|
||||
await uploadBuffer(cfg, gallery, name, buf);
|
||||
// Optional: Metadaten als begleitende .md-Datei — pro Quelle steuerbar.
|
||||
if (await sidecarForKey(key)) {
|
||||
try {
|
||||
const { buildMetadataMd, sidecarName } = await import('./metadata');
|
||||
await uploadBuffer(cfg, gallery, sidecarName(name), Buffer.from(await buildMetadataMd(itemId), 'utf8'));
|
||||
} catch (e) { console.error('[delivery] Metadaten-Beileger fehlgeschlagen', e); }
|
||||
}
|
||||
await query(`UPDATE items SET delivery_status='delivered', delivered_at=now() WHERE id=$1`, [itemId]);
|
||||
return { ok: true, message: `Ausgeliefert nach „${gallery}".` };
|
||||
} catch (e: any) {
|
||||
|
||||
+11
-1
@@ -45,7 +45,17 @@ export function resolveDimensions(input: ResolveInput): Dimensions | null {
|
||||
let cm: [number, number] | undefined;
|
||||
if (format in CM) cm = CM[format];
|
||||
else if (format === 'sticker' || format === 'custom') cm = input.customCm ?? [5, 5];
|
||||
if (!cm) throw new Error(`Unbekanntes Format: ${format}`);
|
||||
else {
|
||||
// Frei definierte Formate direkt aus dem Schlüssel lesen — keine DB-Migration nötig:
|
||||
// „sticker5" / „sticker-7,5" → quadratisch N×N cm; „25x35" / „25×35" → B×H cm.
|
||||
const sq = /^sticker[-_ ]?(\d+(?:[.,]\d+)?)$/i.exec(format);
|
||||
const wh = /^(\d+(?:[.,]\d+)?)\s*[x×*]\s*(\d+(?:[.,]\d+)?)$/i.exec(format);
|
||||
const num = (s: string) => parseFloat(s.replace(',', '.'));
|
||||
if (sq) { const n = num(sq[1]); cm = [n, n]; }
|
||||
else if (wh) cm = [num(wh[1]), num(wh[2])];
|
||||
}
|
||||
if (!cm || !cm[0] || !cm[1] || cm[0] > 300 || cm[1] > 300)
|
||||
throw new Error(`Unbekanntes Format: ${format}`);
|
||||
|
||||
// Hochformat-Konvention → gewünschte Orientierung anwenden
|
||||
let [wCm, hCm] = cm;
|
||||
|
||||
@@ -15,6 +15,8 @@ export function ensureInit(): Promise<void> {
|
||||
try { await startImageWorker(); } catch (e) { console.error('[init] Worker-Start fehlgeschlagen:', e); }
|
||||
try { const { setupTelegram } = await import('./telegram'); await setupTelegram(); }
|
||||
catch (e) { console.error('[init] Telegram-Setup fehlgeschlagen:', e); }
|
||||
try { const { startMaintenance } = await import('./maintenance'); startMaintenance(); }
|
||||
catch (e) { console.error('[init] Wartung-Start fehlgeschlagen:', e); }
|
||||
console.log('[init] Klarbild bereit.');
|
||||
})().catch((e) => { started = null; throw e; });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
// Speicherverwaltung: Nutzung anzeigen, Aufräumen (Retention), Quellen löschen.
|
||||
// Ziel: „den Server nicht vollmüllen".
|
||||
import { stat, readdir } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { one, query } from './db';
|
||||
import { deleteObject } from './storage';
|
||||
|
||||
const DRIVER = (process.env.STORAGE_DRIVER || 'fs').toLowerCase();
|
||||
const DIR = process.env.STORAGE_DIR || '/data';
|
||||
|
||||
async function dirBytes(path: string): Promise<{ bytes: number; files: number }> {
|
||||
let bytes = 0, files = 0;
|
||||
let entries: any[] = [];
|
||||
try { entries = await readdir(path, { withFileTypes: true }); } catch { return { bytes, files }; }
|
||||
for (const e of entries) {
|
||||
const p = join(path, e.name);
|
||||
if (e.isDirectory()) { const s = await dirBytes(p); bytes += s.bytes; files += s.files; }
|
||||
else { try { const st = await stat(p); bytes += st.size; files++; } catch { /* ignore */ } }
|
||||
}
|
||||
return { bytes, files };
|
||||
}
|
||||
|
||||
export interface StorageStats {
|
||||
driver: string; bytes: number | null; files: number | null;
|
||||
sources: number; results: number; thumbs: number; items: number;
|
||||
}
|
||||
|
||||
/** Speicher-Kennzahlen. Byte-Genauigkeit nur beim fs-Treiber. */
|
||||
export async function storageStats(): Promise<StorageStats> {
|
||||
const counts = await one<any>(`SELECT
|
||||
count(*) FILTER (WHERE source_path IS NOT NULL)::int AS sources,
|
||||
count(*) FILTER (WHERE result_path IS NOT NULL)::int AS results,
|
||||
count(*) FILTER (WHERE thumb_path IS NOT NULL)::int AS thumbs,
|
||||
count(*)::int AS items FROM items`);
|
||||
let bytes: number | null = null, files: number | null = null;
|
||||
if (DRIVER === 'fs') { const s = await dirBytes(DIR); bytes = s.bytes; files = s.files; }
|
||||
return { driver: DRIVER, bytes, files,
|
||||
sources: counts?.sources || 0, results: counts?.results || 0,
|
||||
thumbs: counts?.thumbs || 0, items: counts?.items || 0 };
|
||||
}
|
||||
|
||||
/** Löscht Positionen (samt Objekten), die älter als N Tage sind. */
|
||||
export async function runRetention(days: number): Promise<{ deleted: number }> {
|
||||
if (!days || days <= 0) return { deleted: 0 };
|
||||
const rows = await query<any>(
|
||||
`SELECT id, source_path, source_paths, result_path, thumb_path FROM items
|
||||
WHERE created_at < now() - ($1 || ' days')::interval`, [String(days)]);
|
||||
for (const it of rows) {
|
||||
const keys = [it.source_path, it.result_path, it.thumb_path,
|
||||
...((it.source_paths as string[]) || [])].filter(Boolean);
|
||||
for (const k of keys) await deleteObject(k).catch(() => {});
|
||||
await query('DELETE FROM items WHERE id=$1', [it.id]);
|
||||
}
|
||||
return { deleted: rows.length };
|
||||
}
|
||||
|
||||
/** Erzeugt fehlende Vorschaubilder nach (für schnelle Bibliotheks-Anzeige). */
|
||||
export async function rebuildThumbs(): Promise<{ made: number }> {
|
||||
const { getObject, putObject, thumbKey } = await import('./storage');
|
||||
const sharp = (await import('sharp')).default;
|
||||
const rows = await query<any>(
|
||||
`SELECT id, result_path FROM items WHERE status='done' AND result_path IS NOT NULL AND thumb_path IS NULL`);
|
||||
let made = 0;
|
||||
for (const it of rows) {
|
||||
try {
|
||||
const out = await sharp(await getObject(it.result_path), { failOn: 'none' })
|
||||
.resize(600, 600, { fit: 'inside', withoutEnlargement: true }).webp({ quality: 72 }).toBuffer();
|
||||
const tk = thumbKey(it.id);
|
||||
await putObject(tk, out, 'image/webp');
|
||||
await query(`UPDATE items SET thumb_path=$2 WHERE id=$1`, [it.id, tk]);
|
||||
made++;
|
||||
} catch (e) { console.error('[maintenance] Thumb', it.id, e); }
|
||||
}
|
||||
return { made };
|
||||
}
|
||||
|
||||
/** Löscht nur die Quellbilder fertiger Positionen (Ergebnisse bleiben). */
|
||||
export async function purgeSources(): Promise<{ purged: number }> {
|
||||
const rows = await query<any>(
|
||||
`SELECT id, source_path, source_paths FROM items WHERE status='done'
|
||||
AND (source_path IS NOT NULL OR source_paths IS NOT NULL)`);
|
||||
let purged = 0;
|
||||
for (const it of rows) {
|
||||
const keys = [it.source_path, ...((it.source_paths as string[]) || [])].filter(Boolean);
|
||||
for (const k of keys) await deleteObject(k).catch(() => {});
|
||||
await query('UPDATE items SET source_path=NULL, source_paths=NULL WHERE id=$1', [it.id]);
|
||||
purged += keys.length;
|
||||
}
|
||||
return { purged };
|
||||
}
|
||||
|
||||
/** Spiegelt alle fertigen Bilder auf alle Backup-Ziele (NAS + is_backup-Ziele). */
|
||||
export async function mirrorAllToNas(): Promise<{ mirrored: number; failed: number; total: number }> {
|
||||
const { backupAll } = await import('./backup');
|
||||
const r = await backupAll();
|
||||
return { mirrored: r.mirrored, failed: r.failed, total: r.items };
|
||||
}
|
||||
|
||||
/** Private Aufträge „vergessen": Ergebnisse + Aufträge nach kurzer Zeit löschen. */
|
||||
export async function purgePrivate(olderThanMin = 60): Promise<{ deleted: number }> {
|
||||
const rows = await query<any>(
|
||||
`SELECT i.id, i.result_path, i.thumb_path FROM items i JOIN jobs j ON j.id=i.job_id
|
||||
WHERE j.private=true AND i.created_at < now() - ($1 || ' minutes')::interval`, [String(olderThanMin)]);
|
||||
for (const it of rows) {
|
||||
for (const k of [it.result_path, it.thumb_path].filter(Boolean)) await deleteObject(k).catch(() => {});
|
||||
await query('DELETE FROM items WHERE id=$1', [it.id]);
|
||||
}
|
||||
// leere private Aufträge entfernen
|
||||
await query(`DELETE FROM jobs WHERE private=true AND id NOT IN (SELECT DISTINCT job_id FROM items WHERE job_id IS NOT NULL)`);
|
||||
return { deleted: rows.length };
|
||||
}
|
||||
|
||||
let timer: NodeJS.Timeout | null = null;
|
||||
/** Wartungs-Lauf: Retention + private Sessions vergessen. */
|
||||
export function startMaintenance(): void {
|
||||
if (timer) return;
|
||||
const tick = async () => {
|
||||
try {
|
||||
const s = await one<{ retention_days: number | null }>('SELECT retention_days FROM settings WHERE id=1');
|
||||
if (s?.retention_days && s.retention_days > 0) {
|
||||
const r = await runRetention(s.retention_days);
|
||||
if (r.deleted) console.log(`[maintenance] Retention: ${r.deleted} alte Positionen entfernt.`);
|
||||
}
|
||||
const p = await purgePrivate(60);
|
||||
if (p.deleted) console.log(`[maintenance] Privat: ${p.deleted} Ergebnisse vergessen.`);
|
||||
} catch (e) { console.error('[maintenance]', e); }
|
||||
};
|
||||
timer = setInterval(tick, 30 * 60 * 1000); // alle 30 min (auch für private Purge)
|
||||
setTimeout(tick, 60 * 1000); // erster Lauf nach 1 min
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { one } from './db';
|
||||
|
||||
/** Baut eine begleitende Metadaten-Datei (Markdown) zu einer Position. */
|
||||
export async function buildMetadataMd(itemId: string): Promise<string> {
|
||||
const it = await one<any>(
|
||||
`SELECT i.filename, i.output_px, i.has_alpha, i.model_used, i.prompt_used, i.cost,
|
||||
i.created_at, i.delivery_status, i.color_tag, j.mode, j.recipe_snapshot
|
||||
FROM items i JOIN jobs j ON j.id=i.job_id WHERE i.id=$1`, [itemId]);
|
||||
if (!it) return '';
|
||||
const r = it.recipe_snapshot || {};
|
||||
const tasks = Array.isArray(r.tasks) ? r.tasks.join(', ') : '';
|
||||
const TAG: Record<string, string> = { red: 'Rot', orange: 'Orange', green: 'Grün', final: 'Final' };
|
||||
const lines = [
|
||||
`# ${it.filename || itemId}`,
|
||||
'',
|
||||
`- **Erzeugt:** ${new Date(it.created_at).toISOString()}`,
|
||||
`- **Modus:** ${it.mode || 'each'}`,
|
||||
tasks ? `- **Aufgaben:** ${tasks}` : '',
|
||||
r.output_format ? `- **Format:** ${r.output_format}${r.orientation ? ` (${r.orientation})` : ''}` : '',
|
||||
it.output_px ? `- **Auflösung:** ${it.output_px} px${r.dpi ? ` @ ${r.dpi} dpi` : ''}` : '',
|
||||
`- **Transparenz:** ${it.has_alpha ? 'ja' : 'nein'}`,
|
||||
it.model_used ? `- **Modell:** ${it.model_used}` : '',
|
||||
it.color_tag ? `- **Markierung:** ${TAG[it.color_tag] || it.color_tag}` : '',
|
||||
it.cost != null ? `- **Kosten:** $${Number(it.cost).toFixed(4)}` : '',
|
||||
'',
|
||||
'## Prompt',
|
||||
'',
|
||||
'```',
|
||||
(it.prompt_used || '(kein Prompt gespeichert)'),
|
||||
'```',
|
||||
].filter((l) => l !== '');
|
||||
return lines.join('\n') + '\n';
|
||||
}
|
||||
|
||||
/** Dateiname des Beilegers: <ergebnis>.md */
|
||||
export function sidecarName(filename: string | null): string {
|
||||
const base = (filename || 'klarbild').replace(/\.[^.]+$/, '');
|
||||
return `${base}.md`;
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
// Zweite Datensicherung auf ein Synology-NAS (SFTP oder FTPS, wie Picdrop).
|
||||
// Ergebnisse werden zusätzlich zur Bibliothek/Picdrop auf das NAS gespiegelt.
|
||||
import posixpath from 'node:path/posix';
|
||||
import { one, query } from './db';
|
||||
import { decrypt } from './crypto';
|
||||
import { getObject } from './storage';
|
||||
|
||||
export interface NasCfg {
|
||||
host: string; protocol: 'ftps' | 'sftp'; port: number;
|
||||
user: string; password: string; basePath: string;
|
||||
}
|
||||
|
||||
export async function loadNasConfig(): Promise<NasCfg | null> {
|
||||
const s = await one<any>(`SELECT nas_enabled, nas_host, nas_protocol, nas_port, nas_user,
|
||||
nas_password_enc, nas_base_path FROM settings WHERE id=1`);
|
||||
if (!s?.nas_enabled || !s?.nas_host || !s?.nas_user || !s?.nas_password_enc) return null;
|
||||
let password = '';
|
||||
try { password = decrypt(s.nas_password_enc); } catch { return null; }
|
||||
return {
|
||||
host: s.nas_host, protocol: (s.nas_protocol || 'sftp'),
|
||||
port: s.nas_port || (s.nas_protocol === 'ftps' ? 21 : 22),
|
||||
user: s.nas_user, password, basePath: s.nas_base_path || '/',
|
||||
};
|
||||
}
|
||||
|
||||
export async function testNas(cfg: NasCfg): Promise<{ ok: boolean; message: string }> {
|
||||
try {
|
||||
if (cfg.protocol === 'sftp') {
|
||||
const SftpClient = (await import('ssh2-sftp-client')).default;
|
||||
const c = new SftpClient();
|
||||
await c.connect({ host: cfg.host, port: cfg.port, username: cfg.user, password: cfg.password, readyTimeout: 15000 });
|
||||
await c.list(cfg.basePath || '/');
|
||||
await c.end();
|
||||
} else {
|
||||
const { Client } = await import('basic-ftp');
|
||||
const c = new Client(15000);
|
||||
await c.access({ host: cfg.host, port: cfg.port, user: cfg.user, password: cfg.password, secure: true });
|
||||
await c.list(cfg.basePath || '/');
|
||||
c.close();
|
||||
}
|
||||
return { ok: true, message: 'NAS-Verbindung erfolgreich.' };
|
||||
} catch (e: any) {
|
||||
return { ok: false, message: e?.message || 'NAS-Verbindung fehlgeschlagen.' };
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadToNas(cfg: NasCfg, remoteDir: string, filename: string, buf: Buffer): Promise<void> {
|
||||
const dir = posixpath.join(cfg.basePath || '/', remoteDir);
|
||||
const finalPath = posixpath.join(dir, filename);
|
||||
const { Readable } = await import('node:stream');
|
||||
if (cfg.protocol === 'sftp') {
|
||||
const SftpClient = (await import('ssh2-sftp-client')).default;
|
||||
const c = new SftpClient();
|
||||
await c.connect({ host: cfg.host, port: cfg.port, username: cfg.user, password: cfg.password, readyTimeout: 20000 });
|
||||
try {
|
||||
if (!(await c.exists(dir))) await c.mkdir(dir, true);
|
||||
await c.put(buf, finalPath);
|
||||
} finally { await c.end(); }
|
||||
} else {
|
||||
const { Client } = await import('basic-ftp');
|
||||
const c = new Client(20000);
|
||||
await c.access({ host: cfg.host, port: cfg.port, user: cfg.user, password: cfg.password, secure: true });
|
||||
try {
|
||||
await c.ensureDir(dir);
|
||||
await c.uploadFrom(Readable.from(buf), finalPath);
|
||||
} finally { c.close(); }
|
||||
}
|
||||
}
|
||||
|
||||
/** Spiegelt ein fertiges Item auf das NAS (Ordnerstruktur klarbild/JJJJ-MM/). */
|
||||
export async function mirrorItemToNas(itemId: string): Promise<{ ok: boolean; message: string }> {
|
||||
const cfg = await loadNasConfig();
|
||||
if (!cfg) return { ok: false, message: 'NAS nicht konfiguriert.' };
|
||||
const it = await one<any>('SELECT id, result_path, filename, created_at FROM items WHERE id=$1', [itemId]);
|
||||
if (!it?.result_path) return { ok: false, message: 'Kein Ergebnis vorhanden.' };
|
||||
await query(`UPDATE items SET nas_status='pending' WHERE id=$1`, [itemId]);
|
||||
try {
|
||||
const buf = await getObject(it.result_path);
|
||||
const ym = new Date(it.created_at || Date.now()).toISOString().slice(0, 7); // JJJJ-MM
|
||||
// Struktur: <Basisordner>/JJJJ-MM/<Datei> — der Basisordner ist frei wählbar.
|
||||
await uploadToNas(cfg, ym, it.filename || `${it.id}.png`, buf);
|
||||
await query(`UPDATE items SET nas_status='mirrored' WHERE id=$1`, [itemId]);
|
||||
return { ok: true, message: 'Auf NAS gesichert.' };
|
||||
} catch (e: any) {
|
||||
console.error('[nas] mirror', itemId, 'fehlgeschlagen:', e?.message || e);
|
||||
await query(`UPDATE items SET nas_status='failed' WHERE id=$1`, [itemId]);
|
||||
return { ok: false, message: e?.message || 'NAS-Sicherung fehlgeschlagen.' };
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,8 @@ function friendlyFor(status: number): string {
|
||||
export interface GenerateOpts {
|
||||
model: string;
|
||||
prompt: string;
|
||||
inputUrl?: string; // vorsignierte URL des Originals
|
||||
inputUrl?: string; // eine Vorlage (Data-URL) — Kurzform
|
||||
inputUrls?: string[]; // mehrere Vorlagen (compose) — bis zu 14/16 je Modell
|
||||
aspectRatio?: string; // "16:9", "3:4" …
|
||||
resolution?: string; // "2K" | "4K" — NICHT zusammen mit expliziten Pixeln
|
||||
background?: 'transparent' | 'opaque';
|
||||
@@ -56,7 +57,8 @@ export async function generateImage(opts: GenerateOpts): Promise<GenerateResult>
|
||||
n: 1,
|
||||
output_format: opts.outputFormat || 'png',
|
||||
};
|
||||
if (opts.inputUrl) body.input_references = [{ type: 'image_url', image_url: { url: opts.inputUrl } }];
|
||||
const refs = opts.inputUrls?.length ? opts.inputUrls : (opts.inputUrl ? [opts.inputUrl] : []);
|
||||
if (refs.length) body.input_references = refs.map((url) => ({ type: 'image_url', image_url: { url } }));
|
||||
if (opts.aspectRatio) body.aspect_ratio = opts.aspectRatio;
|
||||
if (opts.resolution && !opts.aspectRatio) body.resolution = opts.resolution; // nie beides
|
||||
if (opts.background) body.background = opts.background;
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
// Papier- und Bildformate für den Druckbogen (Modul „Druck", ohne KI).
|
||||
// Alle Maße in Millimetern — mm ist die Leitwährung dieses Moduls, weil ein
|
||||
// Druckbogen physisch ist. Pixel entstehen erst beim Rendern (mm → dpi).
|
||||
|
||||
export interface PaperSize { id: string; label: string; w: number; h: number; group: string }
|
||||
|
||||
/** Bogenformate (Hochformat-Konvention: kurze Seite × lange Seite). */
|
||||
export const PAPERS: PaperSize[] = [
|
||||
// DIN
|
||||
{ id: 'A6', label: 'DIN A6', w: 105, h: 148, group: 'DIN' },
|
||||
{ id: 'A5', label: 'DIN A5', w: 148, h: 210, group: 'DIN' },
|
||||
{ id: 'A4', label: 'DIN A4', w: 210, h: 297, group: 'DIN' },
|
||||
{ id: 'A3', label: 'DIN A3', w: 297, h: 420, group: 'DIN' },
|
||||
{ id: 'A3plus', label: 'DIN A3+ (Super A3)', w: 329, h: 483, group: 'DIN' },
|
||||
{ id: 'A2', label: 'DIN A2', w: 420, h: 594, group: 'DIN' },
|
||||
// Fotopapier (Zuschnitte, wie sie im Fachhandel liegen)
|
||||
{ id: 'F9x13', label: 'Fotopapier 9 × 13 cm', w: 90, h: 130, group: 'Fotopapier' },
|
||||
{ id: 'F10x15', label: 'Fotopapier 10 × 15 cm', w: 100, h: 150, group: 'Fotopapier' },
|
||||
{ id: 'F13x18', label: 'Fotopapier 13 × 18 cm', w: 130, h: 180, group: 'Fotopapier' },
|
||||
{ id: 'F15x20', label: 'Fotopapier 15 × 20 cm', w: 150, h: 200, group: 'Fotopapier' },
|
||||
{ id: 'F20x30', label: 'Fotopapier 20 × 30 cm', w: 200, h: 300, group: 'Fotopapier' },
|
||||
// US
|
||||
{ id: 'Letter', label: 'US Letter', w: 215.9, h: 279.4, group: 'US' },
|
||||
{ id: 'Legal', label: 'US Legal', w: 215.9, h: 355.6, group: 'US' },
|
||||
];
|
||||
|
||||
/** Bildformate (Endformat eines einzelnen Bildes auf dem Bogen). */
|
||||
export interface PhotoSize { id: string; label: string; w: number; h: number; group: string }
|
||||
|
||||
export const PHOTO_SIZES: PhotoSize[] = [
|
||||
// Passbild / Ausweis
|
||||
{ id: 'P35x45', label: 'Biometrisches Passbild 35 × 45 mm', w: 35, h: 45, group: 'Passbild' },
|
||||
{ id: 'P50x50', label: 'Visum USA 50 × 50 mm (2 × 2 in)', w: 50.8, h: 50.8, group: 'Passbild' },
|
||||
// Kleinformate (Kita, Schule, Portemonnaie)
|
||||
{ id: 'K20x30', label: '2 × 3 cm', w: 20, h: 30, group: 'Klein' },
|
||||
{ id: 'K30x40', label: '3 × 4 cm', w: 30, h: 40, group: 'Klein' },
|
||||
{ id: 'K40x50', label: '4 × 5 cm', w: 40, h: 50, group: 'Klein' },
|
||||
{ id: 'K45x60', label: '4,5 × 6 cm', w: 45, h: 60, group: 'Klein' },
|
||||
{ id: 'K60x90', label: '6 × 9 cm', w: 60, h: 90, group: 'Klein' },
|
||||
// Klassische Fotoformate
|
||||
{ id: 'S9x13', label: '9 × 13 cm', w: 90, h: 130, group: 'Foto' },
|
||||
{ id: 'S10x15', label: '10 × 15 cm', w: 100, h: 150, group: 'Foto' },
|
||||
{ id: 'S12x15', label: '12 × 15 cm', w: 120, h: 150, group: 'Foto' },
|
||||
{ id: 'S13x18', label: '13 × 18 cm', w: 130, h: 180, group: 'Foto' },
|
||||
{ id: 'S15x20', label: '15 × 20 cm', w: 150, h: 200, group: 'Foto' },
|
||||
{ id: 'S18x24', label: '18 × 24 cm', w: 180, h: 240, group: 'Foto' },
|
||||
{ id: 'S20x30', label: '20 × 30 cm', w: 200, h: 300, group: 'Foto' },
|
||||
{ id: 'S30x40', label: '30 × 40 cm', w: 300, h: 400, group: 'Foto' },
|
||||
{ id: 'S30x45', label: '30 × 45 cm', w: 300, h: 450, group: 'Foto' },
|
||||
{ id: 'S40x50', label: '40 × 50 cm', w: 400, h: 500, group: 'Foto' },
|
||||
{ id: 'S40x60', label: '40 × 60 cm', w: 400, h: 600, group: 'Foto' },
|
||||
{ id: 'S50x70', label: '50 × 70 cm', w: 500, h: 700, group: 'Foto' },
|
||||
// Quadrate
|
||||
{ id: 'Q10x10', label: '10 × 10 cm', w: 100, h: 100, group: 'Quadrat' },
|
||||
{ id: 'Q13x13', label: '13 × 13 cm', w: 130, h: 130, group: 'Quadrat' },
|
||||
{ id: 'Q20x20', label: '20 × 20 cm', w: 200, h: 200, group: 'Quadrat' },
|
||||
{ id: 'Q30x30', label: '30 × 30 cm', w: 300, h: 300, group: 'Quadrat' },
|
||||
// DIN als Bildformat
|
||||
{ id: 'DA6', label: 'DIN A6 (10,5 × 14,8 cm)', w: 105, h: 148, group: 'DIN' },
|
||||
{ id: 'DA5', label: 'DIN A5 (14,8 × 21 cm)', w: 148, h: 210, group: 'DIN' },
|
||||
{ id: 'DA4', label: 'DIN A4 (21 × 29,7 cm)', w: 210, h: 297, group: 'DIN' },
|
||||
{ id: 'DA3', label: 'DIN A3 (29,7 × 42 cm)', w: 297, h: 420, group: 'DIN' },
|
||||
];
|
||||
|
||||
export const paperById = (id: string) => PAPERS.find((p) => p.id === id) || null;
|
||||
export const photoById = (id: string) => PHOTO_SIZES.find((p) => p.id === id) || null;
|
||||
|
||||
/**
|
||||
* Freitext-Maß → mm. Bewusst großzügig, weil Till Maße so tippt, wie er sie sagt:
|
||||
* „12x15" → 120 × 150 mm (cm ist die Standardeinheit)
|
||||
* „12 × 15 cm" → 120 × 150 mm
|
||||
* „35x45mm" → 35 × 45 mm
|
||||
* „5" → 50 × 50 mm (quadratisch)
|
||||
* „4:3 / 15" → Seitenverhältnis 4:3, längere Seite 15 cm → 150 × 112,5 mm
|
||||
*/
|
||||
export function parseSizeMm(input: string): { w: number; h: number } | null {
|
||||
const t = (input || '').trim().toLowerCase().replace(/,/g, '.');
|
||||
if (!t) return null;
|
||||
|
||||
// Seitenverhältnis mit Zielkante: „4:3 / 15" oder „4:3 15cm"
|
||||
const ratio = /^(\d+(?:\.\d+)?)\s*[:/]\s*(\d+(?:\.\d+)?)\s*(?:[/@ ]\s*(\d+(?:\.\d+)?)\s*(mm|cm)?)?$/.exec(t);
|
||||
if (ratio && ratio[3]) {
|
||||
const a = parseFloat(ratio[1]), b = parseFloat(ratio[2]);
|
||||
const long = ratio[4] === 'mm' ? parseFloat(ratio[3]) : parseFloat(ratio[3]) * 10;
|
||||
if (!a || !b || !long) return null;
|
||||
const [lo, hi] = a >= b ? [b, a] : [a, b];
|
||||
return norm(long * (lo / hi), long);
|
||||
}
|
||||
|
||||
const unit = /mm\s*$/.test(t) ? 1 : 10; // ohne Einheit: cm
|
||||
const body = t.replace(/\s*(mm|cm)\s*$/, '').trim();
|
||||
|
||||
const wh = /^(\d+(?:\.\d+)?)\s*[x×*]\s*(\d+(?:\.\d+)?)$/.exec(body);
|
||||
if (wh) return norm(parseFloat(wh[1]) * unit, parseFloat(wh[2]) * unit);
|
||||
|
||||
const sq = /^(\d+(?:\.\d+)?)$/.exec(body);
|
||||
if (sq) { const n = parseFloat(sq[1]) * unit; return norm(n, n); }
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function norm(w: number, h: number): { w: number; h: number } | null {
|
||||
const r = (n: number) => Math.round(n * 100) / 100;
|
||||
if (!(w > 0) || !(h > 0) || w > 2000 || h > 2000) return null;
|
||||
return { w: r(w), h: r(h) };
|
||||
}
|
||||
|
||||
export const mmToPx = (mm: number, dpi: number) => Math.round((mm / 25.4) * dpi);
|
||||
export const mmToPt = (mm: number) => (mm / 25.4) * 72;
|
||||
|
||||
/** Hübsche Beschriftung eines Maßes für die Oberfläche. */
|
||||
export function labelMm(w: number, h: number): string {
|
||||
const f = (n: number) => (n % 10 === 0 ? String(n / 10) : String(Math.round(n) / 10).replace('.', ','));
|
||||
return w < 100 && h < 100 ? `${fmt(w)} × ${fmt(h)} mm` : `${f(w)} × ${f(h)} cm`;
|
||||
}
|
||||
const fmt = (n: number) => String(Math.round(n * 10) / 10).replace('.', ',');
|
||||
@@ -22,6 +22,20 @@ export async function loadConfig(): Promise<PicdropCfg | null> {
|
||||
};
|
||||
}
|
||||
|
||||
/** Zusätzliches Auslieferungsziel aus delivery_targets. */
|
||||
export async function loadTargetConfig(id: string): Promise<PicdropCfg | null> {
|
||||
const s = await one<any>(`SELECT protocol, host, port, username, password_enc, base_path
|
||||
FROM delivery_targets WHERE id=$1`, [id]);
|
||||
if (!s?.host || !s?.username || !s?.password_enc) return null;
|
||||
let password = '';
|
||||
try { password = decrypt(s.password_enc); } catch { return null; }
|
||||
return {
|
||||
host: s.host, protocol: (s.protocol || 'sftp'),
|
||||
port: s.port || (s.protocol === 'ftps' ? 21 : 22),
|
||||
user: s.username, password, basePath: s.base_path || '/',
|
||||
};
|
||||
}
|
||||
|
||||
/** Verbindung testen: verbinden + Basisordner listen. */
|
||||
export async function testConnection(cfg: PicdropCfg): Promise<{ ok: boolean; message: string }> {
|
||||
try {
|
||||
|
||||
+13
-2
@@ -93,11 +93,22 @@ export function slugify(s: string): string {
|
||||
|
||||
const GENERIC = new Set(['', 'foto', 'photo', 'bild', 'image', 'img', 'telegram', 'unbenannt', 'klarbild', 'screenshot', 'pxl', 'whatsapp']);
|
||||
|
||||
/** Motiv aus einem beliebigen (evtl. schon verarbeiteten) Namen herausschälen:
|
||||
* Datumspräfixe, UUIDs und lange Hex-Ketten entfernen, damit keine Doppelungen entstehen. */
|
||||
function cleanMotif(name: string | null): string {
|
||||
let s = (name || '').replace(/\.[^.]+$/, '');
|
||||
// Ohne \b arbeiten — Wortgrenzen greifen an Unterstrichen nicht (…_2026-07-24…).
|
||||
s = s.replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, ' '); // UUID
|
||||
s = s.replace(/\d{4}-\d{2}-\d{2}/g, ' '); // Datum (auch mehrfach)
|
||||
s = s.replace(/[0-9a-f]{16,}/gi, ' '); // lange Hex-Ketten
|
||||
return slugify(s);
|
||||
}
|
||||
|
||||
/** Logischer Dateiname: JJJJ-MM-TT_<motiv>_<format>.<ext>
|
||||
* z. B. 2026-07-23_lea_the-frame.png */
|
||||
export function buildResultFilename(originalName: string | null, formatToken: string, ext = 'png'): string {
|
||||
const raw = slugify((originalName || '').replace(/\.[^.]+$/, ''));
|
||||
const motif = GENERIC.has(raw) ? 'klarbild' : raw;
|
||||
const raw = cleanMotif(originalName);
|
||||
const motif = (!raw || GENERIC.has(raw)) ? 'klarbild' : raw;
|
||||
const date = new Date().toISOString().slice(0, 10);
|
||||
const fmt = slugify(formatToken) || 'original';
|
||||
return `${date}_${motif}_${fmt}.${ext}`;
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
// Reine Layout-Mathematik für den Druckbogen — mm, kein I/O, keine Abhängigkeiten.
|
||||
// Wird identisch von der Vorschau im Browser und vom PDF-Renderer auf dem Server
|
||||
// benutzt, damit „was ich sehe" und „was gedruckt wird" dieselbe Rechnung sind.
|
||||
//
|
||||
// Koordinaten: Ursprung oben links, x nach rechts, y nach unten (wie Bildschirm/SVG).
|
||||
// Der PDF-Renderer spiegelt y, weil PDF von unten links zählt.
|
||||
|
||||
export interface PlaceSpec {
|
||||
id: string; // verweist auf ein Bild
|
||||
wMm: number; // Endformat (Trimmbox) Breite
|
||||
hMm: number; // Endformat Höhe
|
||||
count: number; // wie oft aufs Papier
|
||||
allowRotate?: boolean;
|
||||
}
|
||||
|
||||
export interface SheetSpec {
|
||||
wMm: number;
|
||||
hMm: number;
|
||||
marginMm: number; // nicht bedruckbarer Rand des Druckers (Sicherheitsrand)
|
||||
gapMm: number; // Abstand zwischen zwei Endformaten
|
||||
bleedMm?: number; // Beschnittzugabe je Seite (Bild ragt darüber hinaus)
|
||||
center?: boolean; // Block auf dem Bogen zentrieren (Default: true)
|
||||
}
|
||||
|
||||
export interface Placement {
|
||||
specId: string;
|
||||
copy: number; // 1-basiert
|
||||
x: number; y: number; // linke obere Ecke der Trimmbox
|
||||
w: number; h: number; // Trimmbox
|
||||
rotated: boolean; // Bild um 90° gedreht platziert
|
||||
}
|
||||
|
||||
export interface Line { x1: number; y1: number; x2: number; y2: number }
|
||||
|
||||
export interface Page { placements: Placement[] }
|
||||
|
||||
export interface LayoutResult {
|
||||
pages: Page[];
|
||||
/** Was auf keinen Bogen passt (z. B. Bild größer als das Papier). */
|
||||
unplaced: { specId: string; count: number; reason: string }[];
|
||||
perSheet: number; // Stück auf dem ersten Bogen (Kennzahl für die UI)
|
||||
}
|
||||
|
||||
export type MarkMode = 'none' | 'corner' | 'grid';
|
||||
|
||||
const EPS = 1e-6;
|
||||
|
||||
/** Effektiver Abstand: Beschnittzugaben zweier Nachbarn dürfen sich nicht überlappen. */
|
||||
export function effectiveGap(sheet: SheetSpec): number {
|
||||
return Math.max(sheet.gapMm || 0, 2 * (sheet.bleedMm || 0));
|
||||
}
|
||||
|
||||
/**
|
||||
* Packt die Endformate auf so wenige Bogen wie möglich.
|
||||
*
|
||||
* Zwei Wege, bewusst getrennt:
|
||||
* 1. Alle Bilder gleich groß → exaktes Raster (Spalten × Zeilen). Perfekt
|
||||
* ausgerichtet, damit durchgehende Schnittlinien wirklich durchgehen.
|
||||
* 2. Gemischte Größen → MaxRects (Best-Short-Side-Fit) mit optionaler
|
||||
* 90°-Drehung. Das ist der Fall „ein großes Bild plus acht Passbilder":
|
||||
* die Kleinen wandern in die Restfläche neben dem Großen.
|
||||
*
|
||||
* Der Abstand wird als „aufgeblasenes" Rechteck mitgeführt (Bild + Abstand),
|
||||
* deshalb stimmen die Zwischenräume überall, auch am Rand.
|
||||
*/
|
||||
export function layout(specs: PlaceSpec[], sheet: SheetSpec): LayoutResult {
|
||||
const gap = effectiveGap(sheet);
|
||||
const bleed = sheet.bleedMm || 0;
|
||||
const inset = (sheet.marginMm || 0) + bleed;
|
||||
const availW = sheet.wMm - 2 * inset;
|
||||
const availH = sheet.hMm - 2 * inset;
|
||||
|
||||
const unplaced: LayoutResult['unplaced'] = [];
|
||||
const units: { specId: string; copy: number; w: number; h: number; rot: boolean }[] = [];
|
||||
|
||||
for (const s of specs) {
|
||||
const n = Math.max(0, Math.floor(s.count || 0));
|
||||
if (!n) continue;
|
||||
const fitsPlain = s.wMm <= availW + EPS && s.hMm <= availH + EPS;
|
||||
const fitsRot = !!s.allowRotate && s.hMm <= availW + EPS && s.wMm <= availH + EPS;
|
||||
if (!fitsPlain && !fitsRot) {
|
||||
unplaced.push({ specId: s.id, count: n, reason: 'größer als die Nutzfläche des Bogens' });
|
||||
continue;
|
||||
}
|
||||
for (let c = 1; c <= n; c++) units.push({ specId: s.id, copy: c, w: s.wMm, h: s.hMm, rot: false });
|
||||
}
|
||||
if (!units.length) return { pages: [], unplaced, perSheet: 0 };
|
||||
|
||||
const uniform = units.every((u) => Math.abs(u.w - units[0].w) < EPS && Math.abs(u.h - units[0].h) < EPS);
|
||||
let pages: Page[];
|
||||
|
||||
if (uniform) {
|
||||
pages = gridPack(units, specs, availW, availH, gap);
|
||||
} else {
|
||||
// Ausrichtung je Bildformat festlegen (nicht je Exemplar) — sonst liegen
|
||||
// gleiche Bilder wild durcheinander auf dem Bogen. Alle sinnvollen
|
||||
// Kombinationen durchprobieren und die beste nehmen.
|
||||
const ids = [...new Set(units.map((u) => u.specId))];
|
||||
const rotatable = ids.filter((id) => {
|
||||
const sp = specs.find((s) => s.id === id)!;
|
||||
return sp.allowRotate !== false && Math.abs(sp.wMm - sp.hMm) > EPS;
|
||||
});
|
||||
const tooBig = ids.length > 5 || units.length > 150;
|
||||
const variants: Record<string, boolean>[] = tooBig
|
||||
? [Object.fromEntries(ids.map((id) => [id, false]))]
|
||||
: combos(rotatable).map((set) => Object.fromEntries(ids.map((id) => [id, set.has(id)])));
|
||||
|
||||
let best: Page[] | null = null, bestScore = -Infinity;
|
||||
for (const [vi, rotMap] of variants.entries()) {
|
||||
const oriented = units.map((u) => {
|
||||
const r = !!rotMap[u.specId];
|
||||
const sp = specs.find((s) => s.id === u.specId)!;
|
||||
// Nur Ausrichtungen zulassen, die überhaupt auf den Bogen passen.
|
||||
const w = r ? sp.hMm : sp.wMm, h = r ? sp.wMm : sp.hMm;
|
||||
return { ...u, w, h, rot: r };
|
||||
});
|
||||
if (oriented.some((u) => u.w > availW + EPS || u.h > availH + EPS)) continue;
|
||||
const cand = maxRectsPack(oriented, availW, availH, gap);
|
||||
const placed = cand.reduce((n, p) => n + p.placements.length, 0);
|
||||
if (placed < units.length) continue;
|
||||
// Wenige Bogen zählt am meisten, dann volle erste Seite, dann „so wenig
|
||||
// gedreht wie möglich" (Tills Wunschausrichtung bleibt eher erhalten).
|
||||
const score = -cand.length * 1000 + (cand[0]?.placements.length || 0) - 0.01 * vi
|
||||
- 0.1 * Object.values(rotMap).filter(Boolean).length;
|
||||
if (score > bestScore) { bestScore = score; best = cand; }
|
||||
}
|
||||
pages = best || maxRectsPack(units.map((u) => ({ ...u, rot: false })), availW, availH, gap);
|
||||
const placed = pages.reduce((n, p) => n + p.placements.length, 0);
|
||||
if (placed < units.length) unplaced.push({ specId: units[0].specId, count: units.length - placed, reason: 'kein Platz auf dem Bogen' });
|
||||
}
|
||||
|
||||
// Auf dem Bogen ausrichten: Blockmitte oder linke obere Ecke.
|
||||
for (const pg of pages) {
|
||||
let offX = inset, offY = inset;
|
||||
if (sheet.center !== false && pg.placements.length) {
|
||||
const minX = Math.min(...pg.placements.map((p) => p.x));
|
||||
const maxX = Math.max(...pg.placements.map((p) => p.x + p.w));
|
||||
const minY = Math.min(...pg.placements.map((p) => p.y));
|
||||
const maxY = Math.max(...pg.placements.map((p) => p.y + p.h));
|
||||
offX = inset + (availW - (maxX - minX)) / 2 - minX;
|
||||
offY = inset + (availH - (maxY - minY)) / 2 - minY;
|
||||
}
|
||||
for (const p of pg.placements) { p.x = round(p.x + offX); p.y = round(p.y + offY); }
|
||||
}
|
||||
|
||||
return { pages, unplaced, perSheet: pages[0]?.placements.length || 0 };
|
||||
}
|
||||
|
||||
/** Gleich große Bilder: sauberes Raster, Zeile für Zeile. */
|
||||
function gridPack(units: { specId: string; copy: number; w: number; h: number }[], specs: PlaceSpec[],
|
||||
availW: number, availH: number, gap: number): Page[] {
|
||||
const spec = specs.find((s) => s.id === units[0].specId);
|
||||
let { w, h } = units[0];
|
||||
let rot = false;
|
||||
const fit = (a: number, b: number) => ({
|
||||
cols: Math.max(0, Math.floor((availW + gap + EPS) / (a + gap))),
|
||||
rows: Math.max(0, Math.floor((availH + gap + EPS) / (b + gap))),
|
||||
});
|
||||
let best = fit(w, h);
|
||||
if (spec?.allowRotate !== false && Math.abs(w - h) > EPS) {
|
||||
const alt = fit(h, w);
|
||||
if (alt.cols * alt.rows > best.cols * best.rows) { best = alt; [w, h] = [h, w]; rot = true; }
|
||||
}
|
||||
const perPage = best.cols * best.rows;
|
||||
if (!perPage) return [];
|
||||
|
||||
const pages: Page[] = [];
|
||||
for (let i = 0; i < units.length; i += perPage) {
|
||||
const chunk = units.slice(i, i + perPage);
|
||||
const placements: Placement[] = chunk.map((u, k) => {
|
||||
const c = k % best.cols, r = Math.floor(k / best.cols);
|
||||
return { specId: u.specId, copy: u.copy, x: round(c * (w + gap)), y: round(r * (h + gap)), w, h, rotated: rot };
|
||||
});
|
||||
pages.push({ placements });
|
||||
}
|
||||
return pages;
|
||||
}
|
||||
|
||||
interface FreeRect { x: number; y: number; w: number; h: number }
|
||||
|
||||
/** Gemischte Größen: MaxRects mit Best-Short-Side-Fit, Ausrichtung schon festgelegt. */
|
||||
function maxRectsPack(units: { specId: string; copy: number; w: number; h: number; rot: boolean }[],
|
||||
availW: number, availH: number, gap: number): Page[] {
|
||||
// Groß zuerst — sonst blockieren Kleinteile die guten Flächen.
|
||||
const todo = [...units].sort((a, b) =>
|
||||
(b.w * b.h) - (a.w * a.h) || Math.max(b.w, b.h) - Math.max(a.w, a.h) ||
|
||||
a.specId.localeCompare(b.specId) || a.copy - b.copy);
|
||||
|
||||
const W = availW + gap, H = availH + gap; // aufgeblasene Fläche
|
||||
const pages: Page[] = [];
|
||||
|
||||
while (todo.length) {
|
||||
let free: FreeRect[] = [{ x: 0, y: 0, w: W, h: H }];
|
||||
const placements: Placement[] = [];
|
||||
|
||||
for (;;) {
|
||||
let bestIdx = -1, bestScore = Infinity, bestAlt = Infinity;
|
||||
let bestRect: FreeRect | null = null;
|
||||
|
||||
for (let i = 0; i < todo.length; i++) {
|
||||
const u = todo[i];
|
||||
const cw = u.w + gap, ch = u.h + gap;
|
||||
for (const r of free) {
|
||||
if (cw > r.w + EPS || ch > r.h + EPS) continue;
|
||||
const short = Math.min(r.w - cw, r.h - ch);
|
||||
// Bei Gleichstand die am weitesten oben/links liegende Fläche nehmen —
|
||||
// das ergibt ein ruhiges, lesbares Bogenbild statt verstreuter Motive.
|
||||
const pos = r.y * 10000 + r.x;
|
||||
if (short < bestScore - EPS || (Math.abs(short - bestScore) < EPS && pos < bestAlt - EPS)) {
|
||||
bestScore = short; bestAlt = pos; bestIdx = i; bestRect = r;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (bestIdx < 0 || !bestRect) break;
|
||||
|
||||
const u = todo.splice(bestIdx, 1)[0];
|
||||
placements.push({ specId: u.specId, copy: u.copy, x: round(bestRect.x), y: round(bestRect.y), w: u.w, h: u.h, rotated: u.rot });
|
||||
free = splitFree(free, { x: bestRect.x, y: bestRect.y, w: u.w + gap, h: u.h + gap });
|
||||
}
|
||||
|
||||
if (!placements.length) break; // nichts platzierbar → Abbruch
|
||||
placements.sort((a, b) => a.y - b.y || a.x - b.x);
|
||||
pages.push({ placements });
|
||||
if (pages.length > 200) break; // Notbremse
|
||||
}
|
||||
return pages;
|
||||
}
|
||||
|
||||
/** Alle Teilmengen (Ausrichtungs-Kombinationen), stabil sortiert. */
|
||||
function combos(ids: string[]): Set<string>[] {
|
||||
const out: Set<string>[] = [];
|
||||
const n = Math.min(ids.length, 8);
|
||||
for (let m = 0; m < (1 << n); m++) {
|
||||
const s = new Set<string>();
|
||||
for (let i = 0; i < n; i++) if (m & (1 << i)) s.add(ids[i]);
|
||||
out.push(s);
|
||||
}
|
||||
// Erst „nichts gedreht", dann aufsteigend nach Anzahl Drehungen.
|
||||
return out.sort((a, b) => a.size - b.size);
|
||||
}
|
||||
|
||||
function splitFree(free: FreeRect[], used: FreeRect): FreeRect[] {
|
||||
const out: FreeRect[] = [];
|
||||
for (const r of free) {
|
||||
const overlap = used.x < r.x + r.w - EPS && used.x + used.w > r.x + EPS
|
||||
&& used.y < r.y + r.h - EPS && used.y + used.h > r.y + EPS;
|
||||
if (!overlap) { out.push(r); continue; }
|
||||
if (used.x > r.x + EPS) out.push({ x: r.x, y: r.y, w: used.x - r.x, h: r.h });
|
||||
if (used.x + used.w < r.x + r.w - EPS) out.push({ x: used.x + used.w, y: r.y, w: r.x + r.w - (used.x + used.w), h: r.h });
|
||||
if (used.y > r.y + EPS) out.push({ x: r.x, y: r.y, w: r.w, h: used.y - r.y });
|
||||
if (used.y + used.h < r.y + r.h - EPS) out.push({ x: r.x, y: used.y + used.h, w: r.w, h: r.y + r.h - (used.y + used.h) });
|
||||
}
|
||||
// In anderen enthaltene Rechtecke wegwerfen (sonst wächst die Liste ins Unendliche).
|
||||
return out.filter((a, i) => a.w > EPS && a.h > EPS && !out.some((b, j) =>
|
||||
j !== i && b.x <= a.x + EPS && b.y <= a.y + EPS &&
|
||||
b.x + b.w >= a.x + a.w - EPS && b.y + b.h >= a.y + a.h - EPS &&
|
||||
(b.w > a.w + EPS || b.h > a.h + EPS || j < i)));
|
||||
}
|
||||
|
||||
/** Wie viele Endformate passen maximal auf einen Bogen? (Kennzahl für die UI.) */
|
||||
export function capacity(spec: Omit<PlaceSpec, 'count'>, sheet: SheetSpec): number {
|
||||
const probe = layout([{ ...spec, count: 200 }], sheet);
|
||||
return probe.pages[0]?.placements.length || 0;
|
||||
}
|
||||
|
||||
export interface MarkOptions {
|
||||
mode: MarkMode;
|
||||
lengthMm?: number; // Länge einer Marke (Norm: 4–10 mm)
|
||||
offsetMm?: number; // Abstand vom Endformat nach außen
|
||||
bleedMm?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schnittmarken als Liniensegmente.
|
||||
* - `corner`: vier Eckenpaare je Bild, außerhalb des Endformats (Adobe-/Druckerei-Konvention).
|
||||
* - `grid`: durchgehende Linien über den ganzen Bogen an jeder Schnittkante — für
|
||||
* das Schneidelineal/die Schlagschere, wenn viele gleiche Bilder drauf sind.
|
||||
*/
|
||||
export function cutMarks(page: Page, sheet: SheetSpec, opt: MarkOptions): Line[] {
|
||||
if (opt.mode === 'none') return [];
|
||||
const len = opt.lengthMm ?? 4;
|
||||
const off = opt.offsetMm ?? Math.max(2, (opt.bleedMm ?? 0) + 1);
|
||||
const lines: Line[] = [];
|
||||
|
||||
if (opt.mode === 'corner') {
|
||||
for (const p of page.placements) {
|
||||
const L = p.x, R = p.x + p.w, T = p.y, B = p.y + p.h;
|
||||
// je Ecke: eine waagerechte und eine senkrechte Marke, nach außen weisend
|
||||
for (const [x, y, sx, sy] of [[L, T, -1, -1], [R, T, 1, -1], [L, B, -1, 1], [R, B, 1, 1]] as const) {
|
||||
lines.push({ x1: x + sx * off, y1: y, x2: x + sx * (off + len), y2: y });
|
||||
lines.push({ x1: x, y1: y + sy * off, x2: x, y2: y + sy * (off + len) });
|
||||
}
|
||||
}
|
||||
return clip(lines, sheet);
|
||||
}
|
||||
|
||||
// grid: Schnittkanten über den ganzen Bogen ziehen — aber nur dort, wo kein
|
||||
// anderes Bild im Weg ist. Sonst liefe die Linie quer durchs Nachbarmotiv.
|
||||
const xs = uniq(page.placements.flatMap((p) => [p.x, p.x + p.w]));
|
||||
const ys = uniq(page.placements.flatMap((p) => [p.y, p.y + p.h]));
|
||||
for (const x of xs) {
|
||||
const blocked = page.placements
|
||||
.filter((p) => p.x + EPS < x && x < p.x + p.w - EPS)
|
||||
.map((p) => [p.y, p.y + p.h] as [number, number]);
|
||||
for (const [a, b] of complement(blocked, 0, sheet.hMm)) lines.push({ x1: x, y1: a, x2: x, y2: b });
|
||||
}
|
||||
for (const y of ys) {
|
||||
const blocked = page.placements
|
||||
.filter((p) => p.y + EPS < y && y < p.y + p.h - EPS)
|
||||
.map((p) => [p.x, p.x + p.w] as [number, number]);
|
||||
for (const [a, b] of complement(blocked, 0, sheet.wMm)) lines.push({ x1: a, y1: y, x2: b, y2: y });
|
||||
}
|
||||
return lines.filter((l) => Math.abs(l.x2 - l.x1) > 0.2 || Math.abs(l.y2 - l.y1) > 0.2);
|
||||
}
|
||||
|
||||
const uniq = (ns: number[]) => [...new Set(ns.map(round))].sort((a, b) => a - b);
|
||||
|
||||
/** Freie Abschnitte in [from,to] nach Abzug der belegten Intervalle. */
|
||||
function complement(blocked: [number, number][], from: number, to: number): [number, number][] {
|
||||
const sorted = [...blocked].sort((a, b) => a[0] - b[0]);
|
||||
const out: [number, number][] = [];
|
||||
let cur = from;
|
||||
for (const [a, b] of sorted) {
|
||||
if (a > cur) out.push([cur, Math.min(a, to)]);
|
||||
cur = Math.max(cur, b);
|
||||
if (cur >= to) break;
|
||||
}
|
||||
if (cur < to) out.push([cur, to]);
|
||||
return out.filter(([a, b]) => b - a > 0.2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Kleinster Abstand, bei dem die gewählten Marken noch sinnvoll sind:
|
||||
* Eckmarken zweier Nachbarn brauchen je (Versatz + Länge) Platz, Beschnittzugaben
|
||||
* dürfen sich nicht überlappen.
|
||||
*/
|
||||
export function recommendedGap(mode: MarkMode, bleedMm = 0, lengthMm = 4, offsetMm?: number): number {
|
||||
const off = offsetMm ?? Math.max(2, bleedMm + 1);
|
||||
const base = 2 * bleedMm;
|
||||
if (mode === 'corner') return Math.max(base, 2 * (off + lengthMm));
|
||||
if (mode === 'grid') return Math.max(base, 0);
|
||||
return base;
|
||||
}
|
||||
|
||||
const round = (n: number) => Math.round(n * 1000) / 1000;
|
||||
|
||||
/** Marken, die über den Bogenrand hinausragen würden, abschneiden bzw. verwerfen. */
|
||||
function clip(lines: Line[], sheet: SheetSpec): Line[] {
|
||||
const cx = (v: number) => Math.min(sheet.wMm, Math.max(0, v));
|
||||
const cy = (v: number) => Math.min(sheet.hMm, Math.max(0, v));
|
||||
return lines
|
||||
.map((l) => ({ x1: cx(l.x1), y1: cy(l.y1), x2: cx(l.x2), y2: cy(l.y2) }))
|
||||
.filter((l) => Math.abs(l.x2 - l.x1) > 0.2 || Math.abs(l.y2 - l.y1) > 0.2);
|
||||
}
|
||||
|
||||
/** Reicht die Quellauflösung für das Endformat bei gewünschter dpi? */
|
||||
export function dpiCheck(srcPx: number, mm: number, wantDpi: number): { dpi: number; ok: boolean } {
|
||||
const dpi = mm > 0 ? (srcPx / (mm / 25.4)) : 0;
|
||||
return { dpi: Math.round(dpi), ok: dpi >= wantDpi * 0.9 };
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
// Rendern des Druckbogens: Zuschnitt (sharp) + PDF-Aufbau (pdf-lib).
|
||||
// Bewusst ohne KI — reine Geometrie und Skalierung, damit das Ergebnis
|
||||
// vorhersagbar und wiederholbar ist.
|
||||
import sharp from 'sharp';
|
||||
import { PDFDocument, StandardFonts, rgb, degrees } from 'pdf-lib';
|
||||
import { mmToPt, mmToPx } from './paper';
|
||||
import type { Line, Page, SheetSpec } from './printlayout';
|
||||
|
||||
/** Zuschnitt relativ zur Quelle (0..1) — auflösungsunabhängig speicherbar. */
|
||||
export interface CropRel { x: number; y: number; w: number; h: number }
|
||||
|
||||
export const FULL_CROP: CropRel = { x: 0, y: 0, w: 1, h: 1 };
|
||||
|
||||
/**
|
||||
* Bringt einen Bildausschnitt exakt auf ein physisches Maß.
|
||||
* `bleedMm` vergrößert das gerenderte Feld nach außen; die Trimmbox bleibt
|
||||
* exakt der gewählte Ausschnitt. Fehlende Randpixel werden aus der Kante
|
||||
* fortgeschrieben (extendWith 'copy'), statt das Motiv zu stauchen.
|
||||
*/
|
||||
export async function renderCell(
|
||||
input: Buffer,
|
||||
crop: CropRel | null,
|
||||
wMm: number,
|
||||
hMm: number,
|
||||
dpi: number,
|
||||
opt: { rotate?: boolean; ext?: 'jpg' | 'png'; bleedMm?: number; background?: string } = {},
|
||||
): Promise<{ buffer: Buffer; width: number; height: number; ext: 'jpg' | 'png'; srcPx: [number, number] }> {
|
||||
const bleed = Math.max(0, opt.bleedMm || 0);
|
||||
const meta = await sharp(input, { failOn: 'none' }).metadata();
|
||||
const nw = meta.width || 0, nh = meta.height || 0;
|
||||
if (!nw || !nh) throw new Error('Bildmaße unbekannt.');
|
||||
|
||||
// Ohne Vorgabe: größtmöglicher mittiger Ausschnitt im Zielverhältnis
|
||||
// („cover") — nie verzerren, das ist bei Druckmaßen die häufigste Falle.
|
||||
const c = crop ? normCrop(crop) : coverCrop(nw, nh, wMm / hMm);
|
||||
// Zuschnitt um die Beschnittzugabe erweitern (mittig), damit die Trimmbox
|
||||
// exakt das bleibt, was in der Vorschau gerahmt wurde.
|
||||
const fx = (wMm + 2 * bleed) / wMm;
|
||||
const fy = (hMm + 2 * bleed) / hMm;
|
||||
const cw = c.w * fx, ch = c.h * fy;
|
||||
const cx = c.x - (cw - c.w) / 2, cy = c.y - (ch - c.h) / 2;
|
||||
|
||||
const want = { left: cx * nw, top: cy * nh, width: cw * nw, height: ch * nh };
|
||||
const left = Math.round(want.left), top = Math.round(want.top);
|
||||
const width = Math.max(1, Math.round(want.width)), height = Math.max(1, Math.round(want.height));
|
||||
|
||||
const ex = {
|
||||
left: Math.min(Math.max(0, left), nw - 1),
|
||||
top: Math.min(Math.max(0, top), nh - 1),
|
||||
};
|
||||
const exW = Math.max(1, Math.min(width + Math.min(0, left), nw - ex.left));
|
||||
const exH = Math.max(1, Math.min(height + Math.min(0, top), nh - ex.top));
|
||||
const padLeft = Math.max(0, ex.left - left);
|
||||
const padTop = Math.max(0, ex.top - top);
|
||||
const padRight = Math.max(0, width - exW - padLeft);
|
||||
const padBottom = Math.max(0, height - exH - padTop);
|
||||
|
||||
const targetW = mmToPx(wMm + 2 * bleed, dpi);
|
||||
const targetH = mmToPx(hMm + 2 * bleed, dpi);
|
||||
|
||||
let img = sharp(input, { failOn: 'none' }).extract({ left: ex.left, top: ex.top, width: exW, height: exH });
|
||||
if (padLeft || padTop || padRight || padBottom) {
|
||||
img = img.extend({ left: padLeft, top: padTop, right: padRight, bottom: padBottom, extendWith: 'copy' });
|
||||
}
|
||||
img = img.resize(targetW, targetH, { fit: 'fill' });
|
||||
if (opt.rotate) img = img.rotate(90);
|
||||
|
||||
const hasAlpha = !!meta.hasAlpha;
|
||||
const ext: 'jpg' | 'png' = opt.ext === 'png' || hasAlpha ? 'png' : 'jpg';
|
||||
const flat = hasAlpha && ext === 'jpg';
|
||||
if (flat) img = img.flatten({ background: opt.background || '#ffffff' });
|
||||
|
||||
const out = await (ext === 'png'
|
||||
? img.withMetadata({ density: dpi }).png({ compressionLevel: 9 })
|
||||
: img.withMetadata({ density: dpi }).jpeg({ quality: 94, mozjpeg: true })
|
||||
).toBuffer({ resolveWithObject: true });
|
||||
|
||||
return { buffer: out.data, width: out.info.width, height: out.info.height, ext, srcPx: [nw, nh] };
|
||||
}
|
||||
|
||||
/** Größter mittiger Ausschnitt mit dem Zielseitenverhältnis. */
|
||||
export function coverCrop(natW: number, natH: number, aspect: number): CropRel {
|
||||
const imgA = natW / natH;
|
||||
const w = imgA > aspect ? aspect / imgA : 1;
|
||||
const h = imgA > aspect ? 1 : imgA / aspect;
|
||||
return { x: (1 - w) / 2, y: (1 - h) / 2, w, h };
|
||||
}
|
||||
|
||||
function normCrop(c: CropRel | null): CropRel {
|
||||
if (!c) return FULL_CROP;
|
||||
const cl = (v: number, lo = 0, hi = 1) => Math.min(hi, Math.max(lo, Number.isFinite(v) ? v : 0));
|
||||
let w = cl(c.w, 0.001, 1), h = cl(c.h, 0.001, 1);
|
||||
let x = cl(c.x, 0, 1 - w), y = cl(c.y, 0, 1 - h);
|
||||
return { x, y, w, h };
|
||||
}
|
||||
|
||||
export interface SheetCellImage { bytes: Buffer; ext: 'jpg' | 'png' }
|
||||
|
||||
export interface SheetPdfInput {
|
||||
sheet: SheetSpec;
|
||||
pages: Page[];
|
||||
marksPerPage: Line[][];
|
||||
/** specId → fertig gerendertes Bild (inkl. Beschnittzugabe, ggf. gedreht). */
|
||||
images: Record<string, SheetCellImage>;
|
||||
title?: string;
|
||||
footer?: string | null;
|
||||
markWidthPt?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Baut das Druck-PDF. Seitengröße = Bogengröße in mm, 1:1 — beim Drucken
|
||||
* unbedingt „Tatsächliche Größe / 100 %" wählen, nicht „an Seite anpassen".
|
||||
*/
|
||||
export async function buildSheetPdf(input: SheetPdfInput): Promise<Uint8Array> {
|
||||
const { sheet, pages, marksPerPage, images } = input;
|
||||
const pdf = await PDFDocument.create();
|
||||
pdf.setTitle(input.title || 'Klarbild Druckbogen');
|
||||
pdf.setProducer('Klarbild');
|
||||
pdf.setCreator('Klarbild — Druckbogen');
|
||||
|
||||
const embedded: Record<string, any> = {};
|
||||
for (const [id, img] of Object.entries(images)) {
|
||||
embedded[id] = img.ext === 'png' ? await pdf.embedPng(img.bytes) : await pdf.embedJpg(img.bytes);
|
||||
}
|
||||
const font = input.footer ? await pdf.embedFont(StandardFonts.Helvetica) : null;
|
||||
const bleed = sheet.bleedMm || 0;
|
||||
const W = mmToPt(sheet.wMm), H = mmToPt(sheet.hMm);
|
||||
|
||||
pages.forEach((pg, i) => {
|
||||
const page = pdf.addPage([W, H]);
|
||||
for (const p of pg.placements) {
|
||||
const im = embedded[p.specId];
|
||||
if (!im) continue;
|
||||
// Bild inkl. Beschnittzugabe: ragt je Seite um `bleed` über die Trimmbox.
|
||||
const x = mmToPt(p.x - bleed);
|
||||
const yTop = p.y - bleed; // von oben gezählt
|
||||
const w = mmToPt(p.w + 2 * bleed);
|
||||
const h = mmToPt(p.h + 2 * bleed);
|
||||
page.drawImage(im, { x, y: H - mmToPt(yTop) - h, width: w, height: h });
|
||||
}
|
||||
for (const l of marksPerPage[i] || []) {
|
||||
page.drawLine({
|
||||
start: { x: mmToPt(l.x1), y: H - mmToPt(l.y1) },
|
||||
end: { x: mmToPt(l.x2), y: H - mmToPt(l.y2) },
|
||||
thickness: input.markWidthPt ?? 0.25,
|
||||
color: rgb(0, 0, 0),
|
||||
});
|
||||
}
|
||||
if (font && input.footer) {
|
||||
page.drawText(input.footer, {
|
||||
x: mmToPt(4), y: mmToPt(3), size: 6, font, color: rgb(0.45, 0.45, 0.42),
|
||||
rotate: degrees(0),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return pdf.save();
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// Auflösen der Bildquellen für den Druckbogen: frisch hochgeladen oder aus der Bibliothek.
|
||||
import { one } from './db';
|
||||
import { getObject } from './storage';
|
||||
|
||||
export type PrintSource =
|
||||
| { kind: 'upload'; path: string }
|
||||
| { kind: 'item'; id: string };
|
||||
|
||||
export interface SessionUser { uid: string | null; role: string }
|
||||
|
||||
/** Lädt die Bilddaten und prüft dabei die Zugriffsrechte wie /api/items/:id/file. */
|
||||
export async function loadSource(src: PrintSource, user: SessionUser): Promise<Buffer> {
|
||||
if (!src || typeof src !== 'object') throw new Error('Quelle fehlt.');
|
||||
|
||||
if (src.kind === 'upload') {
|
||||
// Nur der Upload-Bereich ist erreichbar — kein Weg zu results/ oder /etc.
|
||||
const p = String(src.path || '');
|
||||
if (!/^sources\/[0-9]{4}\/[A-Za-z0-9_-]+\.[A-Za-z0-9]{2,5}$/.test(p)) throw new Error('Ungültige Quelle.');
|
||||
return getObject(p);
|
||||
}
|
||||
|
||||
if (src.kind === 'item') {
|
||||
const item = await one<any>(
|
||||
`SELECT i.result_path, i.filename, j.created_by, j.private
|
||||
FROM items i JOIN jobs j ON j.id = i.job_id WHERE i.id = $1`, [src.id]);
|
||||
if (!item?.result_path) throw new Error('Bild nicht gefunden.');
|
||||
const isAdmin = user.role === 'admin';
|
||||
const own = item.created_by === user.uid;
|
||||
if (!isAdmin && !own) {
|
||||
const s = await one<{ library_visibility: string }>('SELECT library_visibility FROM settings WHERE id=1');
|
||||
if (item.private || s?.library_visibility !== 'shared') throw new Error('Kein Zugriff auf dieses Bild.');
|
||||
}
|
||||
return getObject(item.result_path);
|
||||
}
|
||||
|
||||
throw new Error('Unbekannte Quellenart.');
|
||||
}
|
||||
|
||||
/** Sprechender Dateiname für den Download. */
|
||||
export function sheetFilename(prefix: string, ext: string): string {
|
||||
const date = new Date().toISOString().slice(0, 10);
|
||||
const slug = (prefix || 'druckbogen').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40);
|
||||
return `${date}_${slug || 'druckbogen'}.${ext}`;
|
||||
}
|
||||
+108
-25
@@ -1,13 +1,15 @@
|
||||
import { one, query } from './db';
|
||||
import { getObject, putObject, resultKey } from './storage';
|
||||
import { getObject, putObject, deleteObject, resultKey, thumbKey } from './storage';
|
||||
import { generateImage } from './openrouter';
|
||||
import { buildPrompt, type Task } from './prompts';
|
||||
import { buildPrompt, buildComposePrompt, buildGeneratePrompt, type Task } from './prompts';
|
||||
import { resolveDimensions, aspectRatio, type Orientation } from './format';
|
||||
import { finalizeToFormat, stickerContour, buildResultFilename, formatToken, type CropMode } from './pipeline';
|
||||
import sharp from 'sharp';
|
||||
|
||||
const DEFAULT_MODEL = 'google/gemini-3.1-flash-image';
|
||||
|
||||
type Mode = 'each' | 'compose' | 'generate';
|
||||
|
||||
interface RecipeSnapshot {
|
||||
tasks: Task[];
|
||||
output_format?: string;
|
||||
@@ -17,8 +19,10 @@ interface RecipeSnapshot {
|
||||
contour_mm?: number | null;
|
||||
model_key?: string | null;
|
||||
custom_instruction?: string | null;
|
||||
prompt_text?: string | null; // Beschreibung für compose/generate
|
||||
delivery?: 'library' | 'picdrop' | 'both';
|
||||
picdrop_gallery?: string | null;
|
||||
output_ext?: 'png' | 'jpg' | null; // Dateiformat-Override; sonst globale Einstellung
|
||||
}
|
||||
|
||||
/** Welches Modell-ID + Alpha-Fähigkeit? Aus models-Tabelle, sonst Default. */
|
||||
@@ -42,47 +46,84 @@ async function toDataUrl(buf: Buffer): Promise<string> {
|
||||
return `data:image/jpeg;base64,${small.toString('base64')}`;
|
||||
}
|
||||
|
||||
/** Kleines Vorschaubild (webp) für die Bibliothek — spart Bandbreite & Speicher. */
|
||||
async function makeThumb(buf: Buffer): Promise<Buffer> {
|
||||
return sharp(buf, { failOn: 'none' })
|
||||
.resize(600, 600, { fit: 'inside', withoutEnlargement: true })
|
||||
.webp({ quality: 72 })
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
export interface ProcessResult { ok: boolean; cost: number; error?: string; }
|
||||
|
||||
/** Verarbeitet eine Position vollständig und aktualisiert ihren Datensatz. */
|
||||
export async function processItem(itemId: string): Promise<ProcessResult> {
|
||||
const item = await one<{ id: string; job_id: string; source_path: string; filename: string | null }>(
|
||||
'SELECT id, job_id, source_path, filename FROM items WHERE id=$1', [itemId]);
|
||||
const item = await one<{ id: string; job_id: string; source_path: string | null; source_paths: string[] | null; filename: string | null }>(
|
||||
'SELECT id, job_id, source_path, source_paths, filename FROM items WHERE id=$1', [itemId]);
|
||||
if (!item) return { ok: false, cost: 0, error: 'Position nicht gefunden' };
|
||||
|
||||
const job = await one<{ recipe_snapshot: RecipeSnapshot }>(
|
||||
'SELECT recipe_snapshot FROM jobs WHERE id=$1', [item.job_id]);
|
||||
const job = await one<{ recipe_snapshot: RecipeSnapshot; mode: Mode; private: boolean }>(
|
||||
'SELECT recipe_snapshot, mode, private FROM jobs WHERE id=$1', [item.job_id]);
|
||||
const r = (job?.recipe_snapshot || {}) as RecipeSnapshot;
|
||||
const mode: Mode = job?.mode || 'each';
|
||||
const isPrivate = !!job?.private;
|
||||
const tasks = r.tasks || [];
|
||||
const dpi = r.dpi ?? 300;
|
||||
const cropMode: CropMode = r.crop_mode === 'extend' ? 'extend' : 'crop';
|
||||
const wantCutout = tasks.includes('cutout');
|
||||
const wantContour = tasks.includes('contour');
|
||||
const description = (r.prompt_text || '').trim();
|
||||
|
||||
await query(`UPDATE items SET status='running', attempts=attempts+1 WHERE id=$1`, [itemId]);
|
||||
|
||||
try {
|
||||
const source = await getObject(item.source_path);
|
||||
const target = r.tasks.includes('format') && r.output_format
|
||||
// Quellen je Modus einsammeln
|
||||
const sourceKeys: string[] =
|
||||
mode === 'compose' ? (item.source_paths || []).filter(Boolean)
|
||||
: mode === 'generate' ? []
|
||||
: (item.source_path ? [item.source_path] : []);
|
||||
// Braucht dieser Modus eine Quelle, ist aber keine (mehr) da? Klare Meldung statt roher fs-Fehler.
|
||||
if (mode !== 'generate' && sourceKeys.length === 0) {
|
||||
const e: any = new Error('Quelle nicht mehr vorhanden — bitte das Bild erneut hochladen.');
|
||||
e.friendly = 'Quelle nicht mehr vorhanden — bitte das Bild erneut hochladen.';
|
||||
throw e;
|
||||
}
|
||||
const sources = await Promise.all(sourceKeys.map(async (k) => {
|
||||
try { return await getObject(k); }
|
||||
catch {
|
||||
const e: any = new Error('Quelldatei nicht mehr vorhanden — bitte das Bild erneut hochladen.');
|
||||
e.friendly = 'Quelldatei nicht mehr vorhanden — bitte das Bild erneut hochladen.';
|
||||
throw e;
|
||||
}
|
||||
}));
|
||||
|
||||
const target = tasks.includes('format') && r.output_format
|
||||
? resolveDimensions({ format: r.output_format, orientation: r.orientation, dpi })
|
||||
: null;
|
||||
|
||||
const model = await pickModel(r.model_key);
|
||||
const prompt = buildPrompt({ tasks, cropMode, customInstruction: r.custom_instruction });
|
||||
|
||||
// Nur Modell aufrufen, wenn eine generative Aufgabe dabei ist.
|
||||
const needsModel = tasks.includes('clean') || wantCutout ||
|
||||
(tasks.includes('format') && cropMode === 'extend') || !!r.custom_instruction;
|
||||
// Prompt + ob das Modell überhaupt gebraucht wird, je Modus.
|
||||
let prompt: string;
|
||||
let needsModel: boolean;
|
||||
if (mode === 'compose') { prompt = buildComposePrompt(description, cropMode === 'extend', sourceKeys.length); needsModel = true; }
|
||||
else if (mode === 'generate') { prompt = buildGeneratePrompt(description); needsModel = true; }
|
||||
else {
|
||||
prompt = buildPrompt({ tasks, cropMode, customInstruction: r.custom_instruction });
|
||||
needsModel = tasks.includes('clean') || wantCutout ||
|
||||
(tasks.includes('format') && cropMode === 'extend') || !!r.custom_instruction;
|
||||
}
|
||||
|
||||
let working = source;
|
||||
let working = sources[0] || Buffer.alloc(0);
|
||||
let modelUsed: string | null = null;
|
||||
let cost = 0;
|
||||
|
||||
if (needsModel) {
|
||||
const inputUrls = sources.length ? await Promise.all(sources.map(toDataUrl)) : undefined;
|
||||
const gen = await generateImage({
|
||||
model: model.id,
|
||||
prompt,
|
||||
inputUrl: await toDataUrl(source),
|
||||
inputUrls,
|
||||
aspectRatio: target ? aspectRatio(target.w, target.h) : undefined,
|
||||
background: wantCutout ? 'transparent' : undefined,
|
||||
outputFormat: 'png',
|
||||
@@ -91,6 +132,7 @@ export async function processItem(itemId: string): Promise<ProcessResult> {
|
||||
modelUsed = gen.model;
|
||||
cost = gen.cost;
|
||||
}
|
||||
if (!working.length) throw new Error('Keine Bilddaten erzeugt');
|
||||
|
||||
// Lokale Nachbearbeitung: exakt aufs Zielmaß + dpi
|
||||
const fin = await finalizeToFormat(working, target, cropMode, dpi);
|
||||
@@ -102,30 +144,71 @@ export async function processItem(itemId: string): Promise<ProcessResult> {
|
||||
hasAlpha = true;
|
||||
}
|
||||
|
||||
const key = resultKey(item.id);
|
||||
await putObject(key, outBuf, 'image/png');
|
||||
// Ausgabeformat: Rezept-Override, sonst globale Einstellung. JPG nur ohne Transparenz.
|
||||
const gset = await one<{ output_ext: string | null }>('SELECT output_ext FROM settings WHERE id=1');
|
||||
const wantExt = (r.output_ext || gset?.output_ext || 'png').toLowerCase();
|
||||
let ext = 'png';
|
||||
let contentType = 'image/png';
|
||||
if (wantExt === 'jpg' && !hasAlpha) {
|
||||
outBuf = await sharp(outBuf, { failOn: 'none' })
|
||||
.flatten({ background: '#ffffff' })
|
||||
.withMetadata({ density: dpi })
|
||||
.jpeg({ quality: 92, mozjpeg: true, chromaSubsampling: '4:4:4' })
|
||||
.toBuffer();
|
||||
ext = 'jpg';
|
||||
contentType = 'image/jpeg';
|
||||
}
|
||||
|
||||
const filename = buildResultFilename(item.filename, formatToken(r.output_format), 'png');
|
||||
const key = resultKey(item.id);
|
||||
await putObject(key, outBuf, contentType);
|
||||
|
||||
// Vorschaubild (optional, laut Einstellung)
|
||||
const cfg = await one<{ make_thumbnails: boolean; keep_sources: boolean; nas_enabled: boolean }>(
|
||||
'SELECT make_thumbnails, keep_sources, nas_enabled FROM settings WHERE id=1');
|
||||
let thumbPath: string | null = null;
|
||||
if (cfg?.make_thumbnails !== false) {
|
||||
try {
|
||||
const tk = thumbKey(item.id);
|
||||
await putObject(tk, await makeThumb(outBuf), 'image/webp');
|
||||
thumbPath = tk;
|
||||
} catch (e) { console.error('[process] Thumbnail fehlgeschlagen', e); }
|
||||
}
|
||||
|
||||
const filename = buildResultFilename(item.filename, formatToken(r.output_format), ext);
|
||||
const outputPx = `${fin.width}x${fin.height}`;
|
||||
const deliveryStatus = (r.delivery === 'picdrop' || r.delivery === 'both') ? 'pending' : 'none';
|
||||
// Private Aufträge: kein Delivery, kein Backup, kein gespeicherter Prompt.
|
||||
const deliveryStatus = (!isPrivate && (r.delivery === 'picdrop' || r.delivery === 'both')) ? 'pending' : 'none';
|
||||
const nasStatus = (!isPrivate && cfg?.nas_enabled) ? 'pending' : 'none';
|
||||
|
||||
await query(
|
||||
`UPDATE items SET status='done', result_path=$2, filename=$3, output_px=$4, dpi=$5,
|
||||
has_alpha=$6, model_used=$7, prompt_used=$8, cost=$9, error_message=NULL,
|
||||
delivery_status=$10 WHERE id=$1`,
|
||||
[itemId, key, filename, outputPx, dpi, hasAlpha, modelUsed, prompt.slice(0, 1000),
|
||||
cost, deliveryStatus]);
|
||||
`UPDATE items SET status='done', result_path=$2, thumb_path=$3, filename=$4, output_px=$5, dpi=$6,
|
||||
has_alpha=$7, model_used=$8, prompt_used=$9, cost=$10, error_message=NULL,
|
||||
delivery_status=$11, nas_status=$12 WHERE id=$1`,
|
||||
[itemId, key, thumbPath, filename, outputPx, dpi, hasAlpha, modelUsed,
|
||||
isPrivate ? null : prompt.slice(0, 1000), cost, deliveryStatus, nasStatus]);
|
||||
|
||||
// Quellbilder löschen, wenn nicht behalten — bei privaten Aufträgen immer.
|
||||
if (isPrivate || cfg?.keep_sources === false) {
|
||||
for (const k of sourceKeys) await deleteObject(k).catch(() => {});
|
||||
if (isPrivate) await query(`UPDATE items SET source_path=NULL, source_paths=NULL WHERE id=$1`, [itemId]);
|
||||
}
|
||||
|
||||
// Zweite Sicherung auf alle Backup-Ziele — nicht bei privaten Aufträgen.
|
||||
if (!isPrivate) {
|
||||
try {
|
||||
const { backupItem } = await import('./backup');
|
||||
await backupItem(itemId);
|
||||
} catch (e) { console.error('[process] Backup fehlgeschlagen', e); }
|
||||
}
|
||||
|
||||
return { ok: true, cost };
|
||||
} catch (e: any) {
|
||||
const real = e?.message || String(e);
|
||||
const status = e?.status ? ` [status ${e.status}]` : '';
|
||||
console.error(`[process] Item ${itemId} fehlgeschlagen${status}: ${real}`, e?.stack || '');
|
||||
// Nutzeranzeige: freundlich, aber mit kompaktem Grund fürs Debugging in dieser Phase
|
||||
const msg = (e?.friendly ? `${e.friendly}` : real) + (e?.status ? ` (${e.status})` : '');
|
||||
await query(`UPDATE items SET status='failed', error_message=$2 WHERE id=$1`,
|
||||
[itemId, String(msg).slice(0, 500)]);
|
||||
// 402/Guthaben: nach oben durchreichen, damit der Worker den Auftrag anhält
|
||||
if (e?.status === 402) throw e;
|
||||
return { ok: false, cost: 0, error: msg };
|
||||
}
|
||||
|
||||
@@ -43,3 +43,33 @@ export function buildPrompt({ tasks, cropMode, customInstruction }: PromptOpts):
|
||||
parts.push('Output only the resulting image.');
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
/** Kombinieren/Umwandeln: eine ODER mehrere Vorlagen + Beschreibung → EIN neues Bild.
|
||||
* Bei einer Vorlage = Bild gemäß Anweisung umwandeln (z. B. Foto → Ölgemälde).
|
||||
* Bei mehreren = erste ist Leitbild, weitere liefern Elemente/Personen/Motive. */
|
||||
export function buildComposePrompt(description: string, extend = false, count = 2): string {
|
||||
const parts: string[] = [];
|
||||
if (count <= 1) {
|
||||
parts.push(
|
||||
'Transform the given image according to the instruction below into ONE new, ' +
|
||||
'high-resolution image. Keep the subject, composition and important details recognizable ' +
|
||||
'unless the instruction explicitly asks to change them.');
|
||||
} else {
|
||||
parts.push(
|
||||
'You are given several reference images. Combine them into ONE new, coherent, ' +
|
||||
'high-resolution image that follows the instruction below. Treat the first image as ' +
|
||||
'the main scene/style reference and use the other images as elements to integrate ' +
|
||||
'(people, pets, objects) — match their identity, colors and lighting faithfully so ' +
|
||||
'they look naturally part of the same photo.');
|
||||
}
|
||||
if (description.trim()) parts.push(`Instruction: ${description.trim()}`);
|
||||
if (extend) parts.push('Extend the scene naturally to fill the requested aspect ratio (outpainting).');
|
||||
parts.push('Output only the resulting image.');
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
/** Freitext: gar keine Vorlage → ein komplett neues Bild aus der Beschreibung. */
|
||||
export function buildGeneratePrompt(description: string): string {
|
||||
const d = description.trim() || 'A clean, high-quality image.';
|
||||
return `Create a new, high-resolution image. ${d} Output only the resulting image.`;
|
||||
}
|
||||
|
||||
+42
-1
@@ -15,6 +15,10 @@ export async function seed(): Promise<void> {
|
||||
('google/gemini-3.1-flash-image','Schnell','In wenigen Sekunden fertig. Für den Alltag.',true,true,true,0),
|
||||
('google/gemini-3-pro-image','Beste Qualität','Dauert länger, trifft Schrift und Details genauer.',true,false,true,1)`);
|
||||
}
|
||||
// Open-Source-Option (FLUX.2, offene Gewichte) nachrüsten — auch für bestehende Installationen.
|
||||
await query(`INSERT INTO models (model_id, label, description, active, is_default, supports_alpha, sort)
|
||||
SELECT 'black-forest-labs/flux.2-flex','Open Source (FLUX.2)','Offene Gewichte, gute Qualität. Kein transparentes Freistellen.',true,false,false,2
|
||||
WHERE NOT EXISTS (SELECT 1 FROM models WHERE model_id='black-forest-labs/flux.2-flex')`);
|
||||
|
||||
// Nutzer
|
||||
const till = await one(`SELECT id FROM users WHERE username='till'`);
|
||||
@@ -44,8 +48,45 @@ export async function seed(): Promise<void> {
|
||||
|
||||
await R('Nur bereinigen', ['clean'], 'keep', 'landscape', { is_default: true });
|
||||
await R('Poster 30×40', ['clean', 'format'], '30x40', 'portrait');
|
||||
await R('The Frame', ['clean', 'format'], 'theframe', 'landscape');
|
||||
await R('The Frame', ['clean', 'format'], 'theframe', 'landscape',
|
||||
{ delivery: 'both', picdrop_gallery: 'TheFrame-Backgrounds' });
|
||||
await R('Sticker 5 cm', ['clean', 'cutout', 'format', 'contour'], 'sticker5', 'landscape',
|
||||
{ contour_mm: 3 });
|
||||
}
|
||||
|
||||
// Druck-Vorlagen (Bogen ohne KI) — idempotent über den Namen, auch für bestehende Installationen.
|
||||
{
|
||||
const admin = await one<{ id: string }>(`SELECT id FROM users WHERE username='till'`);
|
||||
const by = admin?.id ?? null;
|
||||
const P = async (name: string, config: Record<string, unknown>) => {
|
||||
const exists = await one(`SELECT id FROM print_presets WHERE name=$1`, [name]);
|
||||
if (exists) return;
|
||||
await query(`INSERT INTO print_presets (name, created_by, config) VALUES ($1,$2,$3)`,
|
||||
[name, by, JSON.stringify(config)]);
|
||||
};
|
||||
const sheet = {
|
||||
paperId: 'A4', paperCustom: '', paperLandscape: false, marginMm: 5,
|
||||
autoGap: true, gapMm: 14, bleedMm: 0,
|
||||
marks: 'corner', markLen: 4, markOff: 3, dpi: 300, ext: 'jpg', center: true, footer: true,
|
||||
};
|
||||
const F = (sizeId: string, count: number, landscape = false) =>
|
||||
({ sizeId, customSize: '', landscape, count, allowRotate: true });
|
||||
|
||||
// Der klassische Kita-/Schulfoto-Satz aus einem Bild.
|
||||
await P('Kita-Satz (A4)', { ...sheet, formats: [F('S13x18', 1), F('S9x13', 2), F('P35x45', 8)] });
|
||||
// Nur die kleinen Abzüge, wenn der große schon gedruckt ist.
|
||||
await P('Schulsatz klein (A4)', { ...sheet, formats: [F('S9x13', 4), F('K30x40', 8)] });
|
||||
// Voller Passbildbogen — durchgehende Linien, weil alles gleich groß ist.
|
||||
await P('Passbildbogen 35×45 (A4)', { ...sheet, marks: 'grid', autoGap: false, gapMm: 4, formats: [F('P35x45', 20)] });
|
||||
// Zwei Bilder in Originalgröße auf ein Blatt 10×15-Fotopapier.
|
||||
await P('2 × 10×7,5 auf Fotopapier 10×15', {
|
||||
...sheet, paperId: 'F10x15', marginMm: 0, marks: 'grid', autoGap: false, gapMm: 0,
|
||||
formats: [{ sizeId: 'custom', customSize: '10x7,5', landscape: false, count: 2, allowRotate: true }],
|
||||
});
|
||||
}
|
||||
|
||||
// The-Frame-Rezept auf die eigene Galerie verdrahten (auch für bestehende Installationen).
|
||||
await query(
|
||||
`UPDATE recipes SET picdrop_gallery='TheFrame-Backgrounds', delivery='both'
|
||||
WHERE output_format='theframe' AND (picdrop_gallery IS NULL OR picdrop_gallery='')`);
|
||||
}
|
||||
|
||||
@@ -90,3 +90,6 @@ export function sourceKey(uuid: string, ext: string): string {
|
||||
export function resultKey(uuid: string): string {
|
||||
return `results/${new Date().getFullYear()}/${uuid}.png`;
|
||||
}
|
||||
export function thumbKey(uuid: string): string {
|
||||
return `thumbs/${new Date().getFullYear()}/${uuid}.webp`;
|
||||
}
|
||||
|
||||
+301
-43
@@ -1,17 +1,43 @@
|
||||
// Telegram-Bot (grammY, Webhook) — vollwertiger Eingangs- und Bedien-Kanal.
|
||||
// Kopplung, Bündelung weitergeleiteter Bilder, Rezeptwahl, Verarbeitung, Rückmeldung.
|
||||
import { Bot, InputFile, InlineKeyboard } from 'grammy';
|
||||
import { Bot, InputFile, InlineKeyboard, Keyboard } from 'grammy';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { one, query } from './db';
|
||||
import { decrypt } from './crypto';
|
||||
import { getObject, putObject, sourceKey } from './storage';
|
||||
import { enqueue } from './queue';
|
||||
import { layout, cutMarks, recommendedGap, type SheetSpec } from './printlayout';
|
||||
import { renderCell, buildSheetPdf } from './printrender';
|
||||
import { photoById, labelMm, paperById } from './paper';
|
||||
|
||||
const QUIET_MS = 4000; // Ruhefenster für die Bündelung
|
||||
const BASE = process.env.PUBLIC_BASE_URL || '';
|
||||
|
||||
let bot: Bot | null = null;
|
||||
let webhookSecret = '';
|
||||
// Zwischenspeicher: Freitext, aus dem der Nutzer per Knopf ein Bild erzeugen kann.
|
||||
const pendingGenerate = new Map<number, string>();
|
||||
// Chats, die gerade eine Bildbeschreibung eintippen (nach Tippen auf „Neues Bild").
|
||||
const awaitingGenerate = new Set<number>();
|
||||
|
||||
// Menü-Beschriftungen (Dauertastatur) — alles per Fingertipp, ohne Befehle.
|
||||
const BTN_NEW = '✨ Neues Bild', BTN_RECIPES = '📁 Rezept wählen', BTN_HELP = '❓ Hilfe';
|
||||
function mainKeyboard() {
|
||||
return new Keyboard().text(BTN_NEW).row().text(BTN_RECIPES).text(BTN_HELP).resized().persistent();
|
||||
}
|
||||
// Druckbogen per Telegram: feste, knopfbare Auswahl — kein Freitext nötig.
|
||||
const PRINT_SIZES = ['P35x45', 'K30x40', 'S9x13', 'S10x15', 'S12x15', 'S13x18'];
|
||||
const PRINT_COUNTS = [1, 2, 4, 8, 0]; // 0 = „Bogen füllen"
|
||||
|
||||
const HELP_TEXT =
|
||||
'So funktioniert’s — alles per Knopf, ohne Befehle:\n\n' +
|
||||
'🖼 *Bearbeiten:* Bild(er) hierher schicken (am besten als *Datei*), dann unten das Rezept antippen.\n' +
|
||||
'🔀 *Kombinieren:* 2+ Bilder schicken, ins Bild-Textfeld eine Beschreibung wie „mit unserer Hündin Frieda" — danach „🔀 Kombinieren" antippen.\n' +
|
||||
'✨ *Neues Bild:* unten „✨ Neues Bild" tippen und beschreiben, was entstehen soll.\n' +
|
||||
'♻️ *Weiterbearbeiten:* ein fertiges Bild von mir einfach wieder zurückschicken.\n' +
|
||||
'📐 *Druckbogen (ohne KI):* Bild schicken → „📐 Druckbogen" → Maß und Anzahl antippen. ' +
|
||||
'Du bekommst ein PDF in 100 %-Größe mit Schnittmarken.\n\n' +
|
||||
'Ich melde mich einmal, wenn alles fertig ist — mit Ergebnis und Link.';
|
||||
|
||||
export async function getToken(): Promise<string> {
|
||||
const s = await one<{ telegram_bot_token_enc: string | null }>('SELECT telegram_bot_token_enc FROM settings WHERE id=1');
|
||||
@@ -46,6 +72,16 @@ export async function setupTelegram(): Promise<void> {
|
||||
console.log('[telegram] Webhook gesetzt:', `${BASE}/api/telegram/webhook`);
|
||||
} catch (e) { console.error('[telegram] setWebhook fehlgeschlagen:', e); }
|
||||
}
|
||||
// Befehlsmenü (das „/"-Menü in Telegram) — als zusätzliche Abkürzung.
|
||||
try {
|
||||
await b.api.setMyCommands([
|
||||
{ command: 'neu', description: '✨ Neues Bild aus Text erzeugen' },
|
||||
{ command: 'rezepte', description: '📁 Standard-Rezept wählen' },
|
||||
{ command: 'status', description: '⏳ Letzten Auftrag anzeigen' },
|
||||
{ command: 'hilfe', description: '❓ Hilfe & Anleitung' },
|
||||
{ command: 'start', description: '🏠 Menü zeigen' },
|
||||
]);
|
||||
} catch (e) { console.error('[telegram] setMyCommands:', e); }
|
||||
setInterval(() => { sweepDrafts().catch((e) => console.error('[telegram] sweep', e)); }, 3000);
|
||||
}
|
||||
|
||||
@@ -66,20 +102,22 @@ async function tryPair(chatId: number, code: string): Promise<boolean> {
|
||||
}
|
||||
|
||||
// --- Bündelung (Drafts) -----------------------------------------------------
|
||||
async function addToDraft(chatId: number, mediaGroup: string | null, fileRef: any, noticeMsgId?: number) {
|
||||
const existing = await one<{ id: string; file_refs: any[] }>(
|
||||
`SELECT id, file_refs FROM telegram_drafts WHERE chat_id=$1 AND status='collecting'
|
||||
async function addToDraft(chatId: number, mediaGroup: string | null, fileRef: any, caption?: string | null) {
|
||||
const existing = await one<{ id: string; file_refs: any[]; caption: string | null }>(
|
||||
`SELECT id, file_refs, caption FROM telegram_drafts WHERE chat_id=$1 AND status='collecting'
|
||||
ORDER BY last_received_at DESC LIMIT 1`, [chatId]);
|
||||
if (existing) {
|
||||
const refs = [...(existing.file_refs || []), fileRef];
|
||||
await query(`UPDATE telegram_drafts SET file_refs=$2, last_received_at=now() WHERE id=$1`,
|
||||
[existing.id, JSON.stringify(refs)]);
|
||||
// Erste vorhandene Bildunterschrift als Beschreibung merken.
|
||||
const cap = existing.caption || (caption?.trim() || null);
|
||||
await query(`UPDATE telegram_drafts SET file_refs=$2, caption=$3, last_received_at=now() WHERE id=$1`,
|
||||
[existing.id, JSON.stringify(refs), cap]);
|
||||
return existing.id;
|
||||
}
|
||||
const row = await one<{ id: string }>(
|
||||
`INSERT INTO telegram_drafts (chat_id, media_group_id, file_refs, status, notice_message_id)
|
||||
VALUES ($1,$2,$3,'collecting',$4) RETURNING id`,
|
||||
[chatId, mediaGroup, JSON.stringify([fileRef]), noticeMsgId ?? null]);
|
||||
`INSERT INTO telegram_drafts (chat_id, media_group_id, file_refs, caption, status)
|
||||
VALUES ($1,$2,$3,$4,'collecting') RETURNING id`,
|
||||
[chatId, mediaGroup, JSON.stringify([fileRef]), caption?.trim() || null]);
|
||||
return row!.id;
|
||||
}
|
||||
|
||||
@@ -87,18 +125,22 @@ async function addToDraft(chatId: number, mediaGroup: string | null, fileRef: an
|
||||
export async function sweepDrafts(): Promise<void> {
|
||||
const b = await getBot(); if (!b) return;
|
||||
const drafts = await query<any>(
|
||||
`SELECT id, chat_id, file_refs FROM telegram_drafts
|
||||
`SELECT id, chat_id, file_refs, caption FROM telegram_drafts
|
||||
WHERE status='collecting' AND last_received_at < now() - interval '${Math.round(QUIET_MS / 1000)} seconds'`);
|
||||
for (const d of drafts) {
|
||||
const recipes = await query<any>('SELECT id, name FROM recipes ORDER BY is_default DESC, name LIMIT 8');
|
||||
const recipes = await query<any>('SELECT id, name FROM recipes ORDER BY is_default DESC, name LIMIT 6');
|
||||
const kb = new InlineKeyboard();
|
||||
// Callback-Daten <64 Bytes: nur Rezept-UUID; der Draft wird beim Klick über den Chat gefunden.
|
||||
recipes.forEach((r, i) => { kb.text(r.name, `r:${r.id}`); if (i % 2 === 1) kb.row(); });
|
||||
const n = (d.file_refs || []).length;
|
||||
// Ab 2 Bildern zusätzlich „Kombinieren" anbieten.
|
||||
if (n >= 2) { kb.row(); kb.text('🔀 Zu einem Bild kombinieren', 'c:x'); }
|
||||
kb.row(); kb.text('📐 Druckbogen (ohne KI)', 'p:menu');
|
||||
const compressed = (d.file_refs || []).some((f: any) => f.quality === 'compressed');
|
||||
const capNote = d.caption ? `\n📝 Beschreibung erkannt: „${d.caption}" — für „Kombinieren".` : '';
|
||||
try {
|
||||
await b.api.sendMessage(d.chat_id,
|
||||
`${n} Bild${n > 1 ? 'er' : ''} empfangen. Welches Rezept?` +
|
||||
`${n} Bild${n > 1 ? 'er' : ''} empfangen. Was möchtest du tun?` + capNote +
|
||||
(compressed ? '\n⚠️ Als Foto gesendet (komprimiert). Für volle Qualität als *Datei* senden.' : ''),
|
||||
{ reply_markup: kb, parse_mode: 'Markdown' });
|
||||
await query(`UPDATE telegram_drafts SET status='awaiting_recipe' WHERE id=$1`, [d.id]);
|
||||
@@ -107,19 +149,12 @@ export async function sweepDrafts(): Promise<void> {
|
||||
}
|
||||
|
||||
// --- Draft -> Auftrag -------------------------------------------------------
|
||||
async function dispatchDraft(chatId: number, draftId: string, recipeId: string, b: Bot) {
|
||||
const draft = await one<any>('SELECT * FROM telegram_drafts WHERE id=$1', [draftId]);
|
||||
if (!draft || draft.status === 'dispatched') return;
|
||||
const recipe = await one<any>('SELECT * FROM recipes WHERE id=$1', [recipeId]);
|
||||
if (!recipe) { await b.api.sendMessage(chatId, 'Rezept nicht gefunden.'); return; }
|
||||
const link = await linkedUser(chatId);
|
||||
|
||||
await b.api.sendMessage(chatId, `⏳ Verarbeite ${(draft.file_refs || []).length} Bild(er) …`);
|
||||
|
||||
// Bilder von Telegram laden und in den Objektspeicher legen
|
||||
/** Lädt alle Bilder eines Drafts von Telegram und legt sie in den Objektspeicher. */
|
||||
async function refsToSources(refs: any[], b: Bot): Promise<any[]> {
|
||||
const sources: any[] = [];
|
||||
const token = await getToken();
|
||||
for (const ref of draft.file_refs || []) {
|
||||
for (const ref of refs || []) {
|
||||
try {
|
||||
const file = await b.api.getFile(ref.file_id);
|
||||
const url = `https://api.telegram.org/file/bot${token}/${file.file_path}`;
|
||||
@@ -129,6 +164,32 @@ async function dispatchDraft(chatId: number, draftId: string, recipeId: string,
|
||||
sources.push({ source_path: key, filename: ref.name || 'telegram.jpg', quality: ref.quality || 'original' });
|
||||
} catch (e) { console.error('[telegram] Datei laden fehlgeschlagen', e); }
|
||||
}
|
||||
return sources;
|
||||
}
|
||||
|
||||
/** Basis-Snapshot aus dem Standard-Rezept des Chats (Format/Modell/Auslieferung). */
|
||||
async function chatRecipeDefaults(chatId: number): Promise<any> {
|
||||
const link = await linkedUser(chatId);
|
||||
if (link?.default_recipe_id) {
|
||||
const r = await one<any>('SELECT * FROM recipes WHERE id=$1', [link.default_recipe_id]);
|
||||
if (r) return {
|
||||
output_format: r.output_format, orientation: r.orientation, crop_mode: r.crop_mode || 'crop',
|
||||
dpi: r.dpi || 300, model_key: r.model_key, delivery: r.delivery || 'library',
|
||||
picdrop_gallery: r.picdrop_gallery, output_ext: r.output_ext, delivery_target_id: r.delivery_target_id,
|
||||
};
|
||||
}
|
||||
return { output_format: 'keep', crop_mode: 'crop', dpi: 300, delivery: 'library', output_ext: null };
|
||||
}
|
||||
|
||||
async function dispatchDraft(chatId: number, draftId: string, recipeId: string, b: Bot) {
|
||||
const draft = await one<any>('SELECT * FROM telegram_drafts WHERE id=$1', [draftId]);
|
||||
if (!draft || draft.status === 'dispatched') return;
|
||||
const recipe = await one<any>('SELECT * FROM recipes WHERE id=$1', [recipeId]);
|
||||
if (!recipe) { await b.api.sendMessage(chatId, 'Rezept nicht gefunden.'); return; }
|
||||
const link = await linkedUser(chatId);
|
||||
|
||||
await b.api.sendMessage(chatId, `⏳ Verarbeite ${(draft.file_refs || []).length} Bild(er) …`);
|
||||
const sources = await refsToSources(draft.file_refs || [], b);
|
||||
if (!sources.length) { await b.api.sendMessage(chatId, '❌ Keine Bilder ladbar.'); return; }
|
||||
|
||||
const snap = {
|
||||
@@ -136,10 +197,11 @@ async function dispatchDraft(chatId: number, draftId: string, recipeId: string,
|
||||
crop_mode: recipe.crop_mode || 'crop', dpi: recipe.dpi || 300, contour_mm: recipe.contour_mm,
|
||||
model_key: recipe.model_key, custom_instruction: recipe.custom_instruction,
|
||||
delivery: recipe.delivery || 'library', picdrop_gallery: recipe.picdrop_gallery,
|
||||
output_ext: recipe.output_ext, delivery_target_id: recipe.delivery_target_id,
|
||||
};
|
||||
const job = await one<{ id: string }>(
|
||||
`INSERT INTO jobs (created_by, origin, recipe_snapshot, status, total, telegram_chat_id)
|
||||
VALUES ($1,'telegram',$2,'queued',$3,$4) RETURNING id`,
|
||||
`INSERT INTO jobs (created_by, origin, mode, recipe_snapshot, status, total, telegram_chat_id)
|
||||
VALUES ($1,'telegram','each',$2,'queued',$3,$4) RETURNING id`,
|
||||
[link?.user_id || null, JSON.stringify(snap), sources.length, chatId]);
|
||||
for (let i = 0; i < sources.length; i++) {
|
||||
const it = await one<{ id: string }>(
|
||||
@@ -151,6 +213,114 @@ async function dispatchDraft(chatId: number, draftId: string, recipeId: string,
|
||||
await query(`UPDATE telegram_drafts SET status='dispatched' WHERE id=$1`, [draftId]);
|
||||
}
|
||||
|
||||
/** Kombinieren: alle Bilder des Drafts + Beschreibung → ein neues Bild. */
|
||||
async function dispatchCompose(chatId: number, draftId: string, description: string, b: Bot) {
|
||||
const draft = await one<any>('SELECT * FROM telegram_drafts WHERE id=$1', [draftId]);
|
||||
if (!draft || draft.status === 'dispatched') return;
|
||||
const link = await linkedUser(chatId);
|
||||
await b.api.sendMessage(chatId, '⏳ Kombiniere die Bilder …');
|
||||
const sources = await refsToSources(draft.file_refs || [], b);
|
||||
if (sources.length < 2) { await b.api.sendMessage(chatId, '❌ Zum Kombinieren brauche ich mindestens 2 Bilder.'); return; }
|
||||
|
||||
const d = await chatRecipeDefaults(chatId);
|
||||
const wantsFormat = d.output_format && d.output_format !== 'keep';
|
||||
const snap = {
|
||||
tasks: wantsFormat ? ['format'] : [], output_format: d.output_format, orientation: d.orientation,
|
||||
crop_mode: d.crop_mode || 'crop', dpi: d.dpi || 300, model_key: d.model_key,
|
||||
prompt_text: description, delivery: d.delivery || 'library', picdrop_gallery: d.picdrop_gallery,
|
||||
output_ext: d.output_ext, delivery_target_id: d.delivery_target_id,
|
||||
};
|
||||
const job = await one<{ id: string }>(
|
||||
`INSERT INTO jobs (created_by, origin, mode, recipe_snapshot, status, total, telegram_chat_id)
|
||||
VALUES ($1,'telegram','compose',$2,'queued',1,$3) RETURNING id`,
|
||||
[link?.user_id || null, JSON.stringify(snap), chatId]);
|
||||
const it = await one<{ id: string }>(
|
||||
`INSERT INTO items (job_id, position, status, source_paths, filename, source_quality)
|
||||
VALUES ($1,0,'queued',$2,'kombiniert','original') RETURNING id`,
|
||||
[job!.id, JSON.stringify(sources.map((s) => s.source_path))]);
|
||||
await enqueue({ itemId: it!.id, jobId: job!.id });
|
||||
await query(`UPDATE telegram_drafts SET status='dispatched' WHERE id=$1`, [draftId]);
|
||||
}
|
||||
|
||||
/** Freitext: ein komplett neues Bild allein aus der Beschreibung. */
|
||||
async function dispatchGenerate(chatId: number, description: string, b: Bot) {
|
||||
const link = await linkedUser(chatId);
|
||||
await b.api.sendMessage(chatId, '⏳ Erzeuge ein neues Bild …');
|
||||
const d = await chatRecipeDefaults(chatId);
|
||||
const wantsFormat = d.output_format && d.output_format !== 'keep';
|
||||
const snap = {
|
||||
tasks: wantsFormat ? ['format'] : [], output_format: d.output_format, orientation: d.orientation,
|
||||
crop_mode: d.crop_mode || 'crop', dpi: d.dpi || 300, model_key: d.model_key,
|
||||
prompt_text: description, delivery: d.delivery || 'library', picdrop_gallery: d.picdrop_gallery,
|
||||
output_ext: d.output_ext, delivery_target_id: d.delivery_target_id,
|
||||
};
|
||||
const job = await one<{ id: string }>(
|
||||
`INSERT INTO jobs (created_by, origin, mode, recipe_snapshot, status, total, telegram_chat_id)
|
||||
VALUES ($1,'telegram','generate',$2,'queued',1,$3) RETURNING id`,
|
||||
[link?.user_id || null, JSON.stringify(snap), chatId]);
|
||||
const it = await one<{ id: string }>(
|
||||
`INSERT INTO items (job_id, position, status, filename, source_quality)
|
||||
VALUES ($1,0,'queued','neu','original') RETURNING id`, [job!.id]);
|
||||
await enqueue({ itemId: it!.id, jobId: job!.id });
|
||||
}
|
||||
|
||||
/**
|
||||
* Druckbogen ohne KI: Bilder des Drafts auf ein Maß bringen, auf A4 setzen,
|
||||
* Schnittmarken dazu, als PDF zurückschicken. Läuft direkt (keine Warteschlange,
|
||||
* kein Modell, keine Kosten).
|
||||
*/
|
||||
async function dispatchPrint(chatId: number, draftId: string, sizeId: string, count: number, b: Bot) {
|
||||
const draft = await one<any>('SELECT * FROM telegram_drafts WHERE id=$1', [draftId]);
|
||||
if (!draft) { await b.api.sendMessage(chatId, 'Kein offener Bild-Stapel gefunden.'); return; }
|
||||
const size = photoById(sizeId);
|
||||
if (!size) { await b.api.sendMessage(chatId, 'Unbekanntes Maß.'); return; }
|
||||
|
||||
await b.api.sendMessage(chatId, '📐 Baue den Druckbogen …');
|
||||
const sources = await refsToSources(draft.file_refs || [], b);
|
||||
if (!sources.length) { await b.api.sendMessage(chatId, '❌ Keine Bilder ladbar.'); return; }
|
||||
|
||||
const a4 = paperById('A4')!;
|
||||
const sheet: SheetSpec = {
|
||||
wMm: a4.w, hMm: a4.h, marginMm: 5,
|
||||
gapMm: recommendedGap('corner', 0, 4, 3), bleedMm: 0, center: true,
|
||||
};
|
||||
// „Bogen füllen": so oft wie möglich, gleichmäßig auf die Bilder verteilt.
|
||||
let per = count;
|
||||
if (!per) {
|
||||
const probe = layout([{ id: 'x', wMm: size.w, hMm: size.h, count: 200, allowRotate: true }], sheet);
|
||||
per = Math.max(1, Math.floor((probe.pages[0]?.placements.length || 1) / sources.length));
|
||||
}
|
||||
const specs = sources.map((s, i) => ({ id: `s${i}`, wMm: size.w, hMm: size.h, count: per, allowRotate: true }));
|
||||
const plan = layout(specs, sheet);
|
||||
if (!plan.pages.length) { await b.api.sendMessage(chatId, '❌ Das Maß passt nicht auf A4.'); return; }
|
||||
|
||||
const images: Record<string, { bytes: Buffer; ext: 'jpg' | 'png' }> = {};
|
||||
for (const [i, src] of sources.entries()) {
|
||||
const buf = await getObject(src.source_path);
|
||||
const rots = new Set(plan.pages.flatMap((pg) => pg.placements.filter((p) => p.specId === `s${i}`).map((p) => p.rotated)));
|
||||
for (const rot of rots) {
|
||||
const out = await renderCell(buf, null, size.w, size.h, 300, { ext: 'jpg', rotate: rot });
|
||||
images[rot ? `s${i}::rot` : `s${i}`] = { bytes: out.buffer, ext: out.ext };
|
||||
}
|
||||
}
|
||||
const pages = plan.pages.map((pg) => ({
|
||||
placements: pg.placements.map((p) => ({ ...p, specId: p.rotated ? `${p.specId}::rot` : p.specId })),
|
||||
}));
|
||||
const marks = plan.pages.map((pg) => cutMarks(pg, sheet, { mode: 'corner', lengthMm: 4, offsetMm: 3 }));
|
||||
const label = labelMm(size.w, size.h);
|
||||
const bytes = await buildSheetPdf({
|
||||
sheet, pages, marksPerPage: marks, images, title: `Klarbild Druckbogen ${label}`,
|
||||
footer: `Klarbild · DIN A4 · ${label} · 300 dpi · Druck bei 100 % (nicht „an Seite anpassen")`,
|
||||
});
|
||||
|
||||
const file = new InputFile(Buffer.from(bytes), `klarbild-druckbogen-${label.replace(/[^0-9]+/g, 'x')}.pdf`);
|
||||
await b.api.sendDocument(chatId, file, {
|
||||
caption: `📐 ${plan.pages.length} Bogen · ${plan.pages[0].placements.length} Bild(er) auf Bogen 1 · ${label}\n` +
|
||||
'Beim Drucken „Tatsächliche Größe / 100 %" wählen — sonst stimmt das Maß nicht.',
|
||||
});
|
||||
await query(`UPDATE telegram_drafts SET status='dispatched' WHERE id=$1`, [draftId]);
|
||||
}
|
||||
|
||||
/** Vom Worker aufgerufen: eine Rückmeldung an den Chat, wenn der Auftrag fertig ist. */
|
||||
export async function notifyJobDone(chatId: number, jobId: string): Promise<void> {
|
||||
const b = await getBot(); if (!b) return;
|
||||
@@ -172,27 +342,39 @@ export async function notifyJobDone(chatId: number, jobId: string): Promise<void
|
||||
let msg = `✅ Fertig: ${done.length} Bild(er)${failed.length ? `, ${failed.length} fehlgeschlagen` : ''}.`;
|
||||
if (delivered) msg += `\n📁 ${delivered} in Picdrop-Galerie ausgeliefert.`;
|
||||
if (BASE) msg += `\n🔗 Bibliothek: ${BASE}/bibliothek`;
|
||||
await b.api.sendMessage(chatId, msg);
|
||||
if (done.length) msg += `\n♻️ Weiterbearbeiten? Schick ein Ergebnis einfach zurück.`;
|
||||
await b.api.sendMessage(chatId, msg, { reply_markup: mainKeyboard() });
|
||||
}
|
||||
|
||||
// --- Handler ----------------------------------------------------------------
|
||||
function register(b: Bot) {
|
||||
b.command('start', async (ctx) => {
|
||||
const link = await linkedUser(ctx.chat.id);
|
||||
if (link) return ctx.reply('👋 Klarbild-Bot ist verbunden. Leite mir Bilder weiter (als *Datei* für volle Qualität), dann wähle ein Rezept. /help für mehr.', { parse_mode: 'Markdown' });
|
||||
return ctx.reply('👋 Willkommen bei Klarbild. Zum Koppeln bitte den *Kopplungscode* aus dem Admin-Bereich hier eingeben.', { parse_mode: 'Markdown' });
|
||||
});
|
||||
|
||||
b.command('help', (ctx) => ctx.reply(
|
||||
'So geht’s:\n• Bilder weiterleiten (einzeln oder als Album) — am besten als *Datei*.\n• Rezept wählen.\n• Ich melde mich einmal, wenn alles fertig ist — mit den Ergebnissen und Links.\n\nBefehle: /rezepte (Standard setzen) · /status · /start',
|
||||
{ parse_mode: 'Markdown' }));
|
||||
|
||||
b.command('rezepte', async (ctx) => {
|
||||
if (!(await linkedUser(ctx.chat.id))) return ctx.reply('Bitte zuerst koppeln (Code aus dem Admin).');
|
||||
const sendRecipeMenu = async (ctx: any) => {
|
||||
const recipes = await query<any>('SELECT id, name FROM recipes ORDER BY is_default DESC, name LIMIT 10');
|
||||
const kb = new InlineKeyboard();
|
||||
recipes.forEach((r, i) => { kb.text(r.name, `d:${r.id}`); if (i % 2 === 1) kb.row(); });
|
||||
return ctx.reply('Standard-Rezept für diesen Chat wählen:', { reply_markup: kb });
|
||||
return ctx.reply('Welches Rezept soll für neue Bilder standardmäßig gelten?', { reply_markup: kb });
|
||||
};
|
||||
|
||||
b.command('start', async (ctx) => {
|
||||
const link = await linkedUser(ctx.chat.id);
|
||||
if (link) return ctx.reply('👋 Klarbild ist verbunden. Schick mir Bilder zum Bearbeiten/Kombinieren, oder tippe unten auf „✨ Neues Bild".', { reply_markup: mainKeyboard() });
|
||||
return ctx.reply('👋 Willkommen bei Klarbild. Zum Koppeln bitte den *Kopplungscode* aus dem Admin-Bereich hier eingeben.', { parse_mode: 'Markdown' });
|
||||
});
|
||||
|
||||
const sendHelp = (ctx: any) => ctx.reply(HELP_TEXT, { parse_mode: 'Markdown', reply_markup: mainKeyboard() });
|
||||
b.command('help', sendHelp);
|
||||
b.command('hilfe', sendHelp);
|
||||
|
||||
b.command('neu', async (ctx) => {
|
||||
if (!(await linkedUser(ctx.chat.id))) return ctx.reply('Bitte zuerst koppeln (Code aus dem Admin).');
|
||||
const text = (ctx.match || '').toString().trim();
|
||||
if (!text) { awaitingGenerate.add(ctx.chat.id); return ctx.reply('Beschreibe kurz dein Bild — was soll entstehen?'); }
|
||||
const b2 = await getBot(); if (b2) await dispatchGenerate(ctx.chat.id, text, b2);
|
||||
});
|
||||
|
||||
b.command('rezepte', async (ctx) => {
|
||||
if (!(await linkedUser(ctx.chat.id))) return ctx.reply('Bitte zuerst koppeln (Code aus dem Admin).');
|
||||
return sendRecipeMenu(ctx);
|
||||
});
|
||||
|
||||
b.command('status', async (ctx) => {
|
||||
@@ -205,7 +387,8 @@ function register(b: Bot) {
|
||||
|
||||
const onImage = async (ctx: any, fileId: string, name: string, quality: 'original' | 'compressed') => {
|
||||
if (!(await linkedUser(ctx.chat.id))) return ctx.reply('⛔️ Nicht gekoppelt. Bitte Kopplungscode aus dem Admin eingeben.');
|
||||
await addToDraft(ctx.chat.id, ctx.message?.media_group_id || null, { file_id: fileId, name, quality });
|
||||
await addToDraft(ctx.chat.id, ctx.message?.media_group_id || null,
|
||||
{ file_id: fileId, name, quality }, ctx.message?.caption || null);
|
||||
};
|
||||
b.on('message:photo', async (ctx) => {
|
||||
const p = ctx.message.photo[ctx.message.photo.length - 1];
|
||||
@@ -219,16 +402,71 @@ function register(b: Bot) {
|
||||
|
||||
b.on('message:text', async (ctx) => {
|
||||
if (ctx.message.text.startsWith('/')) return;
|
||||
if (await linkedUser(ctx.chat.id)) return; // gekoppelt: Text ignorieren
|
||||
if (await tryPair(ctx.chat.id, ctx.message.text)) {
|
||||
return ctx.reply('✅ Verbunden! Leite mir jetzt Bilder weiter. /help für mehr.');
|
||||
const text = ctx.message.text.trim();
|
||||
const linked = await linkedUser(ctx.chat.id);
|
||||
if (linked) {
|
||||
// Dauertastatur-Knöpfe
|
||||
if (text === BTN_HELP) return sendHelp(ctx);
|
||||
if (text === BTN_RECIPES) return sendRecipeMenu(ctx);
|
||||
if (text === BTN_NEW) { awaitingGenerate.add(ctx.chat.id); return ctx.reply('Beschreibe kurz dein Bild — was soll entstehen?'); }
|
||||
|
||||
// Wartet ein Kombinieren-Draft auf die Beschreibung? → Text als Beschreibung nehmen.
|
||||
const waiting = await one<{ id: string }>(
|
||||
`SELECT id FROM telegram_drafts WHERE chat_id=$1 AND status='awaiting_compose_text'
|
||||
ORDER BY last_received_at DESC LIMIT 1`, [ctx.chat.id]);
|
||||
if (waiting) {
|
||||
const b2 = await getBot(); if (b2) await dispatchCompose(ctx.chat.id, waiting.id, text, b2);
|
||||
return;
|
||||
}
|
||||
// Nutzer hat „Neues Bild" angetippt und tippt jetzt die Beschreibung.
|
||||
if (awaitingGenerate.has(ctx.chat.id)) {
|
||||
awaitingGenerate.delete(ctx.chat.id);
|
||||
const b2 = await getBot(); if (b2) await dispatchGenerate(ctx.chat.id, text, b2);
|
||||
return;
|
||||
}
|
||||
// Sonst: Freitext als Angebot zum Neu-Erzeugen anbieten.
|
||||
const kb = new InlineKeyboard().text('✨ Ja, Bild daraus erzeugen', 'g:x');
|
||||
pendingGenerate.set(ctx.chat.id, text);
|
||||
return ctx.reply('Soll ich daraus ein neues Bild erzeugen?', { reply_markup: kb });
|
||||
}
|
||||
if (await tryPair(ctx.chat.id, text)) {
|
||||
return ctx.reply('✅ Verbunden! Schick mir Bilder oder tippe unten auf „✨ Neues Bild".', { reply_markup: mainKeyboard() });
|
||||
}
|
||||
return ctx.reply('Code ungültig oder abgelaufen. Neuen Code im Admin erzeugen.');
|
||||
});
|
||||
|
||||
b.on('callback_query:data', async (ctx) => {
|
||||
const [kind, recipeId] = ctx.callbackQuery.data.split(':');
|
||||
const [kind, recipeId, extra] = ctx.callbackQuery.data.split(':');
|
||||
await ctx.answerCallbackQuery();
|
||||
if (kind === 'p') { // Druckbogen ohne KI
|
||||
const draft = await one<{ id: string }>(
|
||||
`SELECT id FROM telegram_drafts WHERE chat_id=$1 AND status IN ('awaiting_recipe','awaiting_print')
|
||||
ORDER BY last_received_at DESC LIMIT 1`, [ctx.chat!.id]);
|
||||
if (!draft) return ctx.editMessageText('Kein offener Bild-Stapel gefunden — bitte Bilder neu senden.');
|
||||
if (recipeId === 'menu') {
|
||||
await query(`UPDATE telegram_drafts SET status='awaiting_print' WHERE id=$1`, [draft.id]);
|
||||
const kb = new InlineKeyboard();
|
||||
PRINT_SIZES.forEach((id, i) => {
|
||||
const s = photoById(id); if (!s) return;
|
||||
kb.text(labelMm(s.w, s.h), `p:${id}`); if (i % 2 === 1) kb.row();
|
||||
});
|
||||
return ctx.editMessageText('📐 Welches Endmaß soll das Bild haben?', { reply_markup: kb });
|
||||
}
|
||||
if (extra === undefined) {
|
||||
const s = photoById(recipeId);
|
||||
if (!s) return ctx.editMessageText('Unbekanntes Maß.');
|
||||
const kb = new InlineKeyboard();
|
||||
PRINT_COUNTS.forEach((n, i) => {
|
||||
kb.text(n ? `${n}×` : 'Bogen füllen', `p:${recipeId}:${n}`); if (i % 2 === 1) kb.row();
|
||||
});
|
||||
return ctx.editMessageText(`📐 ${labelMm(s.w, s.h)} — wie oft auf den A4-Bogen?`, { reply_markup: kb });
|
||||
}
|
||||
await ctx.editMessageText('Alles klar — Bogen wird gebaut.');
|
||||
const b2 = await getBot();
|
||||
if (b2) await dispatchPrint(ctx.chat!.id, draft.id, recipeId, Number(extra) || 0, b2)
|
||||
.catch((e) => b2.api.sendMessage(ctx.chat!.id, `❌ Druckbogen fehlgeschlagen: ${e?.message || e}`));
|
||||
return;
|
||||
}
|
||||
if (kind === 'd') { // Standard-Rezept setzen
|
||||
await query(`UPDATE telegram_links SET default_recipe_id=$2 WHERE chat_id=$1`, [ctx.chat!.id, recipeId]);
|
||||
return ctx.editMessageText('Standard-Rezept gesetzt.');
|
||||
@@ -241,5 +479,25 @@ function register(b: Bot) {
|
||||
await ctx.editMessageText('Alles klar, los geht’s.');
|
||||
const b2 = await getBot(); if (b2) await dispatchDraft(ctx.chat!.id, draft.id, recipeId, b2);
|
||||
}
|
||||
if (kind === 'c') { // Kombinieren gewählt
|
||||
const draft = await one<{ id: string; caption: string | null }>(
|
||||
`SELECT id, caption FROM telegram_drafts WHERE chat_id=$1 AND status='awaiting_recipe'
|
||||
ORDER BY last_received_at DESC LIMIT 1`, [ctx.chat!.id]);
|
||||
if (!draft) return ctx.editMessageText('Kein offener Bild-Stapel gefunden — bitte Bilder neu senden.');
|
||||
if (draft.caption?.trim()) {
|
||||
await ctx.editMessageText(`Alles klar — kombiniere mit: „${draft.caption.trim()}".`);
|
||||
const b2 = await getBot(); if (b2) await dispatchCompose(ctx.chat!.id, draft.id, draft.caption.trim(), b2);
|
||||
} else {
|
||||
await query(`UPDATE telegram_drafts SET status='awaiting_compose_text' WHERE id=$1`, [draft.id]);
|
||||
await ctx.editMessageText('Beschreibe kurz, was entstehen soll (z. B. „das Bild, aber mit unserer Hündin Frieda"):');
|
||||
}
|
||||
}
|
||||
if (kind === 'g') { // Freitext → neues Bild
|
||||
const text = pendingGenerate.get(ctx.chat!.id);
|
||||
if (!text) return ctx.editMessageText('Text nicht mehr vorhanden — bitte erneut senden oder /neu nutzen.');
|
||||
pendingGenerate.delete(ctx.chat!.id);
|
||||
await ctx.editMessageText('Alles klar, erzeuge ein neues Bild …');
|
||||
const b2 = await getBot(); if (b2) await dispatchGenerate(ctx.chat!.id, text, b2);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
+17
-2
@@ -1,14 +1,29 @@
|
||||
import { defineMiddleware } from 'astro:middleware';
|
||||
import { ensureInit } from './lib/init';
|
||||
import { readSession } from './lib/auth';
|
||||
import { one } from './lib/db';
|
||||
|
||||
const PUBLIC_PATHS = [/^\/login/, /^\/api\/auth\/login/, /^\/api\/health/, /^\/g\//, /^\/api\/telegram\/webhook/];
|
||||
const PUBLIC_PATHS = [/^\/login/, /^\/api\/auth\/login/, /^\/api\/health/, /^\/g\//, /^\/api\/telegram\/webhook/, /^\/llms\.txt/];
|
||||
|
||||
/** API-Token (Authorization: Bearer …) → synthetischer Admin-Nutzer für /api/*. */
|
||||
async function tokenUser(header: string | null): Promise<any | null> {
|
||||
const m = /^Bearer\s+(.+)$/i.exec(header || '');
|
||||
if (!m) return null;
|
||||
const s = await one<{ api_token: string | null }>('SELECT api_token FROM settings WHERE id=1');
|
||||
if (!s?.api_token || s.api_token !== m[1].trim()) return null;
|
||||
const admin = await one<{ id: string }>(`SELECT id FROM users WHERE role='admin' ORDER BY created_at LIMIT 1`);
|
||||
return { uid: admin?.id || null, role: 'admin', name: 'API', username: 'api' };
|
||||
}
|
||||
|
||||
export const onRequest = defineMiddleware(async (ctx, next) => {
|
||||
// Health/Webhook dürfen laufen, auch wenn Init noch hakt — sonst blockiert nichts.
|
||||
try { await ensureInit(); } catch (e) { if (ctx.url.pathname !== '/api/health') throw e; }
|
||||
|
||||
const user = readSession(ctx.request.headers.get('cookie'));
|
||||
let user = readSession(ctx.request.headers.get('cookie'));
|
||||
// Programmatischer Zugriff per API-Token (nur für /api/*, außer Admin-Bereich).
|
||||
if (!user && ctx.url.pathname.startsWith('/api/') && !ctx.url.pathname.startsWith('/api/admin')) {
|
||||
user = await tokenUser(ctx.request.headers.get('authorization'));
|
||||
}
|
||||
ctx.locals.user = user;
|
||||
|
||||
const path = ctx.url.pathname;
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
---
|
||||
import Base from '../layouts/Base.astro';
|
||||
---
|
||||
<Base title="Anleitung · Klarbild">
|
||||
<div class="howto">
|
||||
<div class="hkopf">
|
||||
<h1>So funktioniert Klarbild</h1>
|
||||
<div class="hkopf-akt">
|
||||
<a class="druck" href="/changelog">Changelog</a>
|
||||
<button class="druck" onclick="window.print()">Als PDF / drucken</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="lead">Aus Screenshots und Fundstücken werden druckfertige Bilder — bereinigt, freigestellt,
|
||||
im richtigen Format, optional direkt bei Picdrop. Alles geht über die Website und über Telegram.</p>
|
||||
|
||||
<section>
|
||||
<h2>1 · Bild bearbeiten</h2>
|
||||
<ol>
|
||||
<li>Im <b>Studio</b> oben <b>Bearbeiten</b> wählen.</li>
|
||||
<li>Screenshots hochladen — ziehen, mit ⌘/Strg + V einfügen oder tippen (bis zu 100 auf einmal).</li>
|
||||
<li>Wählen, was passieren soll: <b>Bereinigen</b> (Rahmen, Wand, Shop-Oberfläche weg),
|
||||
<b>Freistellen</b> (Motiv frei, Hintergrund transparent), <b>Format</b> (z. B. 30 × 40 cm oder The Frame),
|
||||
<b>Kontur</b> (weißer Stickerrand).</li>
|
||||
<li>Beim Format „<b>Zuschneiden</b>" oder „<b>Erweitern</b>" wählen — Erweitern erzeugt mehr Szene rund ums
|
||||
Motiv (Outpainting), ideal für The Frame.</li>
|
||||
<li><b>Qualität</b> und Ziel (<b>Bibliothek</b>, <b>Picdrop</b> oder beides) wählen, dann <b>Loslegen</b>.</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>2 · Mehrere Bilder kombinieren</h2>
|
||||
<p>Modus <b>Kombinieren</b>: mindestens zwei Bilder hochladen und beschreiben, was entstehen soll —
|
||||
z. B. „das Poolbild, aber mit unserer Hündin Frieda am Beckenrand". Das erste Bild ist die Leitszene,
|
||||
weitere liefern Personen oder Motive. Es entsteht ein neues, zusammengesetztes Bild.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>3 · Neues Bild aus Text</h2>
|
||||
<p>Modus <b>Neu erzeugen</b>: ganz ohne Vorlage, nur aus einer Beschreibung. Je genauer der Text,
|
||||
desto besser das Ergebnis.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>4 · Bibliothek, Ordner, Alternativen & Weiterbearbeiten</h2>
|
||||
<p>Alle Ergebnisse landen in der <b>Bibliothek</b>. Über die Ordner-Leiste oben lässt sich nach Ordner
|
||||
filtern; jedem Ordner kann eine eigene Picdrop-Galerie zugeordnet werden (Pfeil am Ordner = eigene
|
||||
Galerie hinterlegt).</p>
|
||||
<ul>
|
||||
<li><b>+ Alternative</b>: erzeugt eine weitere Fassung desselben Bildes. Die Fassungen liegen gruppiert
|
||||
beieinander und lassen sich per Miniaturleiste umschalten (Badge „Fassung 2/3").</li>
|
||||
<li><b>Weiterbearbeiten</b>: übernimmt ein fertiges Bild als neue Vorlage ins Studio — dort erneut
|
||||
bearbeiten, ins nächste Format bringen oder im Modus Kombinieren mit weiteren Bildern mischen.</li>
|
||||
<li><b>Vorher/Nachher</b>: zeigt den Vergleich Vorlage ↔ Ergebnis (bei reinen Text-Bildern entfällt das).</li>
|
||||
<li><b>Ordner verwalten</b>: Ordner-Chip antippen, dann ✎ zum Umbenennen (inkl. Galerie) oder ✕ zum Löschen
|
||||
(die Bilder bleiben erhalten).</li>
|
||||
<li><b>Mehrere auf einmal</b>: Bilder markieren, dann als <b>ZIP laden</b> oder gesammelt <b>in einen Ordner</b>
|
||||
verschieben.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>5 · Druck — exakte Maße und Schnittmarken (ohne KI)</h2>
|
||||
<p class="lead">Der Reiter <b>Druck</b> ist der Fotolabor-Teil von Klarbild: kein Modell, keine Kosten,
|
||||
keine Wartezeit. Nur Zuschnitt, Skalierung und Geometrie.</p>
|
||||
<ol>
|
||||
<li>Bilder hochladen (ziehen, ⌘/Strg + V, oder <b>Aus Bibliothek</b> ein fertiges Ergebnis holen).</li>
|
||||
<li>Je Bild das <b>Endmaß</b> wählen — biometrisches Passbild 35 × 45 mm, 3 × 4 cm, 9 × 13, 10 × 15,
|
||||
12 × 15, 13 × 18 … oder <b>Eigenes Maß</b>: <code>12x15</code> (cm), <code>35x45mm</code>,
|
||||
<code>5</code> (= 5 × 5 cm) oder <code>4:3/15</code> (Verhältnis 4:3, längere Kante 15 cm).</li>
|
||||
<li>Auf das Vorschaubild tippen → <b>Zuschnitt</b>: schieben und zoomen. Das Seitenverhältnis bleibt
|
||||
fest am Zielformat, verzerrt wird nie.</li>
|
||||
<li><b>Anzahl</b> je Bild setzen — so entsteht der Passbild- oder Kita-Satz.</li>
|
||||
<li><b>Papierformat</b> wählen: DIN A6–A2 inklusive <b>A3+ (329 × 483 mm)</b>, Fotopapier-Zuschnitte
|
||||
9 × 13 bis 20 × 30, US Letter/Legal — oder ein eigenes Papiermaß.</li>
|
||||
<li><b>Schnitthilfen</b>: <b>Eckmarken</b> (feine Marken außerhalb des Endformats, wie in Photoshop
|
||||
und InDesign) oder <b>durchgehende Linien</b> über den ganzen Bogen fürs Schneidelineal.
|
||||
Die durchgehenden Linien laufen nie durch ein anderes Motiv.</li>
|
||||
<li><b>Druck-PDF erzeugen</b>. Die PDF-Seite hat exakt das Bogenmaß.</li>
|
||||
</ol>
|
||||
<p class="hinweis"><b>Wichtig beim Drucken:</b> im Druckdialog „Tatsächliche Größe" bzw. „100 %" wählen —
|
||||
<b>nicht</b> „an Seitengröße anpassen". Sonst stimmt das Maß nicht mehr. Wer randlos drucken kann,
|
||||
setzt den Rand auf 0; sonst 3–5 mm, sonst schneidet der Drucker die Marken weg.</p>
|
||||
<ul>
|
||||
<li><b>Beschnittzugabe</b> (0–10 mm): das Bild ragt über die Schnittkante hinaus, damit bei leicht
|
||||
schiefem Schnitt kein weißer Rand entsteht. Der Abstand zwischen zwei Bildern wächst automatisch mit.</li>
|
||||
<li><b>Einzeln herunterladen</b>: ein Bild exakt auf Maß als PNG/JPG mit dpi-Metadaten — ohne Bogen.</li>
|
||||
<li><b>Auflösungswarnung</b>: reicht die Quelle nicht für die gewählte dpi, steht es rot unter dem Bild.</li>
|
||||
<li><b>Vorlagen</b>: die ganze Bogen-Einstellung (Papier, Marken, Formate, Stückzahlen) speichern.
|
||||
Mitgeliefert sind <b>Kita-Satz (A4)</b> (1 × 13 × 18, 2 × 9 × 13, 8 × Passbild),
|
||||
<b>Schulsatz klein (A4)</b>, <b>Passbildbogen 35 × 45 (A4)</b> und
|
||||
<b>2 × 10 × 7,5 auf Fotopapier 10 × 15</b>. Lädst du eine mehrformatige Vorlage bei nur
|
||||
<i>einem</i> Bild, wird das Bild automatisch für jedes Format übernommen — ein Klick,
|
||||
fertiger Satz.</li>
|
||||
<li><b>Randprofile</b>: <b>Randlos</b> (0 mm), <b>Standard</b> (5 mm) oder <b>Sicher</b> (10 mm)
|
||||
per Knopf, der genaue Wert bleibt frei eintragbar.</li>
|
||||
<li><b>Wohin?</b>: der fertige Bogen kann zusätzlich an Picdrop, aufs NAS oder an ein
|
||||
Zusatzziel gelegt werden — wie die Bilder aus dem Studio.</li>
|
||||
<li><b>Automatische Anordnung</b>: gleich große Bilder ergeben ein sauberes Raster; bei gemischten
|
||||
Größen werden die kleinen automatisch in die Restflächen neben den großen gesetzt.</li>
|
||||
</ul>
|
||||
<p class="mini">Auch per Telegram: Bild schicken → „📐 Druckbogen" → Maß und Anzahl antippen → PDF zurück.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>6 · Alles per Telegram — ohne Befehle</h2>
|
||||
<p>Einmalig koppeln: im Admin einen Code erzeugen und im Chat mit dem Bot eingeben. Danach läuft alles über
|
||||
die Knöpfe unten im Chat — kein Tippen von Befehlen nötig.</p>
|
||||
<ul>
|
||||
<li><b>Bearbeiten:</b> Bild(er) schicken (am besten als <i>Datei</i> für volle Qualität) → unten das
|
||||
Rezept antippen.</li>
|
||||
<li><b>Kombinieren:</b> zwei oder mehr Bilder schicken, ins Bild-Textfeld eine Beschreibung wie
|
||||
„mit unserer Hündin Frieda" — dann „🔀 Kombinieren" antippen. (Ohne Beschreibung fragt der Bot nach.)</li>
|
||||
<li><b>Neu erzeugen:</b> unten auf „✨ Neues Bild" tippen und beschreiben, was entstehen soll.</li>
|
||||
<li><b>Weiterbearbeiten:</b> ein fertiges Bild vom Bot einfach wieder zurückschicken.</li>
|
||||
<li><b>Druckbogen (ohne KI):</b> Bild schicken → „📐 Druckbogen" → Endmaß und Anzahl antippen.
|
||||
Zurück kommt ein A4-PDF mit Schnittmarken in 100-%-Größe.</li>
|
||||
<li>Über „❓ Hilfe" gibt es die Kurzanleitung, über „📁 Rezept wählen" das Standard-Rezept.</li>
|
||||
</ul>
|
||||
<p class="mini">Für Profis gibt es zusätzlich das „/"-Befehlsmenü (/neu, /rezepte, /status) — nötig ist es nicht.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>7 · Sicherung & sauber neu starten (Admin)</h2>
|
||||
<p>Im Admin-Bereich lässt sich eine <b>zweite Sicherung aufs NAS</b> einrichten (SFTP/FTPS, auch über
|
||||
Tailscale) — jedes fertige Bild wird dann zusätzlich gespiegelt und in der Bibliothek mit <b>„NAS ✓"</b>
|
||||
markiert. Mit <b>„Alle vorhandenen Bilder aufs NAS sichern"</b> lassen sich auch ältere Bilder nachsichern.</p>
|
||||
<p>Zum Aufräumen gibt es <b>„Bibliothek & Aufträge zurücksetzen"</b>: löscht alle Bilder, Aufträge und Ordner
|
||||
(Nutzer, Einstellungen, Modelle und Rezepte bleiben) — mit Sicherheitsabfrage. Am besten vorher einmal
|
||||
aufs NAS sichern.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Tipps</h2>
|
||||
<ul>
|
||||
<li>Für Druck immer 300 dpi und ein cm-Format wählen — die Pixelmaße stehen direkt dabei.</li>
|
||||
<li>In Telegram Bilder als <i>Datei</i> senden, nicht als Foto — sonst komprimiert Telegram.</li>
|
||||
<li>Sticker: Freistellen + Kontur zusammen wählen.</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.howto{max-width:760px;margin:0 auto;}
|
||||
.hkopf{display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap;}
|
||||
.howto h1{font-family:var(--font-display);font-size:1.7rem;letter-spacing:-.02em;margin:6px 0 4px;}
|
||||
.hkopf-akt{display:flex;gap:8px;align-items:center;}
|
||||
.druck{background:var(--card);border:1px solid var(--line);border-radius:999px;padding:8px 15px;cursor:pointer;font-family:inherit;font-size:.85rem;color:var(--ink);text-decoration:none;}
|
||||
.lead{color:var(--soft);line-height:1.6;margin:4px 0 20px;}
|
||||
.howto section{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);padding:16px 18px;margin-bottom:14px;}
|
||||
.howto h2{font-size:1.05rem;margin:0 0 10px;}
|
||||
.howto ol,.howto ul{margin:0;padding-left:20px;line-height:1.65;}
|
||||
.howto li{margin-bottom:6px;}
|
||||
.howto code{font-family:var(--font-mono);font-size:.85em;background:var(--paper);border:1px solid var(--line);border-radius:3px;padding:1px 5px;}
|
||||
.howto .mini{font-size:.82rem;color:var(--soft);margin:8px 0 0;}
|
||||
.howto .hinweis{background:var(--accent-bg);border-left:3px solid var(--accent);padding:10px 13px;margin:12px 0;font-size:.92rem;line-height:1.55;}
|
||||
.howto code{font-family:var(--font-mono);font-size:.85em;background:var(--paper);border:1px solid var(--line);border-radius:3px;padding:1px 5px;}
|
||||
@media print{.topbar,.tabbar,.druck{display:none !important;}.howto section{break-inside:avoid;}}
|
||||
</style>
|
||||
</Base>
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { one, query } from '../../../lib/db';
|
||||
import { encrypt } from '../../../lib/crypto';
|
||||
import { loadTargetConfig, testConnection } from '../../../lib/picdrop';
|
||||
|
||||
export const prerender = false;
|
||||
const json = (b: unknown, s = 200) =>
|
||||
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
|
||||
|
||||
export const GET: APIRoute = async () => {
|
||||
const rows = await query<any>(`SELECT id, name, protocol, host, port, username, base_path, is_backup, metadata_sidecar,
|
||||
(password_enc IS NOT NULL) AS password_set FROM delivery_targets ORDER BY name`);
|
||||
return json({ targets: rows });
|
||||
};
|
||||
|
||||
// POST: anlegen | { action:'test', id } | { action:'backup_all' }
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
const b = await request.json();
|
||||
if (b.action === 'test') {
|
||||
const cfg = await loadTargetConfig(b.id);
|
||||
if (!cfg) return json({ ok: false, message: 'Ziel unvollständig konfiguriert.' });
|
||||
return json(await testConnection(cfg));
|
||||
}
|
||||
if (b.action === 'backup_all') {
|
||||
const { backupAll } = await import('../../../lib/backup');
|
||||
return json(await backupAll());
|
||||
}
|
||||
if (!b.name?.trim()) return json({ error: 'Name fehlt.' }, 400);
|
||||
const row = await one<any>(
|
||||
`INSERT INTO delivery_targets (name, protocol, host, port, username, password_enc, base_path, is_backup, metadata_sidecar)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) RETURNING id`,
|
||||
[b.name.trim(), b.protocol || 'sftp', b.host || null, b.port || null, b.username || null,
|
||||
b.password ? encrypt(String(b.password)) : null, b.base_path || null, !!b.is_backup, !!b.metadata_sidecar]);
|
||||
return json({ id: row!.id });
|
||||
};
|
||||
|
||||
// PATCH: bearbeiten
|
||||
export const PATCH: APIRoute = async ({ request }) => {
|
||||
const b = await request.json();
|
||||
if (!b.id) return json({ error: 'Keine ID.' }, 400);
|
||||
const sets: string[] = []; const args: any[] = [];
|
||||
const set = (c: string, v: any) => { args.push(v); sets.push(`${c}=$${args.length}`); };
|
||||
for (const c of ['name', 'protocol', 'host', 'port', 'username', 'base_path']) {
|
||||
if (c in b) set(c, b[c] === '' ? null : b[c]);
|
||||
}
|
||||
if ('is_backup' in b) set('is_backup', !!b.is_backup);
|
||||
if ('metadata_sidecar' in b) set('metadata_sidecar', !!b.metadata_sidecar);
|
||||
if (b.password) set('password_enc', encrypt(String(b.password)));
|
||||
if (!sets.length) return json({ ok: true });
|
||||
args.push(b.id);
|
||||
await query(`UPDATE delivery_targets SET ${sets.join(',')} WHERE id=$${args.length}`, args);
|
||||
return json({ ok: true });
|
||||
};
|
||||
|
||||
export const DELETE: APIRoute = async ({ request, url }) => {
|
||||
let id = url.searchParams.get('id');
|
||||
if (!id) { try { id = (await request.json())?.id; } catch { /* egal */ } }
|
||||
if (!id) return json({ error: 'Keine ID.' }, 400);
|
||||
await query('DELETE FROM delivery_targets WHERE id=$1', [id]);
|
||||
return json({ ok: true });
|
||||
};
|
||||
@@ -31,6 +31,10 @@ export const POST: APIRoute = async ({ request }) => {
|
||||
await query('UPDATE models SET is_default=false'); await query('UPDATE models SET is_default=true WHERE id=$1', [b.id]);
|
||||
return json({ ok: true });
|
||||
}
|
||||
if (b.action === 'nsfw' && b.id) {
|
||||
await query('UPDATE models SET nsfw = NOT nsfw WHERE id=$1', [b.id]);
|
||||
return json({ ok: true });
|
||||
}
|
||||
if (!b.model_id || !b.label) return json({ error: 'model_id + label nötig.' }, 400);
|
||||
const existing = await one<{ id: string }>('SELECT id FROM models WHERE model_id=$1', [b.model_id]);
|
||||
if (existing) {
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import net from 'node:net';
|
||||
import dns from 'node:dns/promises';
|
||||
import { one } from '../../../lib/db';
|
||||
|
||||
export const prerender = false;
|
||||
const json = (b: unknown, s = 200) =>
|
||||
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
|
||||
|
||||
/** TCP-Verbindungstest aus dem Container heraus (mit Timeout). */
|
||||
function tcpProbe(host: string, port: number, timeoutMs = 6000): Promise<{ ok: boolean; ms: number; error?: string }> {
|
||||
return new Promise((resolve) => {
|
||||
const start = Date.now();
|
||||
const sock = new net.Socket();
|
||||
let done = false;
|
||||
const finish = (ok: boolean, error?: string) => {
|
||||
if (done) return; done = true;
|
||||
try { sock.destroy(); } catch { /* egal */ }
|
||||
resolve({ ok, ms: Date.now() - start, error });
|
||||
};
|
||||
sock.setTimeout(timeoutMs);
|
||||
sock.once('connect', () => finish(true));
|
||||
sock.once('timeout', () => finish(false, 'Timeout — keine Antwort (Firewall/Route?)'));
|
||||
sock.once('error', (e: any) => finish(false, e?.code || e?.message || 'Verbindungsfehler'));
|
||||
sock.connect(port, host);
|
||||
});
|
||||
}
|
||||
|
||||
/** Prüft, ob ein Host:Port aus dem Container erreichbar ist. Body {host,port} oder
|
||||
* ohne Angabe der konfigurierte NAS-Host. Testet zusätzlich DNS-Auflösung. */
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
const b = await request.json().catch(() => ({} as any));
|
||||
let host = (b.host || '').trim();
|
||||
let port = Number(b.port) || 0;
|
||||
if (!host) {
|
||||
const s = await one<{ nas_host: string | null; nas_port: number | null; nas_protocol: string | null }>(
|
||||
'SELECT nas_host, nas_port, nas_protocol FROM settings WHERE id=1');
|
||||
host = (s?.nas_host || '').trim();
|
||||
port = s?.nas_port || (s?.nas_protocol === 'ftps' ? 21 : 22);
|
||||
}
|
||||
if (!host) return json({ error: 'Kein Host angegeben.' }, 400);
|
||||
if (!port) port = 22;
|
||||
|
||||
const out: any = { host, port };
|
||||
// DNS: nur wenn kein reines IP.
|
||||
const isIp = net.isIP(host) !== 0;
|
||||
if (!isIp) {
|
||||
try { const a = await dns.lookup(host); out.resolve = { ok: true, address: a.address }; }
|
||||
catch (e: any) { out.resolve = { ok: false, error: e?.code || 'DNS fehlgeschlagen' }; }
|
||||
} else {
|
||||
out.resolve = { ok: true, address: host, note: 'IP-Adresse' };
|
||||
}
|
||||
out.tcp = await tcpProbe(host, port);
|
||||
out.reachable = out.tcp.ok;
|
||||
return json(out);
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import posixpath from 'node:path/posix';
|
||||
import { loadConfig, cleanupTmp } from '../../../lib/picdrop';
|
||||
|
||||
export const prerender = false;
|
||||
const json = (b: unknown, s = 200) =>
|
||||
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
|
||||
|
||||
/** Diagnose: listet einen Picdrop-Ordner (Standard: Basisordner). Body { path? }
|
||||
* Aufräumen: Body { action:'cleanup_tmp', gallery } entfernt liegengebliebene .tmp-Dateien. */
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
const b = await request.json().catch(() => ({} as any));
|
||||
const cfg = await loadConfig();
|
||||
if (!cfg) return json({ error: 'Picdrop nicht konfiguriert.' }, 400);
|
||||
if (b.action === 'cleanup_tmp') {
|
||||
try {
|
||||
const removed = await cleanupTmp(cfg, b.gallery || '');
|
||||
return json({ cleaned: removed });
|
||||
} catch (e: any) { return json({ error: e?.message || 'Aufräumen fehlgeschlagen.' }, 500); }
|
||||
}
|
||||
const path = b.path || cfg.basePath || '/';
|
||||
try {
|
||||
if (cfg.protocol === 'sftp') {
|
||||
const SftpClient = (await import('ssh2-sftp-client')).default;
|
||||
const c = new SftpClient();
|
||||
await c.connect({ host: cfg.host, port: cfg.port, username: cfg.user, password: cfg.password, readyTimeout: 15000 });
|
||||
try {
|
||||
const list = await c.list(path);
|
||||
return json({ path, entries: list.map((e: any) => ({ name: e.name, type: e.type, size: e.size })) });
|
||||
} finally { await c.end(); }
|
||||
} else {
|
||||
const { Client } = await import('basic-ftp');
|
||||
const c = new Client(15000);
|
||||
await c.access({ host: cfg.host, port: cfg.port, user: cfg.user, password: cfg.password, secure: true });
|
||||
try {
|
||||
const list = await c.list(path);
|
||||
return json({ path, entries: list.map((e: any) => ({ name: e.name, type: e.type, size: e.size })) });
|
||||
} finally { c.close(); }
|
||||
}
|
||||
} catch (e: any) {
|
||||
return json({ path, error: e?.message || 'Listing fehlgeschlagen.' });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { query } from '../../../lib/db';
|
||||
import { deleteObject } from '../../../lib/storage';
|
||||
|
||||
export const prerender = false;
|
||||
const json = (b: unknown, s = 200) =>
|
||||
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
|
||||
|
||||
/** Setzt Inhalte zurück: löscht alle Bilder/Aufträge/Ordner + zugehörige Dateien.
|
||||
* Nutzer, Einstellungen, Modelle und Rezepte bleiben erhalten.
|
||||
* Erfordert { confirm: 'RESET' }. */
|
||||
export const POST: APIRoute = async ({ request, locals }) => {
|
||||
if (locals.user?.role !== 'admin') return new Response('Forbidden', { status: 403 });
|
||||
const b = await request.json().catch(() => ({} as any));
|
||||
if (b.confirm !== 'RESET') return json({ error: 'Bestätigung fehlt (confirm=RESET).' }, 400);
|
||||
|
||||
// Zuerst alle Objekte aus dem Speicher entfernen.
|
||||
const items = await query<any>('SELECT source_path, source_paths, result_path, thumb_path FROM items');
|
||||
let objects = 0;
|
||||
for (const it of items) {
|
||||
const keys = [it.source_path, it.result_path, it.thumb_path,
|
||||
...((it.source_paths as string[]) || [])].filter(Boolean);
|
||||
for (const k of keys) { await deleteObject(k).catch(() => {}); objects++; }
|
||||
}
|
||||
|
||||
// Inhalts-Tabellen leeren (Konfiguration bleibt).
|
||||
await query('DELETE FROM print_job_items');
|
||||
await query('DELETE FROM print_jobs');
|
||||
await query('DELETE FROM share_links');
|
||||
await query('DELETE FROM telegram_drafts');
|
||||
await query('DELETE FROM items');
|
||||
await query('DELETE FROM jobs');
|
||||
await query('DELETE FROM folders');
|
||||
|
||||
return json({ ok: true, deleted_items: items.length, deleted_objects: objects });
|
||||
};
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { one, query } from '../../../lib/db';
|
||||
import { encrypt, maskSecret, decrypt } from '../../../lib/crypto';
|
||||
|
||||
export const prerender = false;
|
||||
const randomToken = () => 'klb_' + randomBytes(24).toString('hex');
|
||||
const json = (b: unknown, s = 200) =>
|
||||
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
|
||||
|
||||
@@ -19,6 +21,17 @@ export const GET: APIRoute = async () => {
|
||||
picdrop_password_set: !!s.picdrop_password_enc,
|
||||
default_dpi: s.default_dpi, default_crop_mode: s.default_crop_mode, concurrency: s.concurrency,
|
||||
cricut_sheet_cm: s.cricut_sheet_cm, monthly_budget: s.monthly_budget, n8n_webhook_url: s.n8n_webhook_url,
|
||||
// Speicherverwaltung
|
||||
keep_sources: s.keep_sources, make_thumbnails: s.make_thumbnails, retention_days: s.retention_days,
|
||||
metadata_sidecar: s.metadata_sidecar, api_token: s.api_token,
|
||||
picdrop_metadata_sidecar: s.picdrop_metadata_sidecar, nas_metadata_sidecar: s.nas_metadata_sidecar,
|
||||
output_ext: s.output_ext,
|
||||
// Rechte / Datenschutz / NSFW
|
||||
library_visibility: s.library_visibility, anonymous_generations: s.anonymous_generations,
|
||||
private_allowed: s.private_allowed, allow_nsfw: s.allow_nsfw,
|
||||
// NAS-Sicherung
|
||||
nas_enabled: s.nas_enabled, nas_host: s.nas_host, nas_protocol: s.nas_protocol, nas_port: s.nas_port,
|
||||
nas_user: s.nas_user, nas_base_path: s.nas_base_path, nas_password_set: !!s.nas_password_enc,
|
||||
} });
|
||||
};
|
||||
|
||||
@@ -29,9 +42,21 @@ export const PATCH: APIRoute = async ({ request }) => {
|
||||
|
||||
if (b.openrouter_key) set('openrouter_key_enc', encrypt(String(b.openrouter_key)));
|
||||
if (b.picdrop_password) set('picdrop_password_enc', encrypt(String(b.picdrop_password)));
|
||||
if (b.nas_password) set('nas_password_enc', encrypt(String(b.nas_password)));
|
||||
if (b.generate_api_token) set('api_token', randomToken());
|
||||
const boolCols = ['keep_sources', 'make_thumbnails', 'nas_enabled', 'metadata_sidecar',
|
||||
'picdrop_metadata_sidecar', 'nas_metadata_sidecar', 'anonymous_generations', 'private_allowed', 'allow_nsfw'];
|
||||
for (const col of ['picdrop_host', 'picdrop_protocol', 'picdrop_port', 'picdrop_user', 'picdrop_base_path',
|
||||
'picdrop_default_gallery', 'default_dpi', 'default_crop_mode', 'concurrency', 'cricut_sheet_cm', 'monthly_budget', 'n8n_webhook_url']) {
|
||||
if (col in b) set(col, b[col] === '' ? null : b[col]);
|
||||
'picdrop_default_gallery', 'default_dpi', 'default_crop_mode', 'concurrency', 'cricut_sheet_cm', 'monthly_budget', 'n8n_webhook_url',
|
||||
'keep_sources', 'make_thumbnails', 'retention_days', 'metadata_sidecar', 'api_token',
|
||||
'picdrop_metadata_sidecar', 'nas_metadata_sidecar', 'output_ext',
|
||||
'library_visibility', 'anonymous_generations', 'private_allowed', 'allow_nsfw',
|
||||
'nas_enabled', 'nas_host', 'nas_protocol', 'nas_port', 'nas_user', 'nas_base_path']) {
|
||||
if (col in b) {
|
||||
const raw = b[col];
|
||||
const val = boolCols.includes(col) ? !!raw : (raw === '' ? null : raw);
|
||||
set(col, val);
|
||||
}
|
||||
}
|
||||
if (!sets.length) return json({ ok: true });
|
||||
await query(`UPDATE settings SET ${sets.join(',')} WHERE id=1`, args);
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { storageStats, runRetention, purgeSources, mirrorAllToNas, rebuildThumbs } from '../../../lib/maintenance';
|
||||
import { one } from '../../../lib/db';
|
||||
|
||||
export const prerender = false;
|
||||
const json = (b: unknown, s = 200) =>
|
||||
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
|
||||
|
||||
export const GET: APIRoute = async () => json({ stats: await storageStats() });
|
||||
|
||||
// POST { action: 'retention' | 'purge_sources' }
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
const b = await request.json().catch(() => ({}));
|
||||
if (b.action === 'purge_sources') return json(await purgeSources());
|
||||
if (b.action === 'mirror_all') return json(await mirrorAllToNas());
|
||||
if (b.action === 'rebuild_thumbs') return json(await rebuildThumbs());
|
||||
if (b.action === 'retention') {
|
||||
const s = await one<{ retention_days: number | null }>('SELECT retention_days FROM settings WHERE id=1');
|
||||
if (!s?.retention_days) return json({ error: 'Keine Aufbewahrungsfrist gesetzt.' }, 400);
|
||||
return json(await runRetention(s.retention_days));
|
||||
}
|
||||
return json({ error: 'Unbekannte Aktion.' }, 400);
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { loadNasConfig, testNas } from '../../../lib/nas';
|
||||
|
||||
export const prerender = false;
|
||||
const json = (b: unknown, s = 200) =>
|
||||
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
|
||||
|
||||
export const POST: APIRoute = async () => {
|
||||
const cfg = await loadNasConfig();
|
||||
if (!cfg) return json({ ok: false, message: 'NAS nicht vollständig konfiguriert (oder deaktiviert).' });
|
||||
return json(await testNas(cfg));
|
||||
};
|
||||
@@ -7,17 +7,25 @@ const json = (b: unknown, s = 200) =>
|
||||
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
|
||||
|
||||
export const GET: APIRoute = async () => {
|
||||
const users = await query('SELECT id, username, role, display_name, created_at FROM users ORDER BY username');
|
||||
const users = await query('SELECT id, username, role, display_name, private_forced, created_at FROM users ORDER BY username');
|
||||
return json({ users });
|
||||
};
|
||||
|
||||
// POST: neuen Nutzer anlegen ODER Passwort setzen (bei vorhandenem username/id).
|
||||
// POST: neuen Nutzer anlegen ODER Passwort/Attribute setzen (bei vorhandenem username/id).
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
const b = await request.json();
|
||||
if (b.id && b.password) {
|
||||
await query('UPDATE users SET password_hash=$2 WHERE id=$1', [b.id, await hashPassword(b.password)]);
|
||||
return json({ ok: true });
|
||||
}
|
||||
if (b.id && 'private_forced' in b) {
|
||||
await query('UPDATE users SET private_forced=$2 WHERE id=$1', [b.id, !!b.private_forced]);
|
||||
return json({ ok: true });
|
||||
}
|
||||
if (b.id && b.role) {
|
||||
await query(`UPDATE users SET role=$2 WHERE id=$1`, [b.id, b.role === 'admin' ? 'admin' : 'user']);
|
||||
return json({ ok: true });
|
||||
}
|
||||
if (!b.username || !b.password) return json({ error: 'username + password nötig.' }, 400);
|
||||
const exists = await one('SELECT id FROM users WHERE username=$1', [b.username.trim()]);
|
||||
if (exists) return json({ error: 'Benutzername vergeben.' }, 400);
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { query } from '../../lib/db';
|
||||
|
||||
export const prerender = false;
|
||||
const json = (b: unknown, s = 200) =>
|
||||
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
|
||||
|
||||
// Namen der Zusatzziele fürs Studio-Dropdown (kein Zugang).
|
||||
export const GET: APIRoute = async ({ locals }) => {
|
||||
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
||||
const rows = await query('SELECT id, name FROM delivery_targets ORDER BY name');
|
||||
return json({ targets: rows });
|
||||
};
|
||||
@@ -20,3 +20,27 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
||||
[b.name.trim(), b.picdrop_gallery || null]);
|
||||
return json({ folder: row });
|
||||
};
|
||||
|
||||
// Ordner umbenennen / Galerie ändern. Body { id, name?, picdrop_gallery? }
|
||||
export const PATCH: APIRoute = async ({ request, locals }) => {
|
||||
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
||||
const b = await request.json();
|
||||
if (!b.id) return json({ error: 'Keine ID.' }, 400);
|
||||
const sets: string[] = []; const args: any[] = [];
|
||||
if (typeof b.name === 'string' && b.name.trim()) { args.push(b.name.trim()); sets.push(`name=$${args.length}`); }
|
||||
if ('picdrop_gallery' in b) { args.push(b.picdrop_gallery?.trim() || null); sets.push(`picdrop_gallery=$${args.length}`); }
|
||||
if (!sets.length) return json({ error: 'Nichts zu ändern.' }, 400);
|
||||
args.push(b.id);
|
||||
const row = await one(`UPDATE folders SET ${sets.join(',')} WHERE id=$${args.length} RETURNING *`, args);
|
||||
return json({ folder: row });
|
||||
};
|
||||
|
||||
// Ordner löschen (Bilder bleiben, verlieren nur die Zuordnung). ?id=…
|
||||
export const DELETE: APIRoute = async ({ request, url, locals }) => {
|
||||
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
||||
let id = url.searchParams.get('id');
|
||||
if (!id) { try { id = (await request.json())?.id; } catch { /* egal */ } }
|
||||
if (!id) return json({ error: 'Keine ID.' }, 400);
|
||||
await query('DELETE FROM folders WHERE id=$1', [id]);
|
||||
return json({ ok: true });
|
||||
};
|
||||
|
||||
@@ -12,9 +12,13 @@ export const PATCH: APIRoute = async ({ params, request, locals }) => {
|
||||
const sets: string[] = []; const args: any[] = [];
|
||||
if (typeof b.filename === 'string') { args.push(b.filename.trim()); sets.push(`filename=$${args.length}`); }
|
||||
if ('folder_id' in b) { args.push(b.folder_id || null); sets.push(`folder_id=$${args.length}`); }
|
||||
if ('color_tag' in b) {
|
||||
const t = ['red', 'orange', 'green', 'final'].includes(b.color_tag) ? b.color_tag : null;
|
||||
args.push(t); sets.push(`color_tag=$${args.length}`);
|
||||
}
|
||||
if (!sets.length) return json({ error: 'Nichts zu ändern.' }, 400);
|
||||
args.push(params.id);
|
||||
const row = await one(`UPDATE items SET ${sets.join(',')} WHERE id=$${args.length} RETURNING id, filename, folder_id`, args);
|
||||
const row = await one(`UPDATE items SET ${sets.join(',')} WHERE id=$${args.length} RETURNING id, filename, folder_id, color_tag`, args);
|
||||
return json({ item: row });
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { one, query } from '../../../../lib/db';
|
||||
import { enqueue } from '../../../../lib/queue';
|
||||
|
||||
export const prerender = false;
|
||||
const json = (b: unknown, s = 200) =>
|
||||
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
|
||||
|
||||
/** Erzeugt eine weitere Fassung (Alternative) zum selben Ursprung, gleiches Rezept. */
|
||||
export const POST: APIRoute = async ({ params, locals }) => {
|
||||
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
||||
const item = await one<any>(
|
||||
`SELECT id, job_id, source_path, source_paths, filename, variant_of FROM items WHERE id=$1`, [params.id]);
|
||||
if (!item) return json({ error: 'Nicht gefunden.' }, 404);
|
||||
// Ohne Quelle & ohne Modellaufruf gibt es nichts zu variieren.
|
||||
const root = item.variant_of || item.id;
|
||||
|
||||
const it = await one<{ id: string }>(
|
||||
`INSERT INTO items (job_id, position, status, source_path, source_paths, filename, source_quality, variant_of)
|
||||
VALUES ($1, (SELECT COALESCE(max(position),0)+1 FROM items WHERE job_id=$1), 'queued', $2, $3, $4, 'original', $5)
|
||||
RETURNING id`,
|
||||
[item.job_id, item.source_path, item.source_paths ? JSON.stringify(item.source_paths) : null,
|
||||
item.filename, root]);
|
||||
// Auftrag zählt eine Position mehr und läuft erneut an.
|
||||
await query(`UPDATE jobs SET total=total+1, status='queued', finished_at=NULL
|
||||
WHERE id=$1 AND status IN ('done','paused')`, [item.job_id]);
|
||||
await enqueue({ itemId: it!.id, jobId: item.job_id });
|
||||
return json({ ok: true, itemId: it!.id });
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import sharp from 'sharp';
|
||||
import { one } from '../../../../lib/db';
|
||||
import { getObject } from '../../../../lib/storage';
|
||||
|
||||
@@ -7,16 +8,45 @@ export const prerender = false;
|
||||
// Streamt das Ergebnis- (oder Quell-)Bild aus dem Objektspeicher.
|
||||
export const GET: APIRoute = async ({ params, url, locals }) => {
|
||||
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
||||
const which = url.searchParams.get('src') === '1' ? 'source_path' : 'result_path';
|
||||
const item = await one<any>(`SELECT ${which} AS path, filename, has_alpha FROM items WHERE id=$1`, [params.id]);
|
||||
if (!item?.path) return new Response('Nicht gefunden', { status: 404 });
|
||||
const q = url.searchParams;
|
||||
const wantThumb = q.get('thumb') === '1';
|
||||
const wantSrc = q.get('src') === '1';
|
||||
const item = await one<any>(
|
||||
`SELECT i.source_path, i.source_paths, i.result_path, i.thumb_path, i.filename, i.has_alpha,
|
||||
j.created_by, j.private FROM items i JOIN jobs j ON j.id=i.job_id WHERE i.id=$1`, [params.id]);
|
||||
if (item) {
|
||||
const isAdmin = locals.user.role === 'admin';
|
||||
const own = item.created_by === locals.user.uid;
|
||||
if (!isAdmin && !own) {
|
||||
const s = await one<{ library_visibility: string }>('SELECT library_visibility FROM settings WHERE id=1');
|
||||
if (item.private || s?.library_visibility !== 'shared') return new Response('Forbidden', { status: 403 });
|
||||
}
|
||||
}
|
||||
// Quelle: Einzelquelle, sonst erste Kombinier-Quelle (für Vorher/Nachher).
|
||||
const firstSource = item?.source_path || (Array.isArray(item?.source_paths) ? item.source_paths[0] : null);
|
||||
const path = wantSrc ? firstSource
|
||||
: wantThumb ? (item?.thumb_path || item?.result_path)
|
||||
: item?.result_path;
|
||||
if (!path) return new Response('Nicht gefunden', { status: 404 });
|
||||
try {
|
||||
const buf = await getObject(item.path);
|
||||
const download = url.searchParams.get('download') === '1';
|
||||
let buf = await getObject(path);
|
||||
const download = q.get('download') === '1';
|
||||
// Inhaltstyp aus den Magic Bytes ableiten — Ergebnisse können PNG oder JPG sein.
|
||||
const sniff = (b: Buffer): string =>
|
||||
b.length > 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff ? 'image/jpeg'
|
||||
: b.length > 12 && b.toString('ascii', 8, 12) === 'WEBP' ? 'image/webp'
|
||||
: 'image/png';
|
||||
let contentType = path.endsWith('.webp') ? 'image/webp' : sniff(buf);
|
||||
// Schnelle Vollbild-Vorschau: nicht-transparente Ergebnisse als JPEG (deutlich kleiner,
|
||||
// schneller auf Mobil) — reicht fürs „In Fotos sichern". Der PNG-Download bleibt unverändert.
|
||||
if (q.get('preview') === '1' && !download && !item?.has_alpha) {
|
||||
try { buf = await sharp(buf, { failOn: 'none' }).jpeg({ quality: 90, mozjpeg: true }).toBuffer(); contentType = 'image/jpeg'; }
|
||||
catch { /* Fallback: Original */ }
|
||||
}
|
||||
return new Response(buf, {
|
||||
headers: {
|
||||
'Content-Type': item.has_alpha ? 'image/png' : 'image/png',
|
||||
'Cache-Control': 'private, max-age=300',
|
||||
'Content-Type': contentType,
|
||||
'Cache-Control': 'private, max-age=600',
|
||||
...(download ? { 'Content-Disposition': `attachment; filename="${item.filename || 'klarbild.png'}"` } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { one } from '../../../../lib/db';
|
||||
import { getObject, putObject, sourceKey } from '../../../../lib/storage';
|
||||
|
||||
export const prerender = false;
|
||||
const json = (b: unknown, s = 200) =>
|
||||
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
|
||||
|
||||
/** Kopiert das Ergebnis eines Items in eine neue Quelle, damit es im Studio
|
||||
* als Vorlage weiterbearbeitet werden kann. */
|
||||
export const POST: APIRoute = async ({ params, locals }) => {
|
||||
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
||||
const it = await one<{ result_path: string | null; filename: string | null }>(
|
||||
'SELECT result_path, filename FROM items WHERE id=$1', [params.id]);
|
||||
if (!it?.result_path) return json({ error: 'Kein Ergebnis vorhanden.' }, 404);
|
||||
try {
|
||||
const buf = await getObject(it.result_path);
|
||||
const key = sourceKey(randomUUID(), 'png');
|
||||
await putObject(key, buf, 'image/png');
|
||||
const name = (it.filename || 'bild').replace(/\.[^.]+$/, '') + '.png';
|
||||
return json({ source_path: key, filename: name });
|
||||
} catch (e: any) {
|
||||
return json({ error: e?.message || 'Konnte Ergebnis nicht laden.' }, 500);
|
||||
}
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { query } from '../../../lib/db';
|
||||
import { one, query } from '../../../lib/db';
|
||||
|
||||
export const prerender = false;
|
||||
const json = (b: unknown, s = 200) =>
|
||||
@@ -9,11 +9,17 @@ const json = (b: unknown, s = 200) =>
|
||||
export const GET: APIRoute = async ({ url, locals }) => {
|
||||
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
||||
const q = url.searchParams;
|
||||
const where: string[] = [`i.status='done'`];
|
||||
const s = await one<any>('SELECT library_visibility, anonymous_generations FROM settings WHERE id=1');
|
||||
const isAdmin = locals.user.role === 'admin';
|
||||
const shared = s?.library_visibility === 'shared';
|
||||
// Private Aufträge nie in der Bibliothek; Sichtbarkeit nach Einstellung.
|
||||
const where: string[] = [`i.status='done'`, `j.private=false`];
|
||||
const args: any[] = [];
|
||||
const add = (cond: string, val: any) => { args.push(val); where.push(cond.replace('?', `$${args.length}`)); };
|
||||
if (!isAdmin && !shared) add('j.created_by = ?', locals.user.uid);
|
||||
|
||||
if (q.get('folder')) add('i.folder_id = ?', q.get('folder'));
|
||||
if (q.get('tag')) add('i.color_tag = ?', q.get('tag'));
|
||||
if (q.get('delivery')) add('i.delivery_status = ?', q.get('delivery'));
|
||||
if (q.get('by')) add('j.created_by = ?', q.get('by'));
|
||||
if (q.get('format')) add('i.output_px = ?', q.get('format'));
|
||||
@@ -23,13 +29,14 @@ export const GET: APIRoute = async ({ url, locals }) => {
|
||||
}
|
||||
|
||||
const rows = await query(
|
||||
`SELECT i.id, i.filename, i.output_px, i.has_alpha, i.result_path, i.folder_id,
|
||||
i.delivery_status, i.created_at, i.model_used, u.display_name AS by_name,
|
||||
`SELECT i.id, i.filename, i.output_px, i.has_alpha, i.result_path, i.thumb_path, i.folder_id, i.color_tag,
|
||||
i.delivery_status, i.nas_status, i.prompt_used, i.created_at, i.model_used, i.variant_of, j.mode,
|
||||
${(s?.anonymous_generations && !isAdmin) ? 'NULL' : 'u.display_name'} AS by_name,
|
||||
j.recipe_snapshot->'tasks' AS tasks
|
||||
FROM items i
|
||||
JOIN jobs j ON j.id = i.job_id
|
||||
LEFT JOIN users u ON u.id = j.created_by
|
||||
WHERE ${where.join(' AND ')}
|
||||
ORDER BY i.created_at DESC LIMIT 300`, args);
|
||||
ORDER BY i.created_at DESC LIMIT 400`, args);
|
||||
return json({ items: rows });
|
||||
};
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import archiver from 'archiver';
|
||||
import { query } from '../../../lib/db';
|
||||
import { getObject } from '../../../lib/storage';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
/** Lädt mehrere Ergebnisse als ZIP. Body { itemIds: [...] } */
|
||||
export const POST: APIRoute = async ({ request, locals }) => {
|
||||
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
||||
const b = await request.json().catch(() => ({} as any));
|
||||
const ids: string[] = b.itemIds || [];
|
||||
if (!ids.length) return new Response('Keine Auswahl.', { status: 400 });
|
||||
|
||||
const rows = await query<{ id: string; result_path: string | null; filename: string | null }>(
|
||||
`SELECT id, result_path, filename FROM items WHERE id = ANY($1) AND result_path IS NOT NULL`, [ids]);
|
||||
if (!rows.length) return new Response('Keine Ergebnisse.', { status: 404 });
|
||||
|
||||
const archive = archiver('zip', { zlib: { level: 6 } });
|
||||
const seen: Record<string, number> = {};
|
||||
for (const r of rows) {
|
||||
try {
|
||||
const buf = await getObject(r.result_path!);
|
||||
let name = r.filename || `${r.id}.png`;
|
||||
if (!/\.[a-z0-9]+$/i.test(name)) name += '.png';
|
||||
// Namenskollisionen vermeiden
|
||||
if (seen[name] != null) { const n = ++seen[name]; name = name.replace(/(\.[^.]+)$/, `_${n}$1`); }
|
||||
else seen[name] = 0;
|
||||
archive.append(buf, { name });
|
||||
} catch { /* Datei überspringen */ }
|
||||
}
|
||||
const done = archive.finalize();
|
||||
|
||||
// Archiver-Stream (Node) in einen Web-ReadableStream überführen.
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
archive.on('data', (c: Buffer) => controller.enqueue(new Uint8Array(c)));
|
||||
archive.on('end', () => controller.close());
|
||||
archive.on('error', (e: any) => controller.error(e));
|
||||
done.catch(() => {});
|
||||
},
|
||||
});
|
||||
const stamp = new Date().toISOString().slice(0, 10);
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'application/zip',
|
||||
'Content-Disposition': `attachment; filename="klarbild_${stamp}.zip"`,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -7,8 +7,15 @@ const json = (b: unknown, s = 200) =>
|
||||
|
||||
export const GET: APIRoute = async ({ params, locals }) => {
|
||||
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
||||
const job = await one('SELECT * FROM jobs WHERE id=$1', [params.id]);
|
||||
const job = await one<any>('SELECT * FROM jobs WHERE id=$1', [params.id]);
|
||||
if (!job) return json({ error: 'Nicht gefunden.' }, 404);
|
||||
// Zugriff: eigener Auftrag, Admin, oder shared (nicht privat).
|
||||
const isAdmin = locals.user.role === 'admin';
|
||||
const own = job.created_by === locals.user.uid;
|
||||
if (!isAdmin && !own) {
|
||||
const s = await one<{ library_visibility: string }>('SELECT library_visibility FROM settings WHERE id=1');
|
||||
if (job.private || s?.library_visibility !== 'shared') return new Response('Forbidden', { status: 403 });
|
||||
}
|
||||
const items = await query(
|
||||
`SELECT id, position, status, filename, output_px, has_alpha, error_message,
|
||||
result_path, delivery_status, cost FROM items WHERE job_id=$1 ORDER BY position`,
|
||||
|
||||
@@ -34,6 +34,19 @@ export const POST: APIRoute = async ({ params, locals }) => {
|
||||
for (const it of items) await enqueue({ itemId: it.id, jobId: id });
|
||||
break;
|
||||
}
|
||||
case 'delete': {
|
||||
// Auftrag samt Positionen und deren Objekten entfernen (für alte/fehlgeschlagene Aufträge).
|
||||
const { deleteObject } = await import('../../../../lib/storage');
|
||||
const items = await query<any>(
|
||||
`SELECT source_path, source_paths, result_path, thumb_path FROM items WHERE job_id=$1`, [id]);
|
||||
for (const it of items) {
|
||||
const keys = [it.source_path, it.result_path, it.thumb_path, ...((it.source_paths as string[]) || [])].filter(Boolean);
|
||||
for (const k of keys) await deleteObject(k).catch(() => {});
|
||||
}
|
||||
await query(`DELETE FROM items WHERE job_id=$1`, [id]);
|
||||
await query(`DELETE FROM jobs WHERE id=$1`, [id]);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
return json({ error: 'Unbekannte Aktion.' }, 400);
|
||||
}
|
||||
|
||||
+49
-15
@@ -8,18 +8,32 @@ const json = (b: unknown, s = 200) =>
|
||||
|
||||
export const GET: APIRoute = async ({ locals }) => {
|
||||
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
||||
const s = await one<any>('SELECT library_visibility, anonymous_generations FROM settings WHERE id=1');
|
||||
const isAdmin = locals.user.role === 'admin';
|
||||
const shared = s?.library_visibility === 'shared';
|
||||
const where: string[] = ['j.private=false']; const args: any[] = [];
|
||||
if (!isAdmin && !shared) { args.push(locals.user.uid); where.push(`j.created_by=$${args.length}`); }
|
||||
const nameCol = (s?.anonymous_generations && !isAdmin) ? 'NULL' : 'u.display_name';
|
||||
const rows = await query(
|
||||
`SELECT j.*, u.display_name AS by_name FROM jobs j
|
||||
LEFT JOIN users u ON u.id=j.created_by ORDER BY j.created_at DESC LIMIT 50`);
|
||||
`SELECT j.*, ${nameCol} AS by_name FROM jobs j
|
||||
LEFT JOIN users u ON u.id=j.created_by
|
||||
WHERE ${where.join(' AND ')} ORDER BY j.created_at DESC LIMIT 50`, args);
|
||||
return json({ jobs: rows });
|
||||
};
|
||||
|
||||
// Body: { recipeId?, recipe?, sources:[{source_path, filename, source_quality?}], origin? }
|
||||
// Body: { recipeId?, recipe?, sources:[{source_path, filename, source_quality?}],
|
||||
// mode?: 'each'|'compose'|'generate', prompt_text?, origin? }
|
||||
export const POST: APIRoute = async ({ request, locals }) => {
|
||||
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
||||
const b = await request.json();
|
||||
const mode: 'each' | 'compose' | 'generate' = ['each', 'compose', 'generate'].includes(b.mode) ? b.mode : 'each';
|
||||
const sources: any[] = b.sources || [];
|
||||
if (!sources.length) return json({ error: 'Keine Bilder.' }, 400);
|
||||
const promptText: string = (b.prompt_text || '').trim();
|
||||
|
||||
if (mode === 'each' && !sources.length) return json({ error: 'Keine Bilder.' }, 400);
|
||||
if (mode === 'compose' && sources.length < 1) return json({ error: 'Mindestens 1 Bild nötig.' }, 400);
|
||||
if (mode === 'compose' && !promptText) return json({ error: 'Bitte beschreiben, was entstehen soll.' }, 400);
|
||||
if (mode === 'generate' && !promptText) return json({ error: 'Bitte einen Text eingeben.' }, 400);
|
||||
|
||||
let snapshot: any = b.recipe;
|
||||
if (b.recipeId) {
|
||||
@@ -27,11 +41,11 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
||||
if (!r) return json({ error: 'Rezept nicht gefunden.' }, 404);
|
||||
snapshot = r;
|
||||
}
|
||||
if (!snapshot) return json({ error: 'Kein Rezept.' }, 400);
|
||||
snapshot = snapshot || {};
|
||||
|
||||
// Rezept-Snapshot einfrieren
|
||||
const snap = {
|
||||
tasks: snapshot.tasks || [],
|
||||
tasks: snapshot.tasks || (mode === 'each' ? [] : []),
|
||||
output_format: snapshot.output_format,
|
||||
orientation: snapshot.orientation,
|
||||
crop_mode: snapshot.crop_mode || 'crop',
|
||||
@@ -39,21 +53,41 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
||||
contour_mm: snapshot.contour_mm ?? null,
|
||||
model_key: snapshot.model_key ?? null,
|
||||
custom_instruction: snapshot.custom_instruction ?? null,
|
||||
prompt_text: promptText || null,
|
||||
delivery: snapshot.delivery || 'library',
|
||||
picdrop_gallery: snapshot.picdrop_gallery ?? null,
|
||||
delivery_target_id: snapshot.delivery_target_id ?? null,
|
||||
output_ext: (snapshot.output_ext === 'jpg' || snapshot.output_ext === 'png') ? snapshot.output_ext : null,
|
||||
};
|
||||
|
||||
const job = await one<{ id: string }>(
|
||||
`INSERT INTO jobs (created_by, origin, recipe_snapshot, status, total)
|
||||
VALUES ($1,$2,$3,'queued',$4) RETURNING id`,
|
||||
[locals.user.uid, b.origin || 'web', JSON.stringify(snap), sources.length]);
|
||||
// Private Session: nur wenn global erlaubt oder für den Nutzer erzwungen.
|
||||
const acc = await one<any>(`SELECT s.private_allowed, u.private_forced
|
||||
FROM settings s, users u WHERE s.id=1 AND u.id=$1`, [locals.user.uid]);
|
||||
const isPrivate = !!(acc?.private_forced || (acc?.private_allowed && b.private));
|
||||
if (isPrivate) snap.delivery = 'library'; // kein externes Delivery bei privaten Aufträgen
|
||||
|
||||
for (let i = 0; i < sources.length; i++) {
|
||||
const s = sources[i];
|
||||
const total = (mode === 'each') ? sources.length : 1;
|
||||
const job = await one<{ id: string }>(
|
||||
`INSERT INTO jobs (created_by, origin, mode, recipe_snapshot, status, total, private)
|
||||
VALUES ($1,$2,$3,$4,'queued',$5,$6) RETURNING id`,
|
||||
[locals.user.uid, b.origin || 'web', mode, JSON.stringify(snap), total, isPrivate]);
|
||||
|
||||
if (mode === 'each') {
|
||||
for (let i = 0; i < sources.length; i++) {
|
||||
const s = sources[i];
|
||||
const item = await one<{ id: string }>(
|
||||
`INSERT INTO items (job_id, position, status, source_path, filename, source_quality)
|
||||
VALUES ($1,$2,'queued',$3,$4,$5) RETURNING id`,
|
||||
[job!.id, i, s.source_path, s.filename || null, s.source_quality || 'original']);
|
||||
await enqueue({ itemId: item!.id, jobId: job!.id });
|
||||
}
|
||||
} else {
|
||||
const srcPaths = sources.map((s) => s.source_path).filter(Boolean);
|
||||
const base = mode === 'generate' ? 'neu' : 'kombiniert';
|
||||
const item = await one<{ id: string }>(
|
||||
`INSERT INTO items (job_id, position, status, source_path, filename, source_quality)
|
||||
VALUES ($1,$2,'queued',$3,$4,$5) RETURNING id`,
|
||||
[job!.id, i, s.source_path, s.filename || null, s.source_quality || 'original']);
|
||||
`INSERT INTO items (job_id, position, status, source_paths, filename, source_quality)
|
||||
VALUES ($1,0,'queued',$2,$3,'original') RETURNING id`,
|
||||
[job!.id, srcPaths.length ? JSON.stringify(srcPaths) : null, base]);
|
||||
await enqueue({ itemId: item!.id, jobId: job!.id });
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { one } from '../../lib/db';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
export const GET: APIRoute = async ({ locals }) => {
|
||||
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
||||
const s = await one<{ private_allowed: boolean }>('SELECT private_allowed FROM settings WHERE id=1');
|
||||
const u = await one<{ private_forced: boolean }>('SELECT private_forced FROM users WHERE id=$1', [locals.user.uid]);
|
||||
return new Response(JSON.stringify({
|
||||
role: locals.user.role,
|
||||
private_allowed: !!s?.private_allowed,
|
||||
private_forced: !!u?.private_forced,
|
||||
}), { headers: { 'Content-Type': 'application/json' } });
|
||||
};
|
||||
@@ -1,13 +1,15 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { query } from '../../lib/db';
|
||||
import { one, query } from '../../lib/db';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
// Aktive Modelle mit freundlichen Namen (für Studio-Auswahl + Ergebnis-Anzeige).
|
||||
// NSFW-fähige Modelle nur, wenn allow_nsfw in den Einstellungen aktiv ist.
|
||||
export const GET: APIRoute = async ({ locals }) => {
|
||||
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
||||
const s = await one<{ allow_nsfw: boolean }>('SELECT allow_nsfw FROM settings WHERE id=1');
|
||||
const models = await query(
|
||||
`SELECT model_id, label, description, is_default, supports_alpha FROM models
|
||||
WHERE active ORDER BY sort, label`);
|
||||
`SELECT model_id, label, description, is_default, supports_alpha, nsfw FROM models
|
||||
WHERE active AND (nsfw=false OR $1=true) ORDER BY sort, label`, [!!s?.allow_nsfw]);
|
||||
return new Response(JSON.stringify({ models }), { headers: { 'Content-Type': 'application/json' } });
|
||||
};
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { query, one } from '../../../lib/db';
|
||||
|
||||
export const prerender = false;
|
||||
const json = (b: unknown, s = 200) =>
|
||||
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
|
||||
|
||||
/** Bogen-Vorlagen: Papier, Marken, Bildformate und Stückzahlen — ohne Bilder. */
|
||||
export const GET: APIRoute = async ({ locals }) => {
|
||||
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
||||
const rows = await query(
|
||||
`SELECT p.id, p.name, p.config, p.created_at, u.display_name AS by_name
|
||||
FROM print_presets p LEFT JOIN users u ON u.id = p.created_by
|
||||
ORDER BY p.name ASC`);
|
||||
return json({ presets: rows });
|
||||
};
|
||||
|
||||
export const POST: APIRoute = async ({ request, locals }) => {
|
||||
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
||||
const body = await request.json().catch(() => null) as any;
|
||||
const name = String(body?.name || '').trim();
|
||||
if (!name) return json({ error: 'Name fehlt.' }, 400);
|
||||
if (!body?.config || typeof body.config !== 'object') return json({ error: 'Konfiguration fehlt.' }, 400);
|
||||
const row = await one(
|
||||
`INSERT INTO print_presets (name, created_by, config) VALUES ($1,$2,$3) RETURNING id, name, config`,
|
||||
[name.slice(0, 80), locals.user.uid, JSON.stringify(body.config)]);
|
||||
return json({ preset: row });
|
||||
};
|
||||
|
||||
export const DELETE: APIRoute = async ({ url, locals }) => {
|
||||
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
||||
const id = url.searchParams.get('id');
|
||||
if (!id) return json({ error: 'id fehlt.' }, 400);
|
||||
await query('DELETE FROM print_presets WHERE id=$1', [id]);
|
||||
return json({ ok: true });
|
||||
};
|
||||
@@ -0,0 +1,181 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { layout, cutMarks, effectiveGap, type PlaceSpec, type SheetSpec, type MarkMode } from '../../../lib/printlayout';
|
||||
import { renderCell, type CropRel, type SheetCellImage, buildSheetPdf } from '../../../lib/printrender';
|
||||
import { loadSource, sheetFilename, type PrintSource } from '../../../lib/printsource';
|
||||
import { paperById, parseSizeMm, labelMm } from '../../../lib/paper';
|
||||
import { cfgForKey } from '../../../lib/delivery';
|
||||
import { uploadBuffer } from '../../../lib/picdrop';
|
||||
import { one } from '../../../lib/db';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
const MAX_CELLS = 40; // verschiedene Bilder je Bogen
|
||||
const MAX_COPIES = 200; // Exemplare je Bild
|
||||
const MAX_PAGES = 20;
|
||||
|
||||
interface CellIn {
|
||||
id: string;
|
||||
src: PrintSource;
|
||||
crop?: CropRel | null;
|
||||
wMm?: number; hMm?: number;
|
||||
size?: string; // Freitext („12x15", „35x45mm") als Alternative zu wMm/hMm
|
||||
count?: number;
|
||||
allowRotate?: boolean;
|
||||
landscape?: boolean; // Bildformat quer statt hoch
|
||||
}
|
||||
|
||||
const json = (b: unknown, s = 200) =>
|
||||
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
|
||||
|
||||
export const POST: APIRoute = async ({ request, locals }) => {
|
||||
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
||||
let body: any;
|
||||
try { body = await request.json(); } catch { return json({ error: 'Ungültige Anfrage.' }, 400); }
|
||||
|
||||
try {
|
||||
const sheet = readSheet(body);
|
||||
const marksMode: MarkMode = ['none', 'corner', 'grid'].includes(body?.marks?.mode) ? body.marks.mode : 'corner';
|
||||
const dpi = clamp(Number(body?.dpi) || 300, 72, 1200);
|
||||
const wantExt: 'jpg' | 'png' = body?.ext === 'png' ? 'png' : 'jpg';
|
||||
|
||||
const cellsIn: CellIn[] = Array.isArray(body?.cells) ? body.cells.slice(0, MAX_CELLS) : [];
|
||||
if (!cellsIn.length) return json({ error: 'Keine Bilder angegeben.' }, 400);
|
||||
|
||||
const specs: PlaceSpec[] = [];
|
||||
const resolved: { cell: CellIn; wMm: number; hMm: number }[] = [];
|
||||
for (const [i, c] of cellsIn.entries()) {
|
||||
const size = cellSize(c);
|
||||
if (!size) return json({ error: `Bild ${i + 1}: Maß fehlt oder ist ungültig.` }, 400);
|
||||
const id = String(c.id || `c${i}`);
|
||||
specs.push({
|
||||
id, wMm: size.w, hMm: size.h,
|
||||
count: clamp(Math.round(Number(c.count) || 1), 1, MAX_COPIES),
|
||||
allowRotate: c.allowRotate !== false,
|
||||
});
|
||||
resolved.push({ cell: { ...c, id }, wMm: size.w, hMm: size.h });
|
||||
}
|
||||
|
||||
const plan = layout(specs, sheet);
|
||||
if (!plan.pages.length) {
|
||||
return json({ error: 'Nichts platzierbar — Bildmaß größer als die Nutzfläche des Bogens.', unplaced: plan.unplaced }, 422);
|
||||
}
|
||||
if (plan.pages.length > MAX_PAGES) return json({ error: `Zu viele Bogen (${plan.pages.length}). Bitte Anzahl reduzieren.` }, 422);
|
||||
|
||||
// Für jedes Bild genau einmal rendern — auch bei 24 Exemplaren.
|
||||
// Gedrehte Platzierungen brauchen eine eigene Fassung.
|
||||
const images: Record<string, SheetCellImage> = {};
|
||||
const needRotated = new Set<string>();
|
||||
const needPlain = new Set<string>();
|
||||
for (const pg of plan.pages) for (const p of pg.placements) (p.rotated ? needRotated : needPlain).add(p.specId);
|
||||
|
||||
for (const r of resolved) {
|
||||
const buf = await loadSource(r.cell.src, locals.user as any);
|
||||
if (needPlain.has(r.cell.id)) {
|
||||
const out = await renderCell(buf, r.cell.crop ?? null, r.wMm, r.hMm, dpi,
|
||||
{ ext: wantExt, bleedMm: sheet.bleedMm, rotate: false });
|
||||
images[r.cell.id] = { bytes: out.buffer, ext: out.ext };
|
||||
}
|
||||
if (needRotated.has(r.cell.id)) {
|
||||
const out = await renderCell(buf, r.cell.crop ?? null, r.wMm, r.hMm, dpi,
|
||||
{ ext: wantExt, bleedMm: sheet.bleedMm, rotate: true });
|
||||
images[`${r.cell.id}::rot`] = { bytes: out.buffer, ext: out.ext };
|
||||
}
|
||||
}
|
||||
|
||||
// Platzierungen auf die passende Bildfassung umbiegen.
|
||||
const pages = plan.pages.map((pg) => ({
|
||||
placements: pg.placements.map((p) => ({ ...p, specId: p.rotated ? `${p.specId}::rot` : p.specId })),
|
||||
}));
|
||||
|
||||
const marks = plan.pages.map((pg) =>
|
||||
cutMarks(pg, sheet, {
|
||||
mode: marksMode,
|
||||
lengthMm: numOr(body?.marks?.lengthMm, 4, 1, 20),
|
||||
offsetMm: body?.marks?.offsetMm != null ? numOr(body.marks.offsetMm, 3, 0, 20) : undefined,
|
||||
bleedMm: sheet.bleedMm,
|
||||
}));
|
||||
|
||||
const kinds = resolved.map((r) => `${labelMm(r.wMm, r.hMm)}${specs.find((s) => s.id === r.cell.id)!.count > 1 ? ` ×${specs.find((s) => s.id === r.cell.id)!.count}` : ''}`);
|
||||
const footer = body?.footer === false ? null
|
||||
: `Klarbild · ${labelMm(sheet.wMm, sheet.hMm)} · ${kinds.join(' · ')} · ${dpi} dpi · Druck bei 100 % (nicht „an Seite anpassen")`;
|
||||
|
||||
const bytes = await buildSheetPdf({
|
||||
sheet, pages, marksPerPage: marks, images,
|
||||
title: String(body?.title || 'Klarbild Druckbogen'),
|
||||
footer,
|
||||
markWidthPt: numOr(body?.marks?.widthPt, 0.25, 0.1, 2),
|
||||
});
|
||||
|
||||
const filename = sheetFilename(body?.name || 'druckbogen', 'pdf');
|
||||
|
||||
// Optional: den fertigen Bogen zusätzlich ins gewohnte Ziel schieben
|
||||
// (Standard-Picdrop, NAS oder ein Zusatzziel) — wie bei den Bildern.
|
||||
let delivered = '0', deliveryMsg = '';
|
||||
if (body?.deliver) {
|
||||
const key = body.deliver.target === '' || body.deliver.target == null ? 'picdrop' : String(body.deliver.target);
|
||||
try {
|
||||
const cfg = await cfgForKey(key);
|
||||
if (!cfg) throw new Error('Ziel nicht konfiguriert.');
|
||||
const s = await one<{ picdrop_default_gallery: string | null }>('SELECT picdrop_default_gallery FROM settings WHERE id=1');
|
||||
const gallery = String(body.deliver.gallery || '').trim() || s?.picdrop_default_gallery || 'POSTER LEA';
|
||||
await uploadBuffer(cfg, gallery, filename, Buffer.from(bytes));
|
||||
delivered = '1';
|
||||
deliveryMsg = `Bogen nach „${gallery}" ausgeliefert.`;
|
||||
} catch (e: any) {
|
||||
deliveryMsg = `Auslieferung fehlgeschlagen: ${e?.message || e}`;
|
||||
}
|
||||
}
|
||||
|
||||
return new Response(Buffer.from(bytes), {
|
||||
headers: {
|
||||
'Content-Type': 'application/pdf',
|
||||
'Content-Disposition': `attachment; filename="${filename}"`,
|
||||
'X-Klarbild-Pages': String(pages.length),
|
||||
'X-Klarbild-Per-Sheet': String(plan.perSheet),
|
||||
'X-Klarbild-Delivered': delivered,
|
||||
...(deliveryMsg ? { 'X-Klarbild-Delivery-Msg': encodeURIComponent(deliveryMsg) } : {}),
|
||||
},
|
||||
});
|
||||
} catch (e: any) {
|
||||
return json({ error: e?.message || 'Druckbogen konnte nicht erzeugt werden.' }, 400);
|
||||
}
|
||||
};
|
||||
|
||||
function readSheet(body: any): SheetSpec {
|
||||
let w: number | null = null, h: number | null = null;
|
||||
const p = body?.paper;
|
||||
if (typeof p === 'string') { const pp = paperById(p); if (pp) { w = pp.w; h = pp.h; } }
|
||||
else if (p && typeof p === 'object') {
|
||||
if (p.id) { const pp = paperById(String(p.id)); if (pp) { w = pp.w; h = pp.h; } }
|
||||
if (p.wMm && p.hMm) { w = Number(p.wMm); h = Number(p.hMm); }
|
||||
if (!w && p.size) { const s = parseSizeMm(String(p.size)); if (s) { w = s.w; h = s.h; } }
|
||||
}
|
||||
if (!w || !h) throw new Error('Papierformat fehlt oder ist unbekannt.');
|
||||
if (body?.landscape) [w, h] = [h, w];
|
||||
if (w < 20 || h < 20 || w > 2000 || h > 2000) throw new Error('Papierformat außerhalb des zulässigen Bereichs.');
|
||||
|
||||
const bleedMm = numOr(body?.bleedMm, 0, 0, 10);
|
||||
const sheet: SheetSpec = {
|
||||
wMm: w, hMm: h,
|
||||
marginMm: numOr(body?.marginMm, 5, 0, 50),
|
||||
gapMm: numOr(body?.gapMm, 4, 0, 100),
|
||||
bleedMm,
|
||||
center: body?.center !== false,
|
||||
};
|
||||
sheet.gapMm = effectiveGap(sheet);
|
||||
return sheet;
|
||||
}
|
||||
|
||||
function cellSize(c: CellIn): { w: number; h: number } | null {
|
||||
let s: { w: number; h: number } | null = null;
|
||||
if (Number(c.wMm) > 0 && Number(c.hMm) > 0) s = { w: Number(c.wMm), h: Number(c.hMm) };
|
||||
else if (c.size) s = parseSizeMm(String(c.size));
|
||||
if (!s || s.w < 5 || s.h < 5 || s.w > 2000 || s.h > 2000) return null;
|
||||
return c.landscape ? { w: s.h, h: s.w } : s;
|
||||
}
|
||||
|
||||
const clamp = (n: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, n));
|
||||
const numOr = (v: any, def: number, lo: number, hi: number) => {
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? clamp(n, lo, hi) : def;
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { renderCell, type CropRel } from '../../../lib/printrender';
|
||||
import { loadSource, sheetFilename, type PrintSource } from '../../../lib/printsource';
|
||||
import { parseSizeMm, mmToPx, labelMm } from '../../../lib/paper';
|
||||
import { dpiCheck } from '../../../lib/printlayout';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
const json = (b: unknown, s = 200) =>
|
||||
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
|
||||
|
||||
/**
|
||||
* Ein einzelnes Bild exakt auf ein Maß bringen — ohne KI, ohne Bogen.
|
||||
* Antwort: die fertige Bilddatei (PNG/JPG) mit dpi-Metadaten.
|
||||
*/
|
||||
export const POST: APIRoute = async ({ request, locals }) => {
|
||||
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
||||
let body: any;
|
||||
try { body = await request.json(); } catch { return json({ error: 'Ungültige Anfrage.' }, 400); }
|
||||
|
||||
try {
|
||||
const src: PrintSource = body?.src;
|
||||
const crop: CropRel | null = body?.crop ?? null;
|
||||
let size = (Number(body?.wMm) > 0 && Number(body?.hMm) > 0)
|
||||
? { w: Number(body.wMm), h: Number(body.hMm) }
|
||||
: parseSizeMm(String(body?.size || ''));
|
||||
if (!size) return json({ error: 'Maß fehlt oder ist ungültig.' }, 400);
|
||||
if (body?.landscape) size = { w: size.h, h: size.w };
|
||||
if (size.w < 5 || size.h < 5 || size.w > 2000 || size.h > 2000) return json({ error: 'Maß außerhalb des zulässigen Bereichs.' }, 400);
|
||||
|
||||
const dpi = Math.min(1200, Math.max(72, Number(body?.dpi) || 300));
|
||||
const ext: 'jpg' | 'png' = body?.ext === 'png' ? 'png' : 'jpg';
|
||||
const buf = await loadSource(src, locals.user as any);
|
||||
const out = await renderCell(buf, crop, size.w, size.h, dpi, { ext, bleedMm: Number(body?.bleedMm) || 0 });
|
||||
|
||||
// Hinweis, falls die Quelle für echte 300 dpi zu klein ist.
|
||||
const cropW = (crop?.w ?? 1) * out.srcPx[0];
|
||||
const check = dpiCheck(cropW, size.w, dpi);
|
||||
|
||||
return new Response(out.buffer, {
|
||||
headers: {
|
||||
'Content-Type': out.ext === 'png' ? 'image/png' : 'image/jpeg',
|
||||
'Content-Disposition': `attachment; filename="${sheetFilename(body?.name || labelMm(size.w, size.h).replace(/[^0-9x×]/g, ''), out.ext)}"`,
|
||||
'X-Klarbild-Px': `${out.width}x${out.height}`,
|
||||
'X-Klarbild-Real-Dpi': String(check.dpi),
|
||||
'X-Klarbild-Dpi-Ok': check.ok ? '1' : '0',
|
||||
},
|
||||
});
|
||||
} catch (e: any) {
|
||||
return json({ error: e?.message || 'Bild konnte nicht erzeugt werden.' }, 400);
|
||||
}
|
||||
};
|
||||
|
||||
/** Kleine Hilfe für Skripte/MCP: Maß → Pixel bei dpi. */
|
||||
export const GET: APIRoute = async ({ url, locals }) => {
|
||||
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
||||
const size = parseSizeMm(url.searchParams.get('size') || '');
|
||||
const dpi = Math.min(1200, Math.max(72, Number(url.searchParams.get('dpi')) || 300));
|
||||
if (!size) return json({ error: 'Parameter size fehlt (z. B. 12x15 oder 35x45mm).' }, 400);
|
||||
return json({ mm: size, dpi, px: { w: mmToPx(size.w, dpi), h: mmToPx(size.h, dpi) }, label: labelMm(size.w, size.h) });
|
||||
};
|
||||
@@ -16,10 +16,12 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
||||
const b = await request.json();
|
||||
const row = await one(
|
||||
`INSERT INTO recipes (name, tasks, output_format, orientation, crop_mode, dpi, contour_mm,
|
||||
model_key, delivery, picdrop_gallery, custom_instruction, is_default, created_by)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,false,$12) RETURNING *`,
|
||||
model_key, delivery, picdrop_gallery, delivery_target_id, custom_instruction, mode, output_ext, is_default, created_by)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,false,$15) RETURNING *`,
|
||||
[b.name, JSON.stringify(b.tasks || []), b.output_format, b.orientation, b.crop_mode || 'crop',
|
||||
b.dpi || 300, b.contour_mm ?? null, b.model_key ?? null, b.delivery || 'library',
|
||||
b.picdrop_gallery ?? null, b.custom_instruction ?? null, locals.user.uid]);
|
||||
b.picdrop_gallery ?? null, b.delivery_target_id ?? null, b.custom_instruction ?? null,
|
||||
['each', 'compose', 'generate'].includes(b.mode) ? b.mode : 'each',
|
||||
(b.output_ext === 'jpg' || b.output_ext === 'png') ? b.output_ext : null, locals.user.uid]);
|
||||
return json({ recipe: row });
|
||||
};
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { one, query } from '../../../lib/db';
|
||||
|
||||
export const prerender = false;
|
||||
const json = (b: unknown, s = 200) =>
|
||||
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
|
||||
|
||||
const COLS = ['name', 'tasks', 'output_format', 'orientation', 'crop_mode', 'dpi', 'contour_mm',
|
||||
'model_key', 'delivery', 'picdrop_gallery', 'delivery_target_id', 'custom_instruction', 'mode', 'output_ext'];
|
||||
|
||||
export const PATCH: APIRoute = async ({ params, request, locals }) => {
|
||||
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
||||
const b = await request.json();
|
||||
const sets: string[] = []; const args: any[] = [];
|
||||
const set = (c: string, v: any) => { args.push(v); sets.push(`${c}=$${args.length}`); };
|
||||
for (const c of COLS) {
|
||||
if (c in b) set(c, c === 'tasks' ? JSON.stringify(b[c] || []) : (b[c] === '' ? null : b[c]));
|
||||
}
|
||||
if (!sets.length) return json({ error: 'Nichts zu ändern.' }, 400);
|
||||
args.push(params.id);
|
||||
const row = await one(`UPDATE recipes SET ${sets.join(',')} WHERE id=$${args.length} RETURNING *`, args);
|
||||
return json({ recipe: row });
|
||||
};
|
||||
|
||||
export const DELETE: APIRoute = async ({ params, locals }) => {
|
||||
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
||||
await query('DELETE FROM recipes WHERE id=$1', [params.id]);
|
||||
return json({ ok: true });
|
||||
};
|
||||
@@ -0,0 +1,78 @@
|
||||
---
|
||||
import Base from '../layouts/Base.astro';
|
||||
---
|
||||
<Base title="Changelog · Klarbild">
|
||||
<div class="clog">
|
||||
<div class="ckopf">
|
||||
<h1>Changelog</h1>
|
||||
<a class="zurueck" href="/anleitung">← Zur Hilfe</a>
|
||||
</div>
|
||||
<p class="lead">Was in Klarbild neu ist. Neueste Änderungen oben.</p>
|
||||
|
||||
<section>
|
||||
<div class="ver"><span class="tag">24.07.2026</span><h2>JPG-Auslieferung, eigene Formate, fehlgeschlagene Aufträge & KI-Doku</h2></div>
|
||||
<ul>
|
||||
<li><b>Picdrop bekommt genau dein Format:</b> Ausgeliefert wird jetzt exakt das Dateiformat, das du eingestellt hast — global in den Admin-Einstellungen <i>oder</i> pro Auftrag im Studio (PNG oder JPG). Keine erzwungene Umwandlung mehr. Freigestellte Motive bleiben immer PNG. (Die zwischenzeitlich „verschwundenen" Bilder waren übrigens die ganze Zeit da — es lag an der Sortierung in der Picdrop-Weboberfläche.)</li>
|
||||
<li><b>Telegram & MCP ziehen mit:</b> Der Telegram-Bot und der MCP-Server (für Assistenten) kennen jetzt ebenfalls das wählbare Dateiformat und die eigenen Formate — egal, worüber du einen Auftrag startest.</li>
|
||||
<li><b>Auftrag löschen</b> direkt aus der Warteschlange (für alte/fehlgeschlagene Aufträge). Fehlende Quellen melden jetzt einen klaren Hinweis statt kryptischer Technikfehler.</li>
|
||||
<li><b>KI-lesbare Doku</b> unter <code>/llms.txt</code> — beschreibt Zweck, Formate, API und MCP-Werkzeuge für Assistenten.</li>
|
||||
<li><b>Dateiformat wählbar:</b> global in den Admin-Einstellungen (Standard-Dateiformat PNG/JPG) <i>und</i> pro Umwandlung im Studio (Standard / PNG / JPG). JPG nur ohne Transparenz.</li>
|
||||
<li><b>Eigene Formate:</b> im Studio „Eigenes Maß (cm)" — einfach z. B. <code>25x35</code> eintippen; für Sticker reicht eine Zahl (<code>5</code> = 5×5 cm). Presets speichern das mit.</li>
|
||||
<li><b>Fehlgeschlagene Aufträge sind jetzt rot</b> in der Übersicht (vorher grün) und tragen den Grund. Behoben: das Preset „Sticker 5 cm" lief ins Leere („Unbekanntes Format: sticker5").</li>
|
||||
<li><b>Endzeit im Auftrag:</b> Warteschlange und Auftrag zeigen, wann er fertig wurde.</li>
|
||||
<li><b>Saubere Dateinamen:</b> keine doppelten Datums-/UUID-Ketten mehr im Namen.</li>
|
||||
<li><b>Metadaten-Beileger pro Ziel:</b> für Picdrop, NAS und jedes Zusatzziel getrennt schaltbar, ob die <b>.md</b>-Datei mitgeschickt wird.</li>
|
||||
<li><b>Schnelleres Laden:</b> Vollbild/Vorschau kommt als kleines JPG (~5× kleiner), Kacheln laden zuerst ein Thumbnail; fehlende Thumbnails per Knopf nachgenerierbar.</li>
|
||||
<li><b>Picdrop-Werkzeuge im Admin:</b> „Ordner anzeigen" (was liegt per SFTP wo) und „Reste aufräumen" (liegengebliebene .tmp-Dateien entfernen).</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="ver"><span class="tag">23.07.2026</span><h2>Kombinieren, Erzeugen & zweite Sicherung</h2></div>
|
||||
<ul>
|
||||
<li><b>Kombinieren:</b> mehrere Bilder + Beschreibung zu einem neuen Bild (z. B. „…mit unserer Hündin Frieda").</li>
|
||||
<li><b>Neu erzeugen:</b> Bilder allein aus Text, ganz ohne Vorlage.</li>
|
||||
<li><b>The Frame „Erweitern":</b> Szene rund ums Motiv weiter generieren (Outpainting) statt nur zuschneiden.</li>
|
||||
<li><b>Weiterbearbeiten:</b> ein fertiges Bild als neue Vorlage übernehmen und erneut bearbeiten.</li>
|
||||
<li><b>Alternativen:</b> weitere Fassungen desselben Bildes erzeugen — gruppiert mit Umschalter.</li>
|
||||
<li><b>Ordner:</b> in der Bibliothek filtern, umbenennen, löschen; je Ordner eigene Picdrop-Galerie.</li>
|
||||
<li><b>Sammel-Aktionen:</b> mehrere Bilder als ZIP laden und auf einmal in einen Ordner verschieben.</li>
|
||||
<li><b>Vorschaubilder</b> für die Bibliothek (schnell & sparsam).</li>
|
||||
<li><b>Modelle:</b> Qualitätsstufen inkl. Open Source (FLUX.2); genutztes Modell wird angezeigt.</li>
|
||||
<li><b>Farbmarkierungen</b> wie bei Picdrop (Rot / Orange / Grün / Final): pro Bild setzen und danach filtern — die Markierung steht auch in der begleitenden .md-Datei.</li>
|
||||
<li><b>„Laden" öffnet groß:</b> Tipp auf „Laden" zeigt das Bild formatfüllend — auf iPhone/iPad dann lange tippen und <b>„In Fotos sichern"</b> (zusätzlich klassischer Datei-Download).</li>
|
||||
<li><b>Rechte & Datenschutz:</b> Sichtbarkeit einstellbar (jede/r nur eigene Bilder oder „alle sehen alles"), Generierungen optional anonym, Admin sieht alles.</li>
|
||||
<li><b>Private Session:</b> nur erzeugen, danach vergessen — kein Bibliothekseintrag, keine Auslieferung/Sicherung, kein gespeicherter Prompt. Vom Admin global erlaubbar oder pro Nutzer erzwingbar.</li>
|
||||
<li><b>NSFW-Schalter:</b> als NSFW markierte Modelle sind nur bei aktiver Admin-Einstellung wähl-/sichtbar.</li>
|
||||
<li><b>Umwandeln (ein Bild + Text):</b> ein einzelnes Bild per Beschreibung umwandeln — z. B. „Foto → Ölgemälde" — und direkt aufs Format bringen. Kein Zwang mehr, zwei Bilder hochzuladen.</li>
|
||||
<li><b>Mehrere Ausgabeziele:</b> zusätzlich zum Standard-Picdrop beliebige weitere FTP/SFTP-Ziele; im Studio unter „Wohin?" auch <b>NAS</b> und einzelne FTP-Ziele wählbar; ein Preset/Ordner kann fest auf ein Ziel zeigen.</li>
|
||||
<li><b>Backup auf alle Ziele:</b> nicht nur NAS — jedes Ziel als „Backup" markierbar; „Alle Bilder sichern" spiegelt auf alle Backup-Ziele.</li>
|
||||
<li><b>Metadaten-Beileger:</b> optional zu jedem Bild eine <b>.md-Datei</b> (Prompt, Modell, Format …) mit auf Picdrop/NAS/FTP.</li>
|
||||
<li><b>Prompt-Anzeige:</b> jeder generierte Bild zeigt in der Bibliothek seinen Prompt (mit Kopieren).</li>
|
||||
<li><b>Presets selbst verwalten:</b> aktuelle Einstellungen als Preset speichern, laden, löschen.</li>
|
||||
<li><b>API-Token & MCP-Server:</b> externer Zugriff — Bilder aus einem Ordner oder von iMessage per Assistent an Klarbild übergeben.</li>
|
||||
<li><b>The Frame</b> liefert jetzt automatisch in die Galerie <b>„TheFrame-Backgrounds"</b>.</li>
|
||||
<li><b>Zweite Sicherung (NAS):</b> jedes Ergebnis zusätzlich per SFTP/FTPS aufs NAS (auch über Tailscale) — mit „NAS ✓"-Anzeige und „Alle Bilder aufs NAS sichern".</li>
|
||||
<li><b>Speicherverwaltung:</b> Vorschaubilder, Quellen löschen, automatische Aufbewahrungsfrist.</li>
|
||||
<li><b>Telegram komplett knopfbasiert:</b> Dauertastatur & Menü — Bearbeiten, Kombinieren, „✨ Neues Bild", Weiterbearbeiten, ganz ohne Befehle.</li>
|
||||
<li><b>Zurücksetzen:</b> Bibliothek & Aufträge sauber leeren (mit Sicherheitsabfrage).</li>
|
||||
<li><b>Hilfe-Seite</b> und dieser Changelog.</li>
|
||||
<li>Unter der Haube: echter Kostendeckel, verlässlicher Auftragsabschluss, Erreichbarkeits-Diagnose fürs NAS.</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.clog{max-width:760px;margin:0 auto;}
|
||||
.ckopf{display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap;}
|
||||
.clog h1{font-family:var(--font-display);font-size:1.7rem;letter-spacing:-.02em;margin:6px 0 4px;}
|
||||
.zurueck{color:var(--accent);text-decoration:none;font-size:.9rem;}
|
||||
.lead{color:var(--soft);margin:4px 0 20px;}
|
||||
.clog section{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);padding:16px 18px;margin-bottom:14px;}
|
||||
.ver{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
|
||||
.ver h2{font-size:1.05rem;margin:0;}
|
||||
.tag{font-family:var(--font-mono);font-size:10px;letter-spacing:.08em;background:var(--accent-bg);color:var(--accent);padding:3px 9px;border-radius:20px;}
|
||||
.clog ul{margin:0;padding-left:20px;line-height:1.65;}
|
||||
.clog li{margin-bottom:6px;}
|
||||
</style>
|
||||
</Base>
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
import Base from '../layouts/Base.astro';
|
||||
import PrintApp from '../components/PrintApp.tsx';
|
||||
---
|
||||
<Base title="Druck · Klarbild">
|
||||
<h1 class="seiten-titel">Druck</h1>
|
||||
<p class="seiten-lead">
|
||||
Bilder ohne KI auf exakte Maße bringen und mehrere davon in 100 %-Größe mit
|
||||
Schnittmarken auf einen Bogen setzen — Passbildsatz, Kita-Bilder, Sticker.
|
||||
</p>
|
||||
<PrintApp client:load />
|
||||
<style>
|
||||
.seiten-titel{font-family:var(--font-display);font-size:1.6rem;margin:6px 0 4px;letter-spacing:-.02em;}
|
||||
.seiten-lead{color:var(--soft);font-size:13.5px;margin:0 0 16px;max-width:64ch;line-height:1.55;}
|
||||
</style>
|
||||
</Base>
|
||||
@@ -0,0 +1,116 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
/**
|
||||
* Maschinen-/KI-lesbare Kurzdoku unter /llms.txt (öffentlich, ohne Geheimnisse).
|
||||
* Beschreibt Zweck, Fähigkeiten und die HTTP-/MCP-Schnittstelle von Klarbild,
|
||||
* damit ein KI-Agent das Tool ohne Weboberfläche bedienen kann.
|
||||
*/
|
||||
const BODY = `# Klarbild
|
||||
|
||||
> Selbst gehostetes KI-Bildwerkzeug: verwandelt Screenshots und Fundstücke in
|
||||
> saubere, druckfertige Bilder (bereinigen, freistellen, exakte Fotoformate,
|
||||
> The-Frame-Querformat, Sticker mit Kontur), liefert an Picdrop (SFTP/FTPS)
|
||||
> aus und sichert auf beliebige Backup-Ziele (z. B. NAS). Zusätzlich ein
|
||||
> KI-freies Druckmodul: Bilder exakt auf physische Maße bringen und mehrere
|
||||
> davon in 100-%-Größe mit Schnittmarken auf einen Druckbogen setzen (PDF).
|
||||
> Bedienung über Web, Telegram-Bot und HTTP-API/MCP.
|
||||
|
||||
Basis-URL: https://klarbild.heidrich-digital.de
|
||||
Sprache der Oberfläche: Deutsch. Stack: Astro 5 (SSR) · Postgres · sharp · pg-boss.
|
||||
|
||||
## Authentifizierung (für Agenten)
|
||||
- Header: \`Authorization: Bearer <API-Token>\` (Admin → „Automatisierung / MCP-Zugriff").
|
||||
- Der Token gilt für \`/api/*\` — NICHT für \`/api/admin/*\` (nur per Login-Session).
|
||||
- Ohne Token/Session antworten API-Routen mit 401.
|
||||
|
||||
## Kernbegriffe
|
||||
- Modus (mode): \`each\` (Screenshot bereinigen/freistellen/formatieren),
|
||||
\`compose\` (ein Bild umwandeln ODER mehrere kombinieren, mit Textbeschreibung),
|
||||
\`generate\` (neues Bild allein aus Text).
|
||||
- Aufgaben (tasks, nur bei each): \`clean\`, \`cutout\`, \`format\`, \`contour\`.
|
||||
- Rezept (recipe): gespeicherte Voreinstellung. Auftrag (job): ein Stapel; Position (item): ein Bild.
|
||||
- Auftrags-Status: queued, running, paused, done, failed, cancelled. \`failed\` = alle Positionen fehlgeschlagen.
|
||||
|
||||
## Formate (output_format)
|
||||
- Feste Schlüssel: 9x13,10x15,13x18,15x20,20x30,30x40,30x45,40x50,40x60,50x70,60x90,
|
||||
A4,A3,A2,20x20,30x30, theframe (3840x2160), hochformat (2160x3840), keep (Original behalten).
|
||||
- Freie Formate ohne Vorgabe: \`BREITExHOEHE\` in cm (z. B. \`25x35\`) oder \`sticker<N>\`
|
||||
für N×N cm (z. B. \`sticker5\` = 5×5 cm). Grenze 300 cm. Exakte Pixel: round(cm/2.54*dpi).
|
||||
- Dateiformat (output_ext): \`png\` (verlustfrei, Transparenz) oder \`jpg\` (klein, Picdrop-freundlich).
|
||||
JPG nur ohne Transparenz; freigestellte Motive bleiben immer PNG. Global (Admin) oder pro Rezept.
|
||||
|
||||
## HTTP-API (Auswahl, JSON)
|
||||
- GET /api/health — Status (öffentlich).
|
||||
- GET /api/me — aktueller Nutzer/Rechte.
|
||||
- GET /api/models — verfügbare Bildmodelle (Qualitätsstufen).
|
||||
- GET /api/recipes — Rezepte/Presets auflisten.
|
||||
- POST /api/recipes — Rezept anlegen. Body u. a.: name, mode, tasks[], output_format,
|
||||
orientation(portrait|landscape), crop_mode(crop|extend), dpi, contour_mm, output_ext(png|jpg|null),
|
||||
delivery(library|picdrop|both), picdrop_gallery, delivery_target_id.
|
||||
- DELETE /api/recipes/:id — Rezept löschen.
|
||||
- GET /api/delivery-targets — Auslieferungs-/Backup-Ziele.
|
||||
- POST /api/jobs — Auftrag starten. Body: { recipe, mode, delivery, private,
|
||||
prompt_text?(compose/generate), sources:[{source_path, filename}] }. Antwort: { jobId }.
|
||||
- GET /api/jobs — letzte Aufträge. GET /api/jobs/:id — Auftrag + Positionen (inkl. error_message, finished_at).
|
||||
- POST /api/jobs/:id/{pause|resume|cancel|retry-failed|delete}.
|
||||
- POST /api/items/:id/{retry|reuse|alternative}.
|
||||
- GET /api/items/:id/file — Ergebnisbild. Query: ?thumb=1 (Vorschau), ?preview=1 (kleines JPG fürs Vollbild),
|
||||
?download=1 (als Datei), ?src=1 (Quelle). Content-Type wird aus den Magic Bytes bestimmt (PNG/JPG/WEBP).
|
||||
|
||||
## Druckmodul (ohne KI, /druck)
|
||||
Rein lokale Geometrie: Zuschnitt (sharp) + PDF (pdf-lib). Kein Modell, keine Kosten.
|
||||
- Maßangaben: \`12x15\` = 12×15 cm · \`35x45mm\` · \`5\` = 5×5 cm · \`4:3/15\` = Verhältnis 4:3, längere Kante 15 cm.
|
||||
- Papierformate (id): A6,A5,A4,A3,A3plus(329×483 mm),A2, F9x13,F10x15,F13x18,F15x20,F20x30, Letter, Legal — oder freies Maß.
|
||||
- Bildformate (id): P35x45 (biometrisch), P50x50, K20x30,K30x40,K40x50,K45x60,K60x90,
|
||||
S9x13,S10x15,S12x15,S13x18,S15x20,S18x24,S20x30,S30x40,S30x45,S40x50,S40x60,S50x70,
|
||||
Q10x10,Q13x13,Q20x20,Q30x30, DA6,DA5,DA4,DA3.
|
||||
- Schnitthilfen (marks.mode): \`none\` · \`corner\` (Eckmarken außerhalb des Endformats, Standard 4 mm lang,
|
||||
3 mm Versatz, 0,25 pt Haarlinie) · \`grid\` (durchgehende Linien über den Bogen, laufen nie durch ein Motiv).
|
||||
- Beschnittzugabe (bleedMm): das Bild ragt je Seite darüber hinaus; die Trimmbox bleibt exakt der gewählte Ausschnitt.
|
||||
Der Abstand zwischen zwei Bildern ist immer ≥ 2 × Beschnittzugabe.
|
||||
- Platzierung: gleich große Bilder → exaktes Raster; gemischte Größen → MaxRects-Packer mit fester
|
||||
Ausrichtung je Format (probiert alle Ausrichtungs-Kombinationen und nimmt die mit den wenigsten Bogen).
|
||||
- Die PDF-Seite hat exakt das Bogenmaß. Beim Drucken „Tatsächliche Größe / 100 %" wählen — nicht „an Seite anpassen".
|
||||
|
||||
- POST /api/print/single — ein Bild exakt auf Maß. Body: { src:{kind:'upload',path}|{kind:'item',id},
|
||||
crop?:{x,y,w,h} (relativ 0..1; ohne Angabe mittiger Ausschnitt), size|wMm+hMm, landscape?, dpi?, ext?(jpg|png), bleedMm? }.
|
||||
Antwort: die Bilddatei. Header: X-Klarbild-Px, X-Klarbild-Real-Dpi, X-Klarbild-Dpi-Ok.
|
||||
- GET /api/print/single?size=12x15&dpi=300 — nur rechnen: mm → Pixel.
|
||||
- POST /api/print/sheet — Druckbogen als PDF. Body: { paper:{id}|{size}|{wMm,hMm}, landscape?, marginMm?,
|
||||
gapMm?, bleedMm?, center?, dpi?, ext?, footer?, marks:{mode,lengthMm?,offsetMm?},
|
||||
cells:[{ id, src, crop?, size|wMm+hMm, landscape?, count?, allowRotate? }] }.
|
||||
Antwort: application/pdf. Header: X-Klarbild-Pages, X-Klarbild-Per-Sheet.
|
||||
- GET/POST/DELETE /api/print/presets — Bogen-Vorlagen (Papier, Marken, Formate, Stückzahlen).
|
||||
Mitgeseedet: „Kita-Satz (A4)", „Schulsatz klein (A4)", „Passbildbogen 35×45 (A4)",
|
||||
„2 × 10×7,5 auf Fotopapier 10×15". Eine Vorlage mit mehreren Formaten und nur einem Bild
|
||||
klont das Bild in alle Formate.
|
||||
- Bogen ausliefern: \`deliver: { target: ''|'nas'|<uuid>, gallery? }\` in /api/print/sheet legt das PDF
|
||||
zusätzlich ins Ziel. Antwort bleibt das PDF; Header X-Klarbild-Delivered (0|1) und
|
||||
X-Klarbild-Delivery-Msg (URI-kodiert).
|
||||
|
||||
## MCP-Server (für Assistenten)
|
||||
Werkzeug \`klarbild\` (mcp/klarbild-mcp.mjs), nutzt denselben API-Token:
|
||||
- list_recipes — verfügbare Rezepte.
|
||||
- process_images — Bilder (lokaler Ordner / URLs) mit einem Rezept verarbeiten lassen.
|
||||
- job_status — Fortschritt/Ergebnis eines Auftrags abfragen.
|
||||
- exact_size — OHNE KI: ein Bild exakt auf ein Maß bringen, Datei lokal speichern.
|
||||
- print_sheet — OHNE KI: Druckbogen (mehrere Bilder × Stückzahl, Schnittmarken) als PDF lokal speichern.
|
||||
|
||||
## Auslieferung
|
||||
- Picdrop = SFTP/FTPS-Ziel; Galerie = Unterordner unter dem Basispfad. Voller Pfad = Basisordner + Galerie
|
||||
(wird erst beim Ausliefern zusammengesetzt). Standard-Galerie „POSTER LEA"; „The Frame" → „TheFrame-Backgrounds".
|
||||
- Ausgeliefert wird das Ergebnis im gewählten Dateiformat (output_ext, global oder pro Auftrag) — PNG oder JPG. Freigestellte Motive bleiben PNG.
|
||||
- Optionaler .md-Metadaten-Beileger je Ziel schaltbar (Picdrop/NAS/Zusatzziel).
|
||||
|
||||
## Datenschutz
|
||||
- Private Session (job.private=true): kein Bibliothekseintrag, keine Auslieferung/Sicherung,
|
||||
kein gespeicherter Prompt; Ergebnis wird nach kurzer Zeit gelöscht.
|
||||
- Sichtbarkeit global steuerbar (nur eigene / alle sehen alles); Admin sieht alles.
|
||||
|
||||
Weitere Doku (menschlich): In-App /anleitung und /changelog.
|
||||
`;
|
||||
|
||||
export const GET: APIRoute = async () =>
|
||||
new Response(BODY, { headers: { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'public, max-age=3600' } });
|
||||
+27
-2
@@ -10,6 +10,17 @@ export async function startImageWorker(): Promise<void> {
|
||||
await startWorker(concurrency, handle);
|
||||
}
|
||||
|
||||
/** Monatliches Budget erreicht? (Summe usage.cost im laufenden Kalendermonat.) */
|
||||
async function budgetExceeded(): Promise<boolean> {
|
||||
const s = await one<{ monthly_budget: number | null }>('SELECT monthly_budget FROM settings WHERE id=1');
|
||||
const cap = s?.monthly_budget ? Number(s.monthly_budget) : 0;
|
||||
if (!cap || cap <= 0) return false;
|
||||
const row = await one<{ spent: number }>(
|
||||
`SELECT COALESCE(sum(cost),0)::float AS spent FROM items
|
||||
WHERE cost IS NOT NULL AND created_at >= date_trunc('month', now())`);
|
||||
return (row?.spent || 0) >= cap;
|
||||
}
|
||||
|
||||
async function handle(job: GenerateJob): Promise<void> {
|
||||
// Angehaltene/abgebrochene Aufträge nicht verarbeiten.
|
||||
const j = await one<{ status: string }>('SELECT status FROM jobs WHERE id=$1', [job.jobId]);
|
||||
@@ -17,6 +28,15 @@ async function handle(job: GenerateJob): Promise<void> {
|
||||
await query(`UPDATE items SET status='queued' WHERE id=$1 AND status='running'`, [job.itemId]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Kostendeckel: bei Erreichen Auftrag anhalten, Position zurück in die Schlange.
|
||||
if (await budgetExceeded()) {
|
||||
await query(`UPDATE jobs SET status='paused' WHERE id=$1`, [job.jobId]);
|
||||
await query(`UPDATE items SET status='queued', error_message='Monatsbudget erreicht' WHERE id=$1`, [job.itemId]);
|
||||
console.error('[worker] Monatsbudget erreicht — Auftrag angehalten', job.jobId);
|
||||
return;
|
||||
}
|
||||
|
||||
await query(`UPDATE jobs SET status='running' WHERE id=$1 AND status='queued'`, [job.jobId]);
|
||||
|
||||
try {
|
||||
@@ -52,8 +72,13 @@ async function maybeComplete(jobId: string): Promise<void> {
|
||||
(SELECT count(*) FROM items WHERE job_id=$1 AND status IN ('queued','running'))::int AS open
|
||||
FROM jobs WHERE id=$1`, [jobId]);
|
||||
if (row && row.open === 0) {
|
||||
await query(`UPDATE jobs SET status='done', finished_at=now() WHERE id=$1 AND status<>'cancelled'`,
|
||||
[jobId]);
|
||||
// Alles fehlgeschlagen, nichts fertig → Auftrag gilt als fehlgeschlagen (rot).
|
||||
const finalStatus = row.done === 0 && row.failed > 0 ? 'failed' : 'done';
|
||||
// Idempotent: nur der erste Übergang löst Auslieferung + Rückmeldung aus.
|
||||
const done = await query(
|
||||
`UPDATE jobs SET status=$2, finished_at=now()
|
||||
WHERE id=$1 AND status NOT IN ('done','failed','cancelled') RETURNING id`, [jobId, finalStatus]);
|
||||
if (done.length === 0) return;
|
||||
// Picdrop-Auslieferung für alle offenen Positionen anstoßen.
|
||||
try { await deliverPendingForJob(jobId); } catch (e) { console.error('[worker] Auslieferung:', e); }
|
||||
// Telegram-Rückmeldung, wenn der Auftrag aus einem Chat kam.
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { layout, capacity, cutMarks, effectiveGap, recommendedGap, dpiCheck, type SheetSpec } from '../src/lib/printlayout';
|
||||
import { parseSizeMm, mmToPx, mmToPt, paperById, photoById, labelMm } from '../src/lib/paper';
|
||||
|
||||
const A4: SheetSpec = { wMm: 210, hMm: 297, marginMm: 5, gapMm: 12, bleedMm: 0, center: true };
|
||||
|
||||
test('parseSizeMm versteht Tills Schreibweisen', () => {
|
||||
assert.deepEqual(parseSizeMm('12x15'), { w: 120, h: 150 });
|
||||
assert.deepEqual(parseSizeMm('12 × 15 cm'), { w: 120, h: 150 });
|
||||
assert.deepEqual(parseSizeMm('35x45mm'), { w: 35, h: 45 });
|
||||
assert.deepEqual(parseSizeMm('5'), { w: 50, h: 50 });
|
||||
assert.deepEqual(parseSizeMm('4,5x6'), { w: 45, h: 60 });
|
||||
assert.deepEqual(parseSizeMm('4:3 / 15'), { w: 112.5, h: 150 });
|
||||
assert.equal(parseSizeMm('quatsch'), null);
|
||||
assert.equal(parseSizeMm('9999x1'), null);
|
||||
});
|
||||
|
||||
test('mm → px ist exakt (Druckmaß-Nachweis)', () => {
|
||||
assert.equal(mmToPx(100, 300), 1181); // 10 cm
|
||||
assert.equal(mmToPx(150, 300), 1772); // 15 cm
|
||||
assert.equal(mmToPx(300, 300), 3543); // 30 cm → wie Poster 30×40
|
||||
assert.equal(mmToPx(400, 300), 4724);
|
||||
assert.equal(mmToPx(35, 300), 413); // Passbild
|
||||
assert.equal(Math.round(mmToPt(210) * 100) / 100, 595.28); // A4 in pt
|
||||
});
|
||||
|
||||
test('Passbild-Bogen: 35×45 mm auf A4 ergibt 20 Stück', () => {
|
||||
assert.equal(capacity({ id: 'p', wMm: 35, hMm: 45, allowRotate: false }, A4), 20);
|
||||
});
|
||||
|
||||
test('Platzierungen überlappen nicht und bleiben im Satzspiegel', () => {
|
||||
const sheet: SheetSpec = { ...A4, gapMm: 6 };
|
||||
const res = layout([
|
||||
{ id: 'a', wMm: 90, hMm: 130, count: 2 },
|
||||
{ id: 'b', wMm: 35, hMm: 45, count: 6 },
|
||||
], sheet);
|
||||
assert.ok(res.pages.length >= 1);
|
||||
for (const pg of res.pages) {
|
||||
for (const p of pg.placements) {
|
||||
assert.ok(p.x >= sheet.marginMm - 1e-6, 'linker Rand');
|
||||
assert.ok(p.y >= sheet.marginMm - 1e-6, 'oberer Rand');
|
||||
assert.ok(p.x + p.w <= sheet.wMm - sheet.marginMm + 1e-6, 'rechter Rand');
|
||||
assert.ok(p.y + p.h <= sheet.hMm - sheet.marginMm + 1e-6, 'unterer Rand');
|
||||
}
|
||||
for (let i = 0; i < pg.placements.length; i++)
|
||||
for (let j = i + 1; j < pg.placements.length; j++) {
|
||||
const a = pg.placements[i], b = pg.placements[j];
|
||||
const overlap = a.x < b.x + b.w - 1e-6 && b.x < a.x + a.w - 1e-6
|
||||
&& a.y < b.y + b.h - 1e-6 && b.y < a.y + a.h - 1e-6;
|
||||
assert.ok(!overlap, `Überlappung ${a.specId}#${a.copy} / ${b.specId}#${b.copy}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('Abstand hält die Beschnittzugabe der Nachbarn auseinander', () => {
|
||||
const sheet: SheetSpec = { ...A4, gapMm: 1, bleedMm: 3 };
|
||||
assert.equal(effectiveGap(sheet), 6);
|
||||
const res = layout([{ id: 'a', wMm: 50, hMm: 50, count: 4 }], { ...sheet, gapMm: effectiveGap(sheet) });
|
||||
const row = res.pages[0].placements.filter((p) => Math.abs(p.y - res.pages[0].placements[0].y) < 1e-6)
|
||||
.sort((a, b) => a.x - b.x);
|
||||
assert.ok(row.length >= 2);
|
||||
assert.ok(row[1].x - (row[0].x + row[0].w) >= 6 - 1e-6);
|
||||
});
|
||||
|
||||
test('Eckmarken liegen außerhalb des Endformats', () => {
|
||||
const res = layout([{ id: 'a', wMm: 90, hMm: 130, count: 1 }], A4);
|
||||
const pg = res.pages[0];
|
||||
const lines = cutMarks(pg, A4, { mode: 'corner', lengthMm: 4, offsetMm: 3 });
|
||||
assert.equal(lines.length, 8, '4 Ecken × 2 Marken');
|
||||
const p = pg.placements[0];
|
||||
for (const l of lines) {
|
||||
const inside = (x: number, y: number) =>
|
||||
x > p.x + 1e-6 && x < p.x + p.w - 1e-6 && y > p.y + 1e-6 && y < p.y + p.h - 1e-6;
|
||||
assert.ok(!inside(l.x1, l.y1) && !inside(l.x2, l.y2), 'Marke ragt ins Bild');
|
||||
}
|
||||
});
|
||||
|
||||
test('Durchgehende Linien laufen nicht durch andere Bilder', () => {
|
||||
const sheet: SheetSpec = { ...A4, gapMm: 4 };
|
||||
const res = layout([{ id: 'a', wMm: 60, hMm: 80, count: 6 }], sheet);
|
||||
const pg = res.pages[0];
|
||||
const lines = cutMarks(pg, sheet, { mode: 'grid' });
|
||||
assert.ok(lines.length > 0);
|
||||
for (const l of lines) {
|
||||
for (const p of pg.placements) {
|
||||
const hitsX = Math.min(l.x1, l.x2) < p.x + p.w - 1e-3 && Math.max(l.x1, l.x2) > p.x + 1e-3;
|
||||
const hitsY = Math.min(l.y1, l.y2) < p.y + p.h - 1e-3 && Math.max(l.y1, l.y2) > p.y + 1e-3;
|
||||
assert.ok(!(hitsX && hitsY), 'Linie schneidet ein Motiv');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('Zu großes Bild landet in unplaced statt im Nirwana', () => {
|
||||
const res = layout([{ id: 'gross', wMm: 300, hMm: 400, count: 1 }], A4);
|
||||
assert.equal(res.pages.length, 0);
|
||||
assert.equal(res.unplaced[0].specId, 'gross');
|
||||
});
|
||||
|
||||
test('Mehr als ein Bogen, wenn die Menge nicht passt', () => {
|
||||
const res = layout([{ id: 'a', wMm: 35, hMm: 45, count: 25 }], A4);
|
||||
assert.equal(res.pages.length, 2);
|
||||
assert.equal(res.pages[0].placements.length, 20);
|
||||
assert.equal(res.pages[1].placements.length, 5);
|
||||
});
|
||||
|
||||
test('Drehen erlaubt mehr Bilder auf 10×15-Fotopapier', () => {
|
||||
const foto: SheetSpec = { wMm: 100, hMm: 150, marginMm: 0, gapMm: 0, bleedMm: 0, center: true };
|
||||
const ohne = capacity({ id: 'x', wMm: 100, hMm: 50, allowRotate: false }, foto);
|
||||
const mit = capacity({ id: 'x', wMm: 50, hMm: 100, allowRotate: true }, foto);
|
||||
assert.equal(ohne, 3);
|
||||
assert.ok(mit >= 3);
|
||||
});
|
||||
|
||||
test('Empfohlener Abstand macht Platz für Marken', () => {
|
||||
assert.equal(recommendedGap('corner', 0, 4, 3), 14);
|
||||
assert.equal(recommendedGap('none', 3), 6);
|
||||
assert.equal(recommendedGap('grid', 0), 0);
|
||||
});
|
||||
|
||||
test('dpi-Warnung greift bei zu kleiner Quelle', () => {
|
||||
assert.equal(dpiCheck(1181, 100, 300).ok, true);
|
||||
assert.equal(dpiCheck(600, 100, 300).ok, false);
|
||||
assert.equal(dpiCheck(1181, 100, 300).dpi, 300);
|
||||
});
|
||||
|
||||
test('Formattabellen sind konsistent', () => {
|
||||
assert.equal(paperById('A4')!.w, 210);
|
||||
assert.equal(paperById('A3plus')!.h, 483);
|
||||
assert.equal(photoById('P35x45')!.w, 35);
|
||||
assert.equal(photoById('S12x15')!.h, 150);
|
||||
assert.equal(labelMm(35, 45), '35 × 45 mm');
|
||||
assert.equal(labelMm(120, 150), '12 × 15 cm');
|
||||
});
|
||||
Reference in New Issue
Block a user