Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72d36f4716 | ||
|
|
0a44fecbe1 | ||
|
|
02d493dede | ||
|
|
107cbadf79 | ||
|
|
2a16b3f962 | ||
|
|
713bf48157 | ||
|
|
b11edd7751 |
No files matched your search
@@ -78,12 +78,32 @@ spec:
|
|||||||
initialDelaySeconds: 30
|
initialDelaySeconds: 30
|
||||||
periodSeconds: 30
|
periodSeconds: 30
|
||||||
failureThreshold: 5
|
failureThreshold: 5
|
||||||
|
# Bemessen nach dem teuersten Vorgang der Anwendung, dem
|
||||||
|
# Admin-Video-Upload: Node laedt den multipart-Body beim
|
||||||
|
# request.formData() vollstaendig in den Speicher, und die Datei
|
||||||
|
# darf bis zu 100 MB gross sein (MAX_UPLOAD_VIDEO_BYTES in
|
||||||
|
# lib/uploads.ts). Zusammen mit dem Grundverbrauch des
|
||||||
|
# Next.js-Servers sprengte das ein Limit von 512Mi — der Kernel
|
||||||
|
# beendet den Prozess dann mitten im Upload (OOMKilled), und im
|
||||||
|
# Browser kommt davon nur die HTML-Fehlerseite des Reverse-Proxy an
|
||||||
|
# ("Unexpected token '<', "<!DOCTYPE "... is not valid JSON").
|
||||||
|
#
|
||||||
|
# Die Route selbst haelt inzwischen nur noch den Body im Speicher
|
||||||
|
# und streamt alles Weitere ueber die Platte (siehe
|
||||||
|
# app/api/admin/upload-video/route.ts) — 1Gi gibt darueber hinaus
|
||||||
|
# Reserve, damit ein zweiter gleichzeitiger Zugriff den Pod nicht
|
||||||
|
# kippt.
|
||||||
|
#
|
||||||
|
# ffmpeg ist CPU-gebunden: mit nur 100m dauert das Umrechnen ein
|
||||||
|
# Vielfaches und laeuft eher in ein Proxy-Zeitlimit. Bewusst KEIN
|
||||||
|
# CPU-Limit, nur ein hoeherer Request — ein Limit wuerde den
|
||||||
|
# Transcode hart drosseln, auch wenn der Knoten gerade frei ist.
|
||||||
resources:
|
resources:
|
||||||
requests:
|
requests:
|
||||||
cpu: 100m
|
cpu: 500m
|
||||||
memory: 256Mi
|
|
||||||
limits:
|
|
||||||
memory: 512Mi
|
memory: 512Mi
|
||||||
|
limits:
|
||||||
|
memory: 1Gi
|
||||||
volumes:
|
volumes:
|
||||||
# Platzhalter — die Betreiberin ergaenzt hier ihre eigene
|
# Platzhalter — die Betreiberin ergaenzt hier ihre eigene
|
||||||
# Volume-Quelle fuer den Namen "uploads" (z.B. ein PVC, das gegen
|
# Volume-Quelle fuer den Namen "uploads" (z.B. ein PVC, das gegen
|
||||||
|
|||||||
@@ -27,6 +27,26 @@ metadata:
|
|||||||
# der beiden Limits (100 MB) plus etwas Spielraum fuer den
|
# der beiden Limits (100 MB) plus etwas Spielraum fuer den
|
||||||
# multipart/form-data-Overhead ab.
|
# multipart/form-data-Overhead ab.
|
||||||
nginx.ingress.kubernetes.io/proxy-body-size: "110m"
|
nginx.ingress.kubernetes.io/proxy-body-size: "110m"
|
||||||
|
# Ohne diese drei Annotationen wartet ingress-nginx nur 60 Sekunden je
|
||||||
|
# Phase und liefert danach eine eigene HTML-Fehlerseite (504) aus. Der
|
||||||
|
# Video-Upload braucht regelmaessig laenger: erst muessen bis zu 100 MB
|
||||||
|
# ueber die Leitung, danach rechnet ffmpeg das Video fuers Web um (siehe
|
||||||
|
# lib/video-transcode.ts). Im Browser kam davon nur an, dass die Antwort
|
||||||
|
# kein JSON war ("Unexpected token '<', "<!DOCTYPE "... is not valid
|
||||||
|
# JSON") — die Ursache war daraus nicht ablesbar.
|
||||||
|
#
|
||||||
|
# 600s deckt den langsamsten realistischen Fall ab und liegt bewusst
|
||||||
|
# oberhalb des harten ffmpeg-Zeitlimits von 5 Minuten
|
||||||
|
# (TRANSCODE_TIMEOUT_MS in lib/video-transcode.ts): so bricht im Zweifel
|
||||||
|
# die Anwendung selbst mit einer verwertbaren JSON-Meldung ab, statt dass
|
||||||
|
# nginx die Verbindung vorher kappt.
|
||||||
|
#
|
||||||
|
# Achtung: sitzt zusaetzlich ein CDN davor (z.B. Cloudflare), gilt dessen
|
||||||
|
# eigenes Origin-Zeitlimit unabhaengig hiervon weiter — bei Cloudflare
|
||||||
|
# sind das 100 Sekunden (Fehler 524), ebenfalls als HTML-Seite.
|
||||||
|
nginx.ingress.kubernetes.io/proxy-read-timeout: "600"
|
||||||
|
nginx.ingress.kubernetes.io/proxy-send-timeout: "600"
|
||||||
|
nginx.ingress.kubernetes.io/proxy-connect-timeout: "60"
|
||||||
spec:
|
spec:
|
||||||
ingressClassName: nginx
|
ingressClassName: nginx
|
||||||
tls:
|
tls:
|
||||||
|
|||||||
+17
-12
@@ -52,17 +52,22 @@ function PeopleIcon() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const STAT_KEYS = [
|
/**
|
||||||
"revenueToday",
|
* Die Kennzahlen-Kacheln des Dashboards. Bewusst ein Typ und keine
|
||||||
"revenueMonth",
|
* Wert-Konstante: die Liste wurde nie zur Laufzeit durchlaufen, sondern
|
||||||
"openOrders",
|
* ausschliesslich benutzt, um die Record-/Array-Typen unten vollstaendig zu
|
||||||
"inventory",
|
* halten — als `as const`-Array landete sie trotzdem im Bundle.
|
||||||
"soldGlasses",
|
*/
|
||||||
"avgCart",
|
type StatKey =
|
||||||
"repeatCustomers",
|
| "revenueToday"
|
||||||
] as const;
|
| "revenueMonth"
|
||||||
|
| "openOrders"
|
||||||
|
| "inventory"
|
||||||
|
| "soldGlasses"
|
||||||
|
| "avgCart"
|
||||||
|
| "repeatCustomers";
|
||||||
|
|
||||||
const statIcons: Record<(typeof STAT_KEYS)[number], () => ReturnType<typeof CoinIcon>> = {
|
const statIcons: Record<StatKey, () => ReturnType<typeof CoinIcon>> = {
|
||||||
revenueToday: CoinIcon,
|
revenueToday: CoinIcon,
|
||||||
revenueMonth: CoinIcon,
|
revenueMonth: CoinIcon,
|
||||||
openOrders: OrdersIcon,
|
openOrders: OrdersIcon,
|
||||||
@@ -161,7 +166,7 @@ export default async function AdminDashboardPage() {
|
|||||||
}
|
}
|
||||||
const repeatCustomerCount = [...ordersPerCustomer.values()].filter((count) => count > 1).length;
|
const repeatCustomerCount = [...ordersPerCustomer.values()].filter((count) => count > 1).length;
|
||||||
|
|
||||||
const statLabels: Record<(typeof STAT_KEYS)[number], string> = {
|
const statLabels: Record<StatKey, string> = {
|
||||||
revenueToday: t.adminDashboardPage.statRevenueToday,
|
revenueToday: t.adminDashboardPage.statRevenueToday,
|
||||||
revenueMonth: t.adminDashboardPage.statRevenueMonth,
|
revenueMonth: t.adminDashboardPage.statRevenueMonth,
|
||||||
openOrders: t.adminDashboardPage.statOpenOrders,
|
openOrders: t.adminDashboardPage.statOpenOrders,
|
||||||
@@ -171,7 +176,7 @@ export default async function AdminDashboardPage() {
|
|||||||
repeatCustomers: t.adminDashboardPage.statRepeatCustomers,
|
repeatCustomers: t.adminDashboardPage.statRepeatCustomers,
|
||||||
};
|
};
|
||||||
|
|
||||||
const stats: { key: (typeof STAT_KEYS)[number]; value: string }[] = [
|
const stats: { key: StatKey; value: string }[] = [
|
||||||
{ key: "revenueToday", value: formatPrice(revenueToday._sum.totalCents ?? 0) },
|
{ key: "revenueToday", value: formatPrice(revenueToday._sum.totalCents ?? 0) },
|
||||||
{ key: "revenueMonth", value: formatPrice(revenueMonth._sum.totalCents ?? 0) },
|
{ key: "revenueMonth", value: formatPrice(revenueMonth._sum.totalCents ?? 0) },
|
||||||
{ key: "openOrders", value: String(openOrders) },
|
{ key: "openOrders", value: String(openOrders) },
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { mkdir, writeFile } from "node:fs/promises";
|
import { createWriteStream } from "node:fs";
|
||||||
|
import { copyFile, mkdir, mkdtemp, open, rename, rm, stat } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import { Readable } from "node:stream";
|
||||||
|
import { pipeline } from "node:stream/promises";
|
||||||
|
import type { ReadableStream as NodeReadableStream } from "node:stream/web";
|
||||||
import { isTrustedOrigin } from "@/lib/csrf";
|
import { isTrustedOrigin } from "@/lib/csrf";
|
||||||
import { rateLimit } from "@/lib/rate-limit";
|
import { rateLimit } from "@/lib/rate-limit";
|
||||||
import { logAdminAction } from "@/lib/audit-log";
|
import { logAdminAction } from "@/lib/audit-log";
|
||||||
@@ -11,25 +16,63 @@ import { requireAdminForApi } from "../_shared";
|
|||||||
// node:fs braucht die Node-Runtime (nicht Edge).
|
// node:fs braucht die Node-Runtime (nicht Edge).
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Anzahl Bytes vom Dateianfang, die fuer die Formaterkennung genuegen:
|
||||||
|
* detectVideoExtension() braucht hoechstens die ersten 12 (MP4 traegt seine
|
||||||
|
* "ftyp"-Kennung ab Byte 4, WebM den EBML-Header ab Byte 0).
|
||||||
|
*/
|
||||||
|
const MAGIC_BYTES_LENGTH = 12;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verschiebt die fertige Datei aus dem Temp-Verzeichnis an ihren
|
||||||
|
* endgueltigen Platz. rename() ist ein reiner Verzeichniseintrag-Wechsel und
|
||||||
|
* damit sofort fertig, funktioniert aber NUR innerhalb desselben
|
||||||
|
* Dateisystems — in Produktion liegt UPLOAD_DIR auf einem gemounteten
|
||||||
|
* Volume, das Temp-Verzeichnis dagegen im Container-Dateisystem, und
|
||||||
|
* rename() scheitert dann mit EXDEV. copyFile() ueberbrueckt das; es kopiert
|
||||||
|
* im Betriebssystem, nicht ueber den Node-Heap, haelt die Datei also
|
||||||
|
* ebenfalls nie vollstaendig im Arbeitsspeicher.
|
||||||
|
*/
|
||||||
|
async function moveIntoUploadDir(from: string, to: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await rename(from, to);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const isCrossDevice = error instanceof Error && "code" in error && error.code === "EXDEV";
|
||||||
|
if (!isCrossDevice) throw error;
|
||||||
|
await copyFile(from, to);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Admin-only Upload-Endpunkt fuer das Hero-Hintergrundvideo (siehe
|
* Admin-only Upload-Endpunkt fuer das Hero-Hintergrundvideo (siehe
|
||||||
* lib/content-registry.ts "video"-Feldtyp, aktuell nur home.hero.video).
|
* lib/content-registry.ts "video"-Feldtyp, aktuell nur home.hero.video).
|
||||||
* Eigene Route statt Erweiterung von app/api/admin/upload/route.ts, weil
|
* Eigene Route statt Erweiterung von app/api/admin/upload/route.ts, weil
|
||||||
* Videos anders validiert werden: sharp kann nur Bilder dekodieren, hier
|
* Videos anders validiert werden: sharp kann nur Bilder dekodieren, hier
|
||||||
* uebernimmt ffmpeg (siehe lib/video-transcode.ts transcodeVideoForWeb) die
|
* uebernimmt ffmpeg (siehe lib/video-transcode.ts) die Neuenkodierung.
|
||||||
* Neuenkodierung. Ablauf: (1) Magic-Bytes bestaetigen MP4/WebM (nur
|
*
|
||||||
|
* Ablauf: (1) die hochgeladene Datei wird STREAMEND in eine temporaere Datei
|
||||||
|
* geschrieben, (2) ihre Magic-Bytes bestaetigen MP4/WebM (nur
|
||||||
* Format-Erkennung, sagt noch nichts ueber vollstaendige Dekodierbarkeit),
|
* Format-Erkennung, sagt noch nichts ueber vollstaendige Dekodierbarkeit),
|
||||||
* (2) ffmpeg enkodiert das Video zu einem web-optimierten MP4 neu (kein Ton,
|
* (3) ffmpeg enkodiert sie zu einem web-optimierten MP4 neu, (4) NUR das
|
||||||
* begrenzte Aufloesung/Bitrate, "+faststart" fuer sofortigen Wiedergabestart
|
* transkodierte Ergebnis wird nach UPLOAD_DIR verschoben, nie die
|
||||||
* ohne die ganze Datei laden zu muessen — das war der Hauptgrund fuer lange
|
* Originaldatei — ein erfolgreicher Transcode validiert nebenbei
|
||||||
* Ladezeiten bei einem direkt von Handy/Kamera hochgeladenen Rohvideo), (3)
|
* vollstaendig, dass die Datei tatsaechlich als Video dekodierbar ist
|
||||||
* NUR das transkodierte Ergebnis wird gespeichert, nie die Originaldatei —
|
* (analog zu reencodeImage() fuer Bilder).
|
||||||
* ein erfolgreicher Transcode validiert nebenbei vollstaendig, dass die
|
*
|
||||||
* Datei tatsaechlich als Video dekodierbar ist (kein reiner Magic-Byte-
|
* Warum streamend statt ueber Buffer: ein Video darf bis
|
||||||
* Vertrauensvorschuss mehr, analog zu reencodeImage() fuer Bilder). Zugriff
|
* MAX_UPLOAD_VIDEO_BYTES (100 MB) gross sein. Die vorherige Fassung hielt es
|
||||||
* ohnehin nur fuer eingeloggte 2FA-Admins (requireAdminForApi), Auslieferung
|
* gleich mehrfach vollstaendig im Node-Heap — einmal aus file.arrayBuffer(),
|
||||||
* mit festem Content-Type + "X-Content-Type-Options: nosniff" (siehe
|
* einmal als Buffer daraus, und das Transcoding-Ergebnis noch einmal aus
|
||||||
* app/uploads/[...path]/route.ts) und zufaelligem Dateinamen.
|
* readFile(). Bei dem in k8s/05-app-deployment.yaml gesetzten Speicherlimit
|
||||||
|
* reicht das aus, um den Prozess mitten im Upload abzuschiessen; im Browser
|
||||||
|
* kommt davon nur die HTML-Fehlerseite des Reverse-Proxy an, die der Client
|
||||||
|
* frueher als "Unexpected token '<'" meldete. Jetzt wandert die Datei ueber
|
||||||
|
* einen Stream auf die Platte und von dort per rename/copyFile an ihr Ziel,
|
||||||
|
* ohne je vollstaendig im Speicher zu liegen.
|
||||||
|
*
|
||||||
|
* Zugriff ohnehin nur fuer eingeloggte 2FA-Admins (requireAdminForApi),
|
||||||
|
* Auslieferung mit festem Content-Type + "X-Content-Type-Options: nosniff"
|
||||||
|
* (siehe app/uploads/[...path]/route.ts) und zufaelligem Dateinamen.
|
||||||
*/
|
*/
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
if (!isTrustedOrigin(request)) {
|
if (!isTrustedOrigin(request)) {
|
||||||
@@ -64,18 +107,39 @@ export async function POST(request: Request) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const buffer = Buffer.from(await file.arrayBuffer());
|
const dir = await mkdtemp(path.join(tmpdir(), "wabenhain-video-"));
|
||||||
|
const inputPath = path.join(dir, "input");
|
||||||
|
const outputPath = path.join(dir, "output.mp4");
|
||||||
|
|
||||||
if (!detectVideoExtension(buffer)) {
|
try {
|
||||||
|
// Der Cast ueberbrueckt nur eine Typ-Doppelung, keine Laufzeitgrenze:
|
||||||
|
// File.stream() ist in den DOM-Typen als ReadableStream deklariert,
|
||||||
|
// Readable.fromWeb() erwartet die Deklaration aus node:stream/web. Unter
|
||||||
|
// der Node-Runtime ist beides dieselbe globale Klasse.
|
||||||
|
const webStream = file.stream() as unknown as NodeReadableStream<Uint8Array>;
|
||||||
|
await pipeline(Readable.fromWeb(webStream), createWriteStream(inputPath));
|
||||||
|
|
||||||
|
// Formaterkennung ausschliesslich anhand der tatsaechlichen Magic-Bytes
|
||||||
|
// am Dateianfang — nie ueber den Client-Dateinamen oder den vom Client
|
||||||
|
// gesendeten Content-Type, beide sind frei faelschbar. Gelesen werden nur
|
||||||
|
// die ersten Bytes, nicht die ganze Datei.
|
||||||
|
const head = Buffer.alloc(MAGIC_BYTES_LENGTH);
|
||||||
|
const handle = await open(inputPath, "r");
|
||||||
|
let bytesRead: number;
|
||||||
|
try {
|
||||||
|
({ bytesRead } = await handle.read(head, 0, MAGIC_BYTES_LENGTH, 0));
|
||||||
|
} finally {
|
||||||
|
await handle.close();
|
||||||
|
}
|
||||||
|
if (!detectVideoExtension(head.subarray(0, bytesRead))) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Nicht unterstütztes Videoformat. Erlaubt: MP4, WebM." },
|
{ error: "Nicht unterstütztes Videoformat. Erlaubt: MP4, WebM." },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let transcoded: Buffer;
|
|
||||||
try {
|
try {
|
||||||
transcoded = await transcodeVideoForWeb(buffer);
|
await transcodeVideoForWeb(inputPath, outputPath);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
console.error("Video-Upload: Transcoding fehlgeschlagen.", error);
|
console.error("Video-Upload: Transcoding fehlgeschlagen.", error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -88,11 +152,16 @@ export async function POST(request: Request) {
|
|||||||
// Eingabeformat (MP4 ODER WebM) — deshalb hier fest "mp4" statt der
|
// Eingabeformat (MP4 ODER WebM) — deshalb hier fest "mp4" statt der
|
||||||
// urspruenglich erkannten Eingabe-Endung.
|
// urspruenglich erkannten Eingabe-Endung.
|
||||||
const filename = generateUploadFilename("mp4");
|
const filename = generateUploadFilename("mp4");
|
||||||
|
let outputBytes: number;
|
||||||
try {
|
try {
|
||||||
|
outputBytes = (await stat(outputPath)).size;
|
||||||
await mkdir(UPLOAD_DIR, { recursive: true });
|
await mkdir(UPLOAD_DIR, { recursive: true });
|
||||||
await writeFile(path.join(UPLOAD_DIR, filename), transcoded);
|
await moveIntoUploadDir(outputPath, path.join(UPLOAD_DIR, filename));
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
// Siehe Kommentar in app/api/admin/upload/route.ts (identisches Muster).
|
// Ohne dieses Catch wuerde ein Dateisystemfehler (z.B. fehlende
|
||||||
|
// Schreibrechte auf UPLOAD_DIR) als unbehandelte Exception
|
||||||
|
// durchschlagen -> Next.js liefert dafuer eine HTML-Fehlerseite statt
|
||||||
|
// JSON zurueck, aus der der Client keine Meldung gewinnen kann.
|
||||||
console.error("Video-Upload: Datei konnte nicht gespeichert werden.", error);
|
console.error("Video-Upload: Datei konnte nicht gespeichert werden.", error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Die Datei konnte nicht gespeichert werden. Bitte informiere die Technik." },
|
{ error: "Die Datei konnte nicht gespeichert werden. Bitte informiere die Technik." },
|
||||||
@@ -104,8 +173,23 @@ export async function POST(request: Request) {
|
|||||||
action: "media.upload",
|
action: "media.upload",
|
||||||
entityType: "Upload",
|
entityType: "Upload",
|
||||||
entityId: filename,
|
entityId: filename,
|
||||||
detail: `mp4, ${Math.ceil(buffer.length / 1024)} KB -> ${Math.ceil(transcoded.length / 1024)} KB`,
|
detail: `mp4, ${Math.ceil(file.size / 1024)} KB -> ${Math.ceil(outputBytes / 1024)} KB`,
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({ path: `/uploads/${filename}` });
|
return NextResponse.json({ path: `/uploads/${filename}` });
|
||||||
|
} catch (error: unknown) {
|
||||||
|
// Letzte Auffanglinie: alles, was oben nicht schon gezielt behandelt ist
|
||||||
|
// (z.B. ein Abbruch beim Schreiben der temporaeren Datei), muss trotzdem
|
||||||
|
// als JSON herauskommen — eine HTML-Fehlerseite waere fuer den Client
|
||||||
|
// nicht auswertbar.
|
||||||
|
console.error("Video-Upload: unerwarteter Fehler.", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Das Video konnte nicht verarbeitet werden." },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
// Temp-Verzeichnis in JEDEM Fall raeumen — auch bei einem Abbruch
|
||||||
|
// mittendrin, sonst sammeln sich Rohvideos im Container an.
|
||||||
|
await rm(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -34,7 +34,13 @@ export default async function ImkereiPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</ScrollReveal>
|
</ScrollReveal>
|
||||||
<ScrollReveal delay={0.1}>
|
{/* Bewusst NICHT in ScrollReveal und mit priority: dieses Bild steht
|
||||||
|
neben der h1 im ersten Viewport und ist auf breiten Bildschirmen
|
||||||
|
das groesste Element der Seite. ScrollReveal startet bei
|
||||||
|
opacity 0 und blendet erst nach der Hydration ein — zusammen mit
|
||||||
|
dem sonst greifenden Lazy-Loading erschien das Bild damit
|
||||||
|
deutlich spaeter als noetig. Die Textspalte daneben behaelt ihre
|
||||||
|
Einblendung, die kostet nichts. */}
|
||||||
<ContentImage
|
<ContentImage
|
||||||
src={image || null}
|
src={image || null}
|
||||||
alt={t.imkereiPage.imageLabel}
|
alt={t.imkereiPage.imageLabel}
|
||||||
@@ -42,8 +48,8 @@ export default async function ImkereiPage() {
|
|||||||
className="aspect-[4/5] w-full"
|
className="aspect-[4/5] w-full"
|
||||||
label={t.imkereiPage.imageLabel}
|
label={t.imkereiPage.imageLabel}
|
||||||
sizes="(min-width: 768px) 50vw, 100vw"
|
sizes="(min-width: 768px) 50vw, 100vw"
|
||||||
|
priority
|
||||||
/>
|
/>
|
||||||
</ScrollReveal>
|
|
||||||
</Container>
|
</Container>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ export function WarenkorbContent({
|
|||||||
const itemStock = stock?.[item.productId];
|
const itemStock = stock?.[item.productId];
|
||||||
return (
|
return (
|
||||||
<div key={item.productId} className="py-6 flex flex-wrap items-center gap-x-6 gap-y-3">
|
<div key={item.productId} className="py-6 flex flex-wrap items-center gap-x-6 gap-y-3">
|
||||||
<CartItemThumbnail image={item.image} className="w-20 h-20 flex-shrink-0" />
|
<CartItemThumbnail image={item.image} className="w-20 h-20 flex-shrink-0 rounded-lg" />
|
||||||
<div className="flex-1 min-w-[160px]">
|
<div className="flex-1 min-w-[160px]">
|
||||||
<Link href={`/honig/${item.slug}`} className="text-lg text-forest">
|
<Link href={`/honig/${item.slug}`} className="text-lg text-forest">
|
||||||
{item.name}
|
{item.name}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useRef, useState, type ChangeEvent } from "react";
|
import { useRef, useState, type ChangeEvent } from "react";
|
||||||
import { useI18n } from "@/i18n/i18n-provider";
|
import { useI18n } from "@/i18n/i18n-provider";
|
||||||
|
import { readUploadResponse } from "@/lib/upload-client";
|
||||||
|
|
||||||
interface AdminImageFieldProps {
|
interface AdminImageFieldProps {
|
||||||
/** Formularfeld-Name, unter dem der aktuelle Bildpfad beim Speichern des
|
/** Formularfeld-Name, unter dem der aktuelle Bildpfad beim Speichern des
|
||||||
@@ -36,12 +37,22 @@ export function AdminImageField({ name, label, initialValue }: AdminImageFieldPr
|
|||||||
try {
|
try {
|
||||||
const body = new FormData();
|
const body = new FormData();
|
||||||
body.append("file", file);
|
body.append("file", file);
|
||||||
const response = await fetch("/api/admin/upload", { method: "POST", body });
|
let response: Response;
|
||||||
const data: { path?: string; error?: string } = await response.json();
|
try {
|
||||||
if (!response.ok || !data.path) {
|
response = await fetch("/api/admin/upload", { method: "POST", body });
|
||||||
throw new Error(data.error ?? t.adminImageField.uploadError);
|
} catch {
|
||||||
|
// fetch() wirft nur, wenn der Request den Server gar nicht erreicht
|
||||||
|
// oder die Verbindung abbricht — ein HTTP-Fehlerstatus landet NICHT
|
||||||
|
// hier, sondern wird unten ausgewertet.
|
||||||
|
throw new Error(t.adminImageField.uploadErrorNetwork);
|
||||||
}
|
}
|
||||||
setValue(data.path);
|
const result = await readUploadResponse(response);
|
||||||
|
if (!result.ok) {
|
||||||
|
throw new Error(
|
||||||
|
result.kind === "message" ? result.message : t.adminImageField.uploadErrorStatus(result.status)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
setValue(result.path);
|
||||||
setStatus("idle");
|
setStatus("idle");
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
setStatus("error");
|
setStatus("error");
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useRef, useState, type ChangeEvent } from "react";
|
import { useRef, useState, type ChangeEvent } from "react";
|
||||||
import { useI18n } from "@/i18n/i18n-provider";
|
import { useI18n } from "@/i18n/i18n-provider";
|
||||||
|
import { readUploadResponse } from "@/lib/upload-client";
|
||||||
|
|
||||||
interface AdminImageListFieldProps {
|
interface AdminImageListFieldProps {
|
||||||
/** Formularfeld-Name, unter dem JEDES Bild als eigenes verstecktes Feld
|
/** Formularfeld-Name, unter dem JEDES Bild als eigenes verstecktes Feld
|
||||||
@@ -37,12 +38,22 @@ export function AdminImageListField({ name, label, initialValues }: AdminImageLi
|
|||||||
try {
|
try {
|
||||||
const body = new FormData();
|
const body = new FormData();
|
||||||
body.append("file", file);
|
body.append("file", file);
|
||||||
const response = await fetch("/api/admin/upload", { method: "POST", body });
|
let response: Response;
|
||||||
const data: { path?: string; error?: string } = await response.json();
|
try {
|
||||||
if (!response.ok || !data.path) {
|
response = await fetch("/api/admin/upload", { method: "POST", body });
|
||||||
throw new Error(data.error ?? t.adminImageField.uploadError);
|
} catch {
|
||||||
|
// fetch() wirft nur, wenn der Request den Server gar nicht erreicht
|
||||||
|
// oder die Verbindung abbricht — ein HTTP-Fehlerstatus landet NICHT
|
||||||
|
// hier, sondern wird unten ausgewertet.
|
||||||
|
throw new Error(t.adminImageField.uploadErrorNetwork);
|
||||||
}
|
}
|
||||||
setImages((prev) => [...prev, data.path!]);
|
const result = await readUploadResponse(response);
|
||||||
|
if (!result.ok) {
|
||||||
|
throw new Error(
|
||||||
|
result.kind === "message" ? result.message : t.adminImageField.uploadErrorStatus(result.status)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
setImages((prev) => [...prev, result.path]);
|
||||||
setStatus("idle");
|
setStatus("idle");
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
setStatus("error");
|
setStatus("error");
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useRef, useState, type ChangeEvent } from "react";
|
import { useRef, useState, type ChangeEvent } from "react";
|
||||||
import { useI18n } from "@/i18n/i18n-provider";
|
import { useI18n } from "@/i18n/i18n-provider";
|
||||||
|
import { readUploadResponse } from "@/lib/upload-client";
|
||||||
|
|
||||||
interface AdminVideoFieldProps {
|
interface AdminVideoFieldProps {
|
||||||
/** Formularfeld-Name, unter dem der aktuelle Videopfad beim Speichern des
|
/** Formularfeld-Name, unter dem der aktuelle Videopfad beim Speichern des
|
||||||
@@ -34,12 +35,22 @@ export function AdminVideoField({ name, label, initialValue }: AdminVideoFieldPr
|
|||||||
try {
|
try {
|
||||||
const body = new FormData();
|
const body = new FormData();
|
||||||
body.append("file", file);
|
body.append("file", file);
|
||||||
const response = await fetch("/api/admin/upload-video", { method: "POST", body });
|
let response: Response;
|
||||||
const data: { path?: string; error?: string } = await response.json();
|
try {
|
||||||
if (!response.ok || !data.path) {
|
response = await fetch("/api/admin/upload-video", { method: "POST", body });
|
||||||
throw new Error(data.error ?? t.adminVideoField.uploadError);
|
} catch {
|
||||||
|
// fetch() wirft nur, wenn der Request den Server gar nicht erreicht
|
||||||
|
// oder die Verbindung abbricht — ein HTTP-Fehlerstatus landet NICHT
|
||||||
|
// hier, sondern wird unten ausgewertet.
|
||||||
|
throw new Error(t.adminVideoField.uploadErrorNetwork);
|
||||||
}
|
}
|
||||||
setValue(data.path);
|
const result = await readUploadResponse(response);
|
||||||
|
if (!result.ok) {
|
||||||
|
throw new Error(
|
||||||
|
result.kind === "message" ? result.message : t.adminVideoField.uploadErrorStatus(result.status)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
setValue(result.path);
|
||||||
setStatus("idle");
|
setStatus("idle");
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
setStatus("error");
|
setStatus("error");
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import Image from "next/image";
|
||||||
import { PlaceholderImage } from "@/components/placeholder-image";
|
import { PlaceholderImage } from "@/components/placeholder-image";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -8,8 +9,18 @@ import { PlaceholderImage } from "@/components/placeholder-image";
|
|||||||
export function CartItemThumbnail({ image, className }: { image: string | null; className: string }) {
|
export function CartItemThumbnail({ image, className }: { image: string | null; className: string }) {
|
||||||
if (image) {
|
if (image) {
|
||||||
return (
|
return (
|
||||||
// eslint-disable-next-line @next/next/no-img-element -- externe Bildquelle noch unbekannt, siehe product-gallery.tsx
|
// Ueber next/image statt als rohes <img>. Der frueher hier stehende
|
||||||
<img src={image} alt="" className={`${className} object-cover`} />
|
// Hinweis auf eine "noch unbekannte externe Bildquelle" trifft nicht
|
||||||
|
// mehr zu: das Bild kommt aus Product.images[0] und liegt damit immer
|
||||||
|
// same-origin unter /uploads/... (geprueft von isValidUploadPath in
|
||||||
|
// lib/uploads.ts). Als rohes <img> lud der Warenkorb fuer JEDE Position
|
||||||
|
// das vollstaendig gespeicherte Bild — bis MAX_IMAGE_DIMENSION
|
||||||
|
// Kantenlaenge — um es auf 64 bzw. 80 Pixel zu verkleinern. Bei
|
||||||
|
// mehreren Positionen summierte sich das spuerbar, gerade im
|
||||||
|
// Mobilfunknetz.
|
||||||
|
<div className={`relative overflow-hidden ${className}`}>
|
||||||
|
<Image src={image} alt="" fill sizes="80px" className="object-cover" />
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return <PlaceholderImage tone="honey" className={className} />;
|
return <PlaceholderImage tone="honey" className={className} />;
|
||||||
|
|||||||
@@ -14,6 +14,12 @@ interface ContentImageProps {
|
|||||||
* true setzen — laedt das Bild mit hoher Prioritaet ohne Lazy-Loading,
|
* true setzen — laedt das Bild mit hoher Prioritaet ohne Lazy-Loading,
|
||||||
* statt es erst beim Scannen weiter unten im Dokument zu entdecken. */
|
* statt es erst beim Scannen weiter unten im Dokument zu entdecken. */
|
||||||
priority?: boolean;
|
priority?: boolean;
|
||||||
|
/** Leicht abgerundete Ecken. Standard true — passt fuer Karten, Teaser
|
||||||
|
* und Inhaltsbilder, die als eigene Flaeche auf dem Seitenhintergrund
|
||||||
|
* liegen. Fuer randlose Flaechen (Hero ueber die volle Fensterbreite)
|
||||||
|
* bewusst false: dort saesse die Rundung direkt an der Fensterkante und
|
||||||
|
* wirkte wie ein Darstellungsfehler. */
|
||||||
|
rounded?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -34,9 +40,16 @@ export function ContentImage({
|
|||||||
label,
|
label,
|
||||||
sizes = "100vw",
|
sizes = "100vw",
|
||||||
priority = false,
|
priority = false,
|
||||||
|
rounded = true,
|
||||||
}: ContentImageProps) {
|
}: ContentImageProps) {
|
||||||
|
// Dieselbe Rundung fuer Platzhalter UND echtes Bild — sonst spraenge die
|
||||||
|
// Form in dem Moment um, in dem ein Bild gepflegt wird.
|
||||||
|
const roundedClass = rounded ? "rounded-lg" : "";
|
||||||
|
|
||||||
if (!src) {
|
if (!src) {
|
||||||
return <PlaceholderImage tone={tone} className={className} label={label} />;
|
return (
|
||||||
|
<PlaceholderImage tone={tone} className={`${roundedClass} ${className}`} label={label} />
|
||||||
|
);
|
||||||
}
|
}
|
||||||
// "label" traegt bereits eine kurze inhaltliche Einordnung (Ernte-Jahr/Typ,
|
// "label" traegt bereits eine kurze inhaltliche Einordnung (Ernte-Jahr/Typ,
|
||||||
// Kategorie, Ort — siehe Aufrufer), die bisher nur auf der Platzhalterkachel
|
// Kategorie, Ort — siehe Aufrufer), die bisher nur auf der Platzhalterkachel
|
||||||
@@ -45,8 +58,22 @@ export function ContentImage({
|
|||||||
// Bildersuche als der Produkt-/Ernteame allein.
|
// Bildersuche als der Produkt-/Ernteame allein.
|
||||||
const descriptiveAlt = label ? `${alt} – ${label}` : alt;
|
const descriptiveAlt = label ? `${alt} – ${label}` : alt;
|
||||||
return (
|
return (
|
||||||
<div className={`relative overflow-hidden ${className}`}>
|
<div className={`relative overflow-hidden ${roundedClass} ${className}`}>
|
||||||
<Image src={src} alt={descriptiveAlt} fill sizes={sizes} priority={priority} className="object-cover" />
|
<Image
|
||||||
|
src={src}
|
||||||
|
alt={descriptiveAlt}
|
||||||
|
fill
|
||||||
|
sizes={sizes}
|
||||||
|
// Next.js 16 hat die frueher hier verwendete Prop "priority"
|
||||||
|
// abgekuendigt: sie erzeugt nur noch einen <link rel="preload">, setzt
|
||||||
|
// am <img> selbst aber weder fetchpriority noch loading. Gemessen kam
|
||||||
|
// das Hero-Bild damit trotz "priority" mit niedriger Netzwerk-
|
||||||
|
// Prioritaet an. Die Dokumentation nennt fuer diesen Fall
|
||||||
|
// ausdruecklich loading="eager" und fetchPriority="high" als Ersatz.
|
||||||
|
loading={priority ? "eager" : "lazy"}
|
||||||
|
fetchPriority={priority ? "high" : "auto"}
|
||||||
|
className="object-cover"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
+35
-13
@@ -5,11 +5,6 @@ import { Container, LinkButton } from "@/components/ui";
|
|||||||
import { ContentImage } from "@/components/content-image";
|
import { ContentImage } from "@/components/content-image";
|
||||||
import { useI18n } from "@/i18n/i18n-provider";
|
import { useI18n } from "@/i18n/i18n-provider";
|
||||||
|
|
||||||
const sectionVariants: Variants = {
|
|
||||||
hidden: { opacity: 0 },
|
|
||||||
visible: { opacity: 1, transition: { duration: 0.6 } },
|
|
||||||
};
|
|
||||||
|
|
||||||
const itemContainerVariants: Variants = {
|
const itemContainerVariants: Variants = {
|
||||||
hidden: {},
|
hidden: {},
|
||||||
visible: { transition: { delayChildren: 0.3, staggerChildren: 0.12 } },
|
visible: { transition: { delayChildren: 0.3, staggerChildren: 0.12 } },
|
||||||
@@ -41,17 +36,38 @@ export function Hero({
|
|||||||
const shouldReduceMotion = useReducedMotion();
|
const shouldReduceMotion = useReducedMotion();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.section
|
// Bewusst KEINE Einblend-Animation auf der aeussersten Ebene: eine
|
||||||
className="relative"
|
// Section, die bei opacity 0 startet, wird erst sichtbar, wenn das
|
||||||
initial={shouldReduceMotion ? false : "hidden"}
|
// JS-Bundle geladen und die Animation gelaufen ist. Das Hero-Bild ist
|
||||||
animate="visible"
|
// aber das groesste Element im ersten Viewport (LCP) — es so lange
|
||||||
variants={sectionVariants}
|
// zurueckzuhalten verschiebt den gemessenen Ladezeitpunkt der Seite um
|
||||||
>
|
// die gesamte Hydrationsdauer. Der gestaffelte Aufbau der Textspalte
|
||||||
|
// weiter unten bleibt unveraendert, der laeuft unabhaengig davon.
|
||||||
|
<section className="relative">
|
||||||
{video ? (
|
{video ? (
|
||||||
<div className="relative h-[78vh] min-h-[520px] w-full overflow-hidden">
|
<div className="relative h-[78vh] min-h-[520px] w-full overflow-hidden">
|
||||||
|
{/* Standbild als eigene Ebene UNTER dem Video statt im
|
||||||
|
poster-Attribut. poster nimmt nur eine fertige URL und laedt die
|
||||||
|
hochgeladene Datei damit in voller gespeicherter Groesse (bis
|
||||||
|
MAX_IMAGE_DIMENSION, siehe lib/uploads.ts) — am Bildoptimierer
|
||||||
|
vorbei und ohne Ruecksicht auf das Geraet. Als ContentImage
|
||||||
|
laeuft dasselbe Bild durch next/image: passende Breite je
|
||||||
|
Bildschirm, WebP statt JPEG, und mit "priority" als erstes
|
||||||
|
angefordert. Das Video liegt darueber und ist transparent, bis es
|
||||||
|
eigene Bilddaten hat — der Uebergang ist deshalb nicht sichtbar. */}
|
||||||
|
<div className="absolute inset-0">
|
||||||
|
<ContentImage
|
||||||
|
src={image}
|
||||||
|
alt=""
|
||||||
|
tone="forest"
|
||||||
|
className="h-full w-full"
|
||||||
|
rounded={false}
|
||||||
|
sizes="100vw"
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<video
|
<video
|
||||||
src={video}
|
src={video}
|
||||||
poster={image ?? undefined}
|
|
||||||
className="absolute inset-0 h-full w-full object-cover"
|
className="absolute inset-0 h-full w-full object-cover"
|
||||||
// Nutzer mit "Bewegung reduzieren" bekommen nur das Standbild
|
// Nutzer mit "Bewegung reduzieren" bekommen nur das Standbild
|
||||||
// (poster) statt der automatisch laufenden Videoschleife (WCAG
|
// (poster) statt der automatisch laufenden Videoschleife (WCAG
|
||||||
@@ -60,6 +76,11 @@ export function Hero({
|
|||||||
muted
|
muted
|
||||||
loop
|
loop
|
||||||
playsInline
|
playsInline
|
||||||
|
// Ohne Autoplay (Einstellung "Bewegung reduzieren") gibt es
|
||||||
|
// nichts abzuspielen — dann nur die Kopfdaten holen statt die
|
||||||
|
// ganze Datei. Mit Autoplay ignorieren Browser den Hinweis
|
||||||
|
// ohnehin und laden so viel, wie zum Starten noetig ist.
|
||||||
|
preload={shouldReduceMotion ? "metadata" : "auto"}
|
||||||
// JS-Fallback zusaetzlich zum loop-Attribut: manche Browser
|
// JS-Fallback zusaetzlich zum loop-Attribut: manche Browser
|
||||||
// (u.a. aeltere iOS-Safari-Versionen) starten ein Video nach
|
// (u.a. aeltere iOS-Safari-Versionen) starten ein Video nach
|
||||||
// "ended" nicht zuverlaessig neu, wenn autoplay/muted/playsinline
|
// "ended" nicht zuverlaessig neu, wenn autoplay/muted/playsinline
|
||||||
@@ -79,6 +100,7 @@ export function Hero({
|
|||||||
tone="forest"
|
tone="forest"
|
||||||
className="h-[78vh] min-h-[520px] w-full"
|
className="h-[78vh] min-h-[520px] w-full"
|
||||||
label="Bienenstand im Morgenlicht · Altenmünster"
|
label="Bienenstand im Morgenlicht · Altenmünster"
|
||||||
|
rounded={false}
|
||||||
priority
|
priority
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -124,6 +146,6 @@ export function Hero({
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
</Container>
|
</Container>
|
||||||
</div>
|
</div>
|
||||||
</motion.section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -63,7 +63,7 @@ export function ProductGallery({ images, productName, description }: ProductGall
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div
|
<div
|
||||||
className="relative aspect-[4/3] overflow-hidden touch-pan-y"
|
className="relative aspect-[4/3] overflow-hidden rounded-lg touch-pan-y"
|
||||||
onTouchStart={handleTouchStart}
|
onTouchStart={handleTouchStart}
|
||||||
onTouchEnd={handleTouchEnd}
|
onTouchEnd={handleTouchEnd}
|
||||||
>
|
>
|
||||||
@@ -72,14 +72,18 @@ export function ProductGallery({ images, productName, description }: ProductGall
|
|||||||
// /uploads/... (siehe uploads.ts/isValidUploadPath) — next/image
|
// /uploads/... (siehe uploads.ts/isValidUploadPath) — next/image
|
||||||
// braucht dafuer KEINEN remotePatterns-Eintrag (nur externe
|
// braucht dafuer KEINEN remotePatterns-Eintrag (nur externe
|
||||||
// Bildquellen brauchen den), siehe auch content-image.tsx. Als
|
// Bildquellen brauchen den), siehe auch content-image.tsx. Als
|
||||||
// wahrscheinlichstes LCP-Element der Produktseite mit "priority"
|
// wahrscheinlichstes LCP-Element der Produktseite sofort und mit
|
||||||
// geladen, statt erst beim Scannen des Bild-Tags entdeckt zu werden.
|
// hoher Netzwerk-Prioritaet geladen, statt erst beim Scannen des
|
||||||
|
// Bild-Tags entdeckt zu werden. Bewusst NICHT ueber die Prop
|
||||||
|
// "priority": die ist seit Next.js 16 abgekuendigt und erzeugt nur
|
||||||
|
// noch einen Preload-Link, ohne fetchpriority am <img> zu setzen.
|
||||||
<Image
|
<Image
|
||||||
src={active.src}
|
src={active.src}
|
||||||
alt={mainImageAlt}
|
alt={mainImageAlt}
|
||||||
fill
|
fill
|
||||||
sizes="(min-width: 768px) 50vw, 100vw"
|
sizes="(min-width: 768px) 50vw, 100vw"
|
||||||
priority
|
loading="eager"
|
||||||
|
fetchPriority="high"
|
||||||
className="object-cover"
|
className="object-cover"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -95,7 +99,7 @@ export function ProductGallery({ images, productName, description }: ProductGall
|
|||||||
onClick={() => setActiveIndex(index)}
|
onClick={() => setActiveIndex(index)}
|
||||||
aria-label={`${isImageSlide(slide) ? `Produktbild ${index + 1}` : slide.label} – Bild ${index + 1} von ${slides.length} anzeigen`}
|
aria-label={`${isImageSlide(slide) ? `Produktbild ${index + 1}` : slide.label} – Bild ${index + 1} von ${slides.length} anzeigen`}
|
||||||
aria-current={index === activeIndex}
|
aria-current={index === activeIndex}
|
||||||
className={`relative aspect-square overflow-hidden border-2 transition-colors ${
|
className={`relative aspect-square overflow-hidden rounded-lg border-2 transition-colors ${
|
||||||
index === activeIndex ? "border-forest" : "border-transparent hover:border-ink/20"
|
index === activeIndex ? "border-forest" : "border-transparent hover:border-ink/20"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useRecentlyViewedStore } from "@/store/recently-viewed-store";
|
import { useRecentlyViewedStore } from "@/store/recently-viewed-store";
|
||||||
import { getRecentlyViewedProducts } from "@/app/shop/actions";
|
import { getRecentlyViewedProducts } from "@/app/shop/actions";
|
||||||
import { ProductCard } from "@/components/product-card";
|
import { ProductCard } from "@/components/product-card";
|
||||||
@@ -15,22 +15,29 @@ import type { ProductWithRelations } from "@/lib/data";
|
|||||||
export function RecentlyViewedSection({ excludeProductId }: { excludeProductId?: string }) {
|
export function RecentlyViewedSection({ excludeProductId }: { excludeProductId?: string }) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const viewedIds = useRecentlyViewedStore((s) => s.viewedIds);
|
const viewedIds = useRecentlyViewedStore((s) => s.viewedIds);
|
||||||
const [products, setProducts] = useState<ProductWithRelations[]>([]);
|
const ids = useMemo(
|
||||||
|
() => viewedIds.filter((id) => id !== excludeProductId),
|
||||||
|
[viewedIds, excludeProductId]
|
||||||
|
);
|
||||||
|
const [fetched, setFetched] = useState<ProductWithRelations[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const ids = viewedIds.filter((id) => id !== excludeProductId);
|
// Der Leerfall wird bewusst NICHT mehr per setState im Effekt behandelt:
|
||||||
if (ids.length === 0) {
|
// "keine angesehenen Produkte" ergibt sich unmittelbar aus ids und laesst
|
||||||
setProducts([]);
|
// sich beim Rendern ableiten. Ein setState direkt im Effektrumpf loest
|
||||||
return;
|
// dagegen einen zusaetzlichen Renderdurchlauf aus, nur um einen Zustand
|
||||||
}
|
// herzustellen, der ohnehin schon feststand.
|
||||||
|
if (ids.length === 0) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
getRecentlyViewedProducts(ids).then((result) => {
|
getRecentlyViewedProducts(ids).then((result) => {
|
||||||
if (!cancelled) setProducts(result);
|
if (!cancelled) setFetched(result);
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [viewedIds, excludeProductId]);
|
}, [ids]);
|
||||||
|
|
||||||
|
const products = ids.length === 0 ? [] : fetched;
|
||||||
|
|
||||||
if (products.length === 0) return null;
|
if (products.length === 0) return null;
|
||||||
|
|
||||||
|
|||||||
@@ -101,12 +101,17 @@ function SearchButton() {
|
|||||||
const [suggestions, setSuggestions] = useState<SearchSuggestion[]>([]);
|
const [suggestions, setSuggestions] = useState<SearchSuggestion[]>([]);
|
||||||
const toggleButtonRef = useRef<HTMLButtonElement>(null);
|
const toggleButtonRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
|
// "Eingabe zu kurz" ergibt sich unmittelbar aus query und wird beim
|
||||||
|
// Rendern abgeleitet, statt im Effekt per setState hergestellt zu werden —
|
||||||
|
// ein setState direkt im Effektrumpf loest einen zusaetzlichen
|
||||||
|
// Renderdurchlauf aus, nur um einen ohnehin feststehenden Zustand zu
|
||||||
|
// erzeugen. Nebeneffekt: beim Leeren des Feldes verschwindet die Liste
|
||||||
|
// sofort, statt erst nach dem naechsten Effektlauf.
|
||||||
|
const visibleSuggestions = query.trim().length < 2 ? [] : suggestions;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const trimmed = query.trim();
|
const trimmed = query.trim();
|
||||||
if (trimmed.length < 2) {
|
if (trimmed.length < 2) return;
|
||||||
setSuggestions([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
fetch(`/api/shop/search-suggestions?q=${encodeURIComponent(trimmed)}`, {
|
fetch(`/api/shop/search-suggestions?q=${encodeURIComponent(trimmed)}`, {
|
||||||
@@ -153,9 +158,9 @@ function SearchButton() {
|
|||||||
placeholder={t.nav.searchPlaceholder}
|
placeholder={t.nav.searchPlaceholder}
|
||||||
className="w-full border-b border-forest bg-transparent px-2 py-1 text-sm focus:outline-none"
|
className="w-full border-b border-forest bg-transparent px-2 py-1 text-sm focus:outline-none"
|
||||||
/>
|
/>
|
||||||
{suggestions.length > 0 ? (
|
{visibleSuggestions.length > 0 ? (
|
||||||
<ul className="absolute top-full left-0 mt-2 w-72 bg-background border border-ink/10 shadow-[0_16px_32px_-16px_rgba(36,53,43,0.25)] z-50">
|
<ul className="absolute top-full left-0 mt-2 w-72 bg-background border border-ink/10 shadow-[0_16px_32px_-16px_rgba(36,53,43,0.25)] z-50">
|
||||||
{suggestions.map((s) => (
|
{visibleSuggestions.map((s) => (
|
||||||
<li key={s.slug}>
|
<li key={s.slug}>
|
||||||
<Link
|
<Link
|
||||||
href={`/honig/${s.slug}`}
|
href={`/honig/${s.slug}`}
|
||||||
@@ -466,7 +471,7 @@ export function CartDrawer() {
|
|||||||
const itemStock = stock?.[item.productId];
|
const itemStock = stock?.[item.productId];
|
||||||
return (
|
return (
|
||||||
<li key={item.productId} className="py-4 flex gap-4">
|
<li key={item.productId} className="py-4 flex gap-4">
|
||||||
<CartItemThumbnail image={item.image} className="w-16 h-16 flex-shrink-0 rounded-sm" />
|
<CartItemThumbnail image={item.image} className="w-16 h-16 flex-shrink-0 rounded-lg" />
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<p className="text-sm font-medium text-ink">{item.name}</p>
|
<p className="text-sm font-medium text-ink">{item.name}</p>
|
||||||
<p className="text-xs text-ink/65">{item.weightGrams} g</p>
|
<p className="text-xs text-ink/65">{item.weightGrams} g</p>
|
||||||
|
|||||||
@@ -1139,6 +1139,13 @@ export const de: Dictionary = {
|
|||||||
remove: "Bild entfernen",
|
remove: "Bild entfernen",
|
||||||
removeImageAt: (position) => `Bild ${position} entfernen`,
|
removeImageAt: (position) => `Bild ${position} entfernen`,
|
||||||
uploadError: "Der Upload ist fehlgeschlagen.",
|
uploadError: "Der Upload ist fehlgeschlagen.",
|
||||||
|
uploadErrorStatus: (status) =>
|
||||||
|
status === 413
|
||||||
|
? `Die Datei wurde abgewiesen, weil sie zu groß ist (HTTP 413). Sie hat den Shop gar nicht erreicht — diese Grenze setzt der vorgeschaltete Proxy bzw. das CDN.`
|
||||||
|
: status === 502 || status === 503 || status === 504 || status === 522 || status === 524
|
||||||
|
? `Der Server hat die Verarbeitung abgebrochen (HTTP ${status}). Das ist fast immer eine Zeitüberschreitung: die Verarbeitung dauert länger, als der vorgeschaltete Proxy auf eine Antwort wartet.`
|
||||||
|
: `Der Server hat unerwartet geantwortet (HTTP ${status}) — keine verwertbare Rückmeldung. Bitte informiere die Technik und nenne diese Nummer.`,
|
||||||
|
uploadErrorNetwork: "Die Verbindung zum Server wurde während des Uploads unterbrochen.",
|
||||||
},
|
},
|
||||||
adminVideoField: {
|
adminVideoField: {
|
||||||
none: "Kein Video",
|
none: "Kein Video",
|
||||||
@@ -1146,6 +1153,13 @@ export const de: Dictionary = {
|
|||||||
uploading: "Wird hochgeladen…",
|
uploading: "Wird hochgeladen…",
|
||||||
remove: "Video entfernen",
|
remove: "Video entfernen",
|
||||||
uploadError: "Der Upload ist fehlgeschlagen.",
|
uploadError: "Der Upload ist fehlgeschlagen.",
|
||||||
|
uploadErrorStatus: (status) =>
|
||||||
|
status === 413
|
||||||
|
? `Das Video wurde abgewiesen, weil es zu groß ist (HTTP 413). Es hat den Shop gar nicht erreicht — diese Grenze setzt der vorgeschaltete Proxy bzw. das CDN, nicht der Shop selbst.`
|
||||||
|
: status === 502 || status === 503 || status === 504 || status === 522 || status === 524
|
||||||
|
? `Der Server hat die Verarbeitung abgebrochen (HTTP ${status}). Bei Videos ist das fast immer eine Zeitüberschreitung: das Umrechnen fürs Web dauert länger, als der vorgeschaltete Proxy auf eine Antwort wartet. Ein kürzeres oder kleineres Video kommt in der Regel durch.`
|
||||||
|
: `Der Server hat unerwartet geantwortet (HTTP ${status}) — keine verwertbare Rückmeldung. Bitte informiere die Technik und nenne diese Nummer.`,
|
||||||
|
uploadErrorNetwork: "Die Verbindung zum Server wurde während des Uploads unterbrochen.",
|
||||||
},
|
},
|
||||||
adminShell: {
|
adminShell: {
|
||||||
navDashboard: "Dashboard",
|
navDashboard: "Dashboard",
|
||||||
|
|||||||
@@ -1139,6 +1139,13 @@ export const en: Dictionary = {
|
|||||||
remove: "Remove image",
|
remove: "Remove image",
|
||||||
removeImageAt: (position) => `Remove image ${position}`,
|
removeImageAt: (position) => `Remove image ${position}`,
|
||||||
uploadError: "The upload failed.",
|
uploadError: "The upload failed.",
|
||||||
|
uploadErrorStatus: (status) =>
|
||||||
|
status === 413
|
||||||
|
? `The file was rejected because it is too large (HTTP 413). It never reached the shop — that limit comes from the reverse proxy or CDN in front of it.`
|
||||||
|
: status === 502 || status === 503 || status === 504 || status === 522 || status === 524
|
||||||
|
? `The server aborted processing (HTTP ${status}). This is almost always a timeout: processing takes longer than the proxy in front of it waits for a response.`
|
||||||
|
: `The server responded unexpectedly (HTTP ${status}) — no usable reply. Please report this number to your technical contact.`,
|
||||||
|
uploadErrorNetwork: "The connection to the server was interrupted during the upload.",
|
||||||
},
|
},
|
||||||
adminVideoField: {
|
adminVideoField: {
|
||||||
none: "No video",
|
none: "No video",
|
||||||
@@ -1146,6 +1153,13 @@ export const en: Dictionary = {
|
|||||||
uploading: "Uploading…",
|
uploading: "Uploading…",
|
||||||
remove: "Remove video",
|
remove: "Remove video",
|
||||||
uploadError: "The upload failed.",
|
uploadError: "The upload failed.",
|
||||||
|
uploadErrorStatus: (status) =>
|
||||||
|
status === 413
|
||||||
|
? `The video was rejected because it is too large (HTTP 413). It never reached the shop — that limit comes from the reverse proxy or CDN in front of it, not from the shop itself.`
|
||||||
|
: status === 502 || status === 503 || status === 504 || status === 522 || status === 524
|
||||||
|
? `The server aborted processing (HTTP ${status}). For videos this is almost always a timeout: re-encoding for the web takes longer than the proxy in front of it waits for a response. A shorter or smaller video usually gets through.`
|
||||||
|
: `The server responded unexpectedly (HTTP ${status}) — no usable reply. Please report this number to your technical contact.`,
|
||||||
|
uploadErrorNetwork: "The connection to the server was interrupted during the upload.",
|
||||||
},
|
},
|
||||||
adminShell: {
|
adminShell: {
|
||||||
navDashboard: "Dashboard",
|
navDashboard: "Dashboard",
|
||||||
|
|||||||
@@ -840,6 +840,12 @@ export interface Dictionary {
|
|||||||
remove: string;
|
remove: string;
|
||||||
removeImageAt: (position: number) => string;
|
removeImageAt: (position: number) => string;
|
||||||
uploadError: string;
|
uploadError: string;
|
||||||
|
/** Meldung, wenn die Antwort gar kein verwertbares JSON war — dann ist
|
||||||
|
* nur der HTTP-Status bekannt (siehe lib/upload-client.ts). */
|
||||||
|
uploadErrorStatus: (status: number) => string;
|
||||||
|
/** Meldung, wenn der Request den Server gar nicht erreicht hat oder die
|
||||||
|
* Verbindung waehrend des Uploads abbrach. */
|
||||||
|
uploadErrorNetwork: string;
|
||||||
};
|
};
|
||||||
/** Wie adminImageField, aber fuer AdminVideoField (Hero-Hintergrundvideo,
|
/** Wie adminImageField, aber fuer AdminVideoField (Hero-Hintergrundvideo,
|
||||||
* admin/inhalte). */
|
* admin/inhalte). */
|
||||||
@@ -849,6 +855,10 @@ export interface Dictionary {
|
|||||||
uploading: string;
|
uploading: string;
|
||||||
remove: string;
|
remove: string;
|
||||||
uploadError: string;
|
uploadError: string;
|
||||||
|
/** Wie adminImageField.uploadErrorStatus, aber mit dem fuer Videos
|
||||||
|
* haeufigsten Grund (Zeitueberschreitung beim Umrechnen) im Text. */
|
||||||
|
uploadErrorStatus: (status: number) => string;
|
||||||
|
uploadErrorNetwork: string;
|
||||||
};
|
};
|
||||||
adminShell: {
|
adminShell: {
|
||||||
navDashboard: string;
|
navDashboard: string;
|
||||||
|
|||||||
+9
-4
@@ -165,13 +165,18 @@ describe("createDhlShipmentLabel", () => {
|
|||||||
vi.stubGlobal("fetch", fetchMock);
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
const { createDhlShipmentLabel, DhlApiError } = await import("./dhl");
|
const { createDhlShipmentLabel, DhlApiError } = await import("./dhl");
|
||||||
|
|
||||||
await expect(
|
const attempt = createDhlShipmentLabel({
|
||||||
createDhlShipmentLabel({
|
|
||||||
reference: "WH-2026-0004",
|
reference: "WH-2026-0004",
|
||||||
weightGrams: 500,
|
weightGrams: 500,
|
||||||
shipper: validShipper,
|
shipper: validShipper,
|
||||||
receiver: validAddress,
|
receiver: validAddress,
|
||||||
})
|
});
|
||||||
).rejects.toThrow(/Ungültige Anfrage/);
|
|
||||||
|
// Neben der Meldung auch den Fehlertyp pruefen — wie in den beiden Tests
|
||||||
|
// darueber. Ohne diese Zusage blieb das bereits importierte DhlApiError
|
||||||
|
// hier ungenutzt, und der Test haette jeden beliebigen anderen Fehler mit
|
||||||
|
// passendem Text durchgehen lassen.
|
||||||
|
await expect(attempt).rejects.toThrow(DhlApiError);
|
||||||
|
await expect(attempt).rejects.toThrow(/Ungültige Anfrage/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { describe, test, expect } from "vitest";
|
||||||
|
import { readUploadResponse } from "./upload-client";
|
||||||
|
|
||||||
|
function jsonResponse(body: unknown, status = 200): Response {
|
||||||
|
return new Response(JSON.stringify(body), {
|
||||||
|
status,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function htmlResponse(status: number): Response {
|
||||||
|
return new Response("<!DOCTYPE html><html><body>Gateway Time-out</body></html>", {
|
||||||
|
status,
|
||||||
|
headers: { "content-type": "text/html; charset=utf-8" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("readUploadResponse", () => {
|
||||||
|
test("returns the path of a successful upload", async () => {
|
||||||
|
const result = await readUploadResponse(jsonResponse({ path: "/uploads/abc.mp4" }));
|
||||||
|
expect(result).toEqual({ ok: true, path: "/uploads/abc.mp4" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("passes through the server's own error message", async () => {
|
||||||
|
const result = await readUploadResponse(jsonResponse({ error: "Die Datei ist zu groß (max. 100 MB)." }, 413));
|
||||||
|
expect(result).toEqual({ ok: false, kind: "message", message: "Die Datei ist zu groß (max. 100 MB)." });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reports an HTML error page by status instead of trying to parse it as JSON", async () => {
|
||||||
|
// Der eigentliche Bug: response.json() auf einer HTML-Fehlerseite wirft
|
||||||
|
// "Unexpected token '<', "<!DOCTYPE "... is not valid JSON" und
|
||||||
|
// verdeckt damit vollstaendig, WAS schiefging. Eine Fehlerseite kommt
|
||||||
|
// nie von diesem Endpunkt selbst (der antwortet immer mit JSON),
|
||||||
|
// sondern von einer Schicht davor — Reverse-Proxy, CDN oder Gateway.
|
||||||
|
const result = await readUploadResponse(htmlResponse(504));
|
||||||
|
expect(result).toEqual({ ok: false, kind: "status", status: 504 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reports a proxy 413 by status (the request never reached the app)", async () => {
|
||||||
|
const result = await readUploadResponse(htmlResponse(413));
|
||||||
|
expect(result).toEqual({ ok: false, kind: "status", status: 413 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("treats a 200 response that is not JSON as a status failure", async () => {
|
||||||
|
const result = await readUploadResponse(htmlResponse(200));
|
||||||
|
expect(result).toEqual({ ok: false, kind: "status", status: 200 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("treats JSON that is not parseable as a status failure", async () => {
|
||||||
|
const broken = new Response("{ truncated", { status: 200, headers: { "content-type": "application/json" } });
|
||||||
|
expect(await readUploadResponse(broken)).toEqual({ ok: false, kind: "status", status: 200 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("treats a JSON success body without a path as a status failure", async () => {
|
||||||
|
expect(await readUploadResponse(jsonResponse({}))).toEqual({ ok: false, kind: "status", status: 200 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("treats an empty body with an error status as a status failure", async () => {
|
||||||
|
const empty = new Response(null, { status: 502 });
|
||||||
|
expect(await readUploadResponse(empty)).toEqual({ ok: false, kind: "status", status: 502 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepts a JSON content type that carries extra parameters", async () => {
|
||||||
|
const res = new Response(JSON.stringify({ path: "/uploads/x.jpg" }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json; charset=utf-8" },
|
||||||
|
});
|
||||||
|
expect(await readUploadResponse(res)).toEqual({ ok: true, path: "/uploads/x.jpg" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
/**
|
||||||
|
* Ergebnis eines Upload-Requests aus Sicht der Admin-Felder.
|
||||||
|
*
|
||||||
|
* - `ok: true` -> der Server hat einen Upload-Pfad geliefert.
|
||||||
|
* - `kind: "message"` -> der Server hat eine eigene, bereits fuer
|
||||||
|
* Menschen formulierte Fehlermeldung geliefert
|
||||||
|
* (z.B. "Die Datei ist zu gross (max. 100 MB).").
|
||||||
|
* Die wird unveraendert angezeigt.
|
||||||
|
* - `kind: "status"` -> die Antwort kam gar nicht als verwertbares JSON
|
||||||
|
* zurueck. Dann ist nur der HTTP-Status bekannt,
|
||||||
|
* und die Meldung muss daraus gebildet werden.
|
||||||
|
*/
|
||||||
|
export type UploadResult =
|
||||||
|
| { ok: true; path: string }
|
||||||
|
| { ok: false; kind: "message"; message: string }
|
||||||
|
| { ok: false; kind: "status"; status: number };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wertet die Antwort eines Upload-Endpunkts aus, OHNE blind
|
||||||
|
* response.json() aufzurufen.
|
||||||
|
*
|
||||||
|
* Die Upload-Route-Handler (app/api/admin/upload/route.ts und
|
||||||
|
* upload-video/route.ts) antworten ausnahmslos mit JSON — auch in jedem
|
||||||
|
* Fehlerfall. Trifft hier trotzdem HTML ein, stammt die Antwort gar nicht
|
||||||
|
* von der Anwendung, sondern von einer Schicht davor: Reverse-Proxy, CDN
|
||||||
|
* oder Gateway, das den Request abgewiesen (zu grosser Body) oder
|
||||||
|
* abgebrochen hat (Zeitueberschreitung beim Transcoding eines grossen
|
||||||
|
* Videos).
|
||||||
|
*
|
||||||
|
* Genau dort lag der eigentliche Schaden: response.json() auf so einer
|
||||||
|
* HTML-Seite wirft "Unexpected token '<', "<!DOCTYPE "... is not valid
|
||||||
|
* JSON". Diese Meldung landete wortwoertlich im Admin-Formular und
|
||||||
|
* verdeckte vollstaendig, was tatsaechlich passiert war — weder der
|
||||||
|
* HTTP-Status noch die Schicht, die abgebrochen hat, waren daraus
|
||||||
|
* ablesbar. Der Status wird deshalb erhalten und nach oben gereicht.
|
||||||
|
*/
|
||||||
|
export async function readUploadResponse(response: Response): Promise<UploadResult> {
|
||||||
|
const contentType = response.headers.get("content-type") ?? "";
|
||||||
|
const isJson = contentType.split(";")[0].trim().toLowerCase() === "application/json";
|
||||||
|
|
||||||
|
if (!isJson) {
|
||||||
|
return { ok: false, kind: "status", status: response.status };
|
||||||
|
}
|
||||||
|
|
||||||
|
let data: { path?: unknown; error?: unknown };
|
||||||
|
try {
|
||||||
|
data = (await response.json()) as { path?: unknown; error?: unknown };
|
||||||
|
} catch {
|
||||||
|
// JSON angekuendigt, aber abgeschnitten/unvollstaendig — etwa wenn die
|
||||||
|
// Verbindung mitten in der Antwort abbricht.
|
||||||
|
return { ok: false, kind: "status", status: response.status };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof data.error === "string" && data.error.length > 0) {
|
||||||
|
return { ok: false, kind: "message", message: data.error };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.ok && typeof data.path === "string" && data.path.length > 0) {
|
||||||
|
return { ok: true, path: data.path };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: false, kind: "status", status: response.status };
|
||||||
|
}
|
||||||
+22
-30
@@ -1,7 +1,4 @@
|
|||||||
import { spawn } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
||||||
import { tmpdir } from "node:os";
|
|
||||||
import path from "node:path";
|
|
||||||
import ffmpegStaticPath from "ffmpeg-static";
|
import ffmpegStaticPath from "ffmpeg-static";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -9,7 +6,7 @@ import ffmpegStaticPath from "ffmpeg-static";
|
|||||||
* (versehentlich oder absichtlich) pathologischer Upload einen
|
* (versehentlich oder absichtlich) pathologischer Upload einen
|
||||||
* Server-Prozess auf unbestimmte Zeit blockiert. Admin-Uploads sind selten
|
* Server-Prozess auf unbestimmte Zeit blockiert. Admin-Uploads sind selten
|
||||||
* (ein Hero-Video), 5 Minuten reichen fuer jedes realistische kurze
|
* (ein Hero-Video), 5 Minuten reichen fuer jedes realistische kurze
|
||||||
* Hintergrundvideo bei "medium"-Preset deutlich.
|
* Hintergrundvideo bei "veryfast"-Preset deutlich.
|
||||||
*/
|
*/
|
||||||
const TRANSCODE_TIMEOUT_MS = 5 * 60 * 1000;
|
const TRANSCODE_TIMEOUT_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
@@ -29,20 +26,23 @@ const TRANSCODE_TIMEOUT_MS = 5 * 60 * 1000;
|
|||||||
* statt der oft sehr hohen Rohbitrate direkt von Kamera/Handy.
|
* statt der oft sehr hohen Rohbitrate direkt von Kamera/Handy.
|
||||||
* 4. "+faststart": verschiebt den moov-Atom (Inhaltsverzeichnis der Datei)
|
* 4. "+faststart": verschiebt den moov-Atom (Inhaltsverzeichnis der Datei)
|
||||||
* an den Dateianfang. Ohne dieses Flag muss der Browser haeufig die
|
* an den Dateianfang. Ohne dieses Flag muss der Browser haeufig die
|
||||||
* GESAMTE Datei laden, bevor er ueberhaupt zu spielen beginnen kann —
|
* GESAMTE Datei laden, bevor er ueberhaupt zu spielen beginnen kann.
|
||||||
* das allein erklaerte einen grossen Teil der langsamen ersten
|
|
||||||
* Wiedergabe, unabhaengig von der Dateigroesse.
|
|
||||||
*
|
*
|
||||||
* Nebeneffekt: ein erfolgreicher Transcode beweist zugleich, dass die Datei
|
* Nebeneffekt: ein erfolgreicher Transcode beweist zugleich, dass die Datei
|
||||||
* tatsaechlich vollstaendig als Video dekodierbar ist — schliesst damit die
|
* tatsaechlich vollstaendig als Video dekodierbar ist — schliesst damit die
|
||||||
* gleiche Restluecke, die reencodeImage() (lib/uploads.ts) fuer Bilder schon
|
* gleiche Restluecke, die reencodeImage() (lib/uploads.ts) fuer Bilder schon
|
||||||
* abdeckt (volle Neuvalidierung der Pixel-/Frame-Daten, nicht nur der
|
* abdeckt (volle Neuvalidierung der Frame-Daten, nicht nur der Magic-Bytes).
|
||||||
* Magic-Bytes am Dateianfang).
|
*
|
||||||
|
* Arbeitet bewusst auf DATEIPFADEN, nicht auf Buffern: ffmpeg liest und
|
||||||
|
* schreibt ohnehin von der Platte, und ein Hero-Video darf bis zu
|
||||||
|
* MAX_UPLOAD_VIDEO_BYTES gross sein. Eingabe und Ergebnis zusaetzlich
|
||||||
|
* vollstaendig im Node-Heap zu halten, kostete pro Upload ein Vielfaches
|
||||||
|
* der Dateigroesse an Arbeitsspeicher — bei einem Container mit knappem
|
||||||
|
* Speicherlimit reicht das aus, um den Prozess abzuschiessen (der Browser
|
||||||
|
* sieht davon nur den Verbindungsabbruch bzw. die HTML-Fehlerseite des
|
||||||
|
* Reverse-Proxy).
|
||||||
*/
|
*/
|
||||||
export async function transcodeVideoForWeb(buffer: Buffer): Promise<Buffer> {
|
export async function transcodeVideoForWeb(inputPath: string, outputPath: string): Promise<void> {
|
||||||
const dir = await mkdtemp(path.join(tmpdir(), "wabenhain-video-"));
|
|
||||||
const inputPath = path.join(dir, "input");
|
|
||||||
const outputPath = path.join(dir, "output.mp4");
|
|
||||||
const args = [
|
const args = [
|
||||||
"-y",
|
"-y",
|
||||||
"-i",
|
"-i",
|
||||||
@@ -56,12 +56,11 @@ export async function transcodeVideoForWeb(buffer: Buffer): Promise<Buffer> {
|
|||||||
// gegen Kompressionseffizienz, nicht gegen das Qualitaetsziel selbst (das
|
// gegen Kompressionseffizienz, nicht gegen das Qualitaetsziel selbst (das
|
||||||
// steuert weiterhin allein -crf) — bei identischem CRF wird die Datei mit
|
// steuert weiterhin allein -crf) — bei identischem CRF wird die Datei mit
|
||||||
// "veryfast" minimal groesser, aber typischerweise 3-5x schneller fertig.
|
// "veryfast" minimal groesser, aber typischerweise 3-5x schneller fertig.
|
||||||
// Noetig, weil ein Reverse-Proxy/CDN (hier: Cloudflare) vor einem zu
|
// Noetig, weil ein Reverse-Proxy/CDN vor einem zu lange antwortenden
|
||||||
// lange antwortenden Origin-Request selbst abbricht und eine eigene
|
// Origin-Request selbst abbricht und eine eigene HTML-Fehlerseite
|
||||||
// HTML-Fehlerseite ausliefert -> im Client "Unexpected token '<' ...
|
// ausliefert -> im Client "Unexpected token '<' ... is not valid JSON",
|
||||||
// is not valid JSON", weil response.json() diese HTML-Seite nicht
|
// weil die Antwort dann kein JSON mehr ist (siehe lib/upload-client.ts,
|
||||||
// parsen kann. Mit "medium" konnte ein 57-MB-Rohvideo laenger brauchen,
|
// das diesen Fall inzwischen als Status meldet statt zu parsen).
|
||||||
// als das CDN auf eine Antwort wartet.
|
|
||||||
"-preset",
|
"-preset",
|
||||||
"veryfast",
|
"veryfast",
|
||||||
"-crf",
|
"-crf",
|
||||||
@@ -77,8 +76,6 @@ export async function transcodeVideoForWeb(buffer: Buffer): Promise<Buffer> {
|
|||||||
outputPath,
|
outputPath,
|
||||||
];
|
];
|
||||||
|
|
||||||
try {
|
|
||||||
await writeFile(inputPath, buffer);
|
|
||||||
try {
|
try {
|
||||||
// Bevorzugt "ffmpeg" ueber PATH — im Produktions-Image per "apk add
|
// Bevorzugt "ffmpeg" ueber PATH — im Produktions-Image per "apk add
|
||||||
// ffmpeg" installiert (siehe Dockerfile "runner"-Stage), fest unter
|
// ffmpeg" installiert (siehe Dockerfile "runner"-Stage), fest unter
|
||||||
@@ -87,19 +84,14 @@ export async function transcodeVideoForWeb(buffer: Buffer): Promise<Buffer> {
|
|||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
// ENOENT = Binary ueber PATH nicht gefunden (typischerweise ein
|
// ENOENT = Binary ueber PATH nicht gefunden (typischerweise ein
|
||||||
// lokaler Windows-/Mac-Rechner ohne System-ffmpeg) — dann auf die per
|
// lokaler Windows-/Mac-Rechner ohne System-ffmpeg) — dann auf die per
|
||||||
// npm mitgelieferte ffmpeg-static-Binary ausweichen, damit "npm run
|
// npm mitgelieferte ffmpeg-static-Binary ausweichen, damit "pnpm dev"
|
||||||
// dev" ohne manuelle Einrichtung funktioniert. Jeder andere Fehler
|
// ohne manuelle Einrichtung funktioniert. Jeder andere Fehler (z.B. eine
|
||||||
// (z.B. eine tatsaechlich nicht dekodierbare Datei) soll dagegen
|
// tatsaechlich nicht dekodierbare Datei) soll dagegen unveraendert
|
||||||
// unveraendert durchschlagen, nicht stillschweigend erneut versucht
|
// durchschlagen, nicht stillschweigend erneut versucht werden.
|
||||||
// werden.
|
|
||||||
const isMissingBinary = error instanceof Error && "code" in error && error.code === "ENOENT";
|
const isMissingBinary = error instanceof Error && "code" in error && error.code === "ENOENT";
|
||||||
if (!isMissingBinary || !ffmpegStaticPath) throw error;
|
if (!isMissingBinary || !ffmpegStaticPath) throw error;
|
||||||
await runFfmpeg(ffmpegStaticPath, args);
|
await runFfmpeg(ffmpegStaticPath, args);
|
||||||
}
|
}
|
||||||
return await readFile(outputPath);
|
|
||||||
} finally {
|
|
||||||
await rm(dir, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function runFfmpeg(binary: string, args: string[]): Promise<void> {
|
function runFfmpeg(binary: string, args: string[]): Promise<void> {
|
||||||
|
|||||||
Reference in new issue
Block a user