Compare commits

..
3 Commits
Author SHA1 Message Date
Nick SzaunigandClaude Sonnet 5 a3b11cc8e2 feat: Ernten und Chargen im Admin loeschbar machen
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m27s
Neue deleteHarvestAction/deleteBatchAction, jeweils mit vorheriger
Referenzpruefung statt rohem Datenbankfehler: eine Ernte laesst sich nur
loeschen, wenn keine Chargen/Produkte mehr darauf verweisen, eine Charge
nur, wenn sie keinem Produkt zugeordnet ist und in keiner Bestellung
verwendet wurde (Chargen-Code steht auf Rechnungen/QR-Codes und darf aus
Nachweispflicht nie verschwinden). Loeschen-Button erscheint in den
Listen nur, wenn die Loeschung tatsaechlich moeglich ist, mit
Bestaetigungsdialog wie beim bestehenden Benutzer-Loeschen.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-11 23:45:23 +02:00
Nick SzaunigandClaude Sonnet 5 c7286041a1 fix: echten Button statt rohem Datei-Input bei allen Admin-Uploads
Bild-/Video-Upload-Felder (admin/inhalte, admin/produkte, admin/ernten)
zeigten den unstyled Browser-Standard-Datei-Input — kaum als
Bedienelement erkennbar. Input jetzt versteckt, stattdessen ein
richtiger, zum Admin-Design passender Button, der den Datei-Dialog
oeffnet.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-11 23:30:32 +02:00
Nick SzaunigandClaude Sonnet 5 6a815e85fe fix: Video-Upload-Limit auf 100 MB anheben
40 MB war fuer ein reales, nicht web-optimiertes Hero-Video (57 MB) zu
knapp. Ingress-Body-Size-Limit entsprechend mitgezogen.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-11 23:13:04 +02:00
11 changed files with 185 additions and 24 deletions

No files matched your search

+5 -5
View File
@@ -20,13 +20,13 @@ metadata:
nginx.ingress.kubernetes.io/ssl-redirect: "true" nginx.ingress.kubernetes.io/ssl-redirect: "true"
# ingress-nginx laesst ohne diese Annotation standardmaessig nur 1 MB pro # ingress-nginx laesst ohne diese Annotation standardmaessig nur 1 MB pro
# Request durch — weit unter dem Bild- (8 MB, lib/uploads.ts # Request durch — weit unter dem Bild- (8 MB, lib/uploads.ts
# MAX_UPLOAD_BYTES) und erst recht dem Video-Limit (40 MB, lib/uploads.ts # MAX_UPLOAD_BYTES) und erst recht dem Video-Limit (100 MB, lib/uploads.ts
# MAX_UPLOAD_VIDEO_BYTES) der Admin-Uploads. Ohne diese Zeile bricht # MAX_UPLOAD_VIDEO_BYTES) der Admin-Uploads. Ohne diese Zeile bricht
# nginx jeden Upload jenseits von 1 MB vor der App ab (Verbindungsabbruch # nginx jeden Upload jenseits von 1 MB vor der App ab (Verbindungsabbruch
# ohne verwertbare Fehlermeldung im Browser) — 45m deckt das groessere der # ohne verwertbare Fehlermeldung im Browser) — 110m deckt das groessere
# beiden Limits (40 MB) plus etwas Spielraum fuer den multipart/form-data- # der beiden Limits (100 MB) plus etwas Spielraum fuer den
# Overhead ab. # multipart/form-data-Overhead ab.
nginx.ingress.kubernetes.io/proxy-body-size: "45m" nginx.ingress.kubernetes.io/proxy-body-size: "110m"
spec: spec:
ingressClassName: nginx ingressClassName: nginx
tls: tls:
+80 -1
View File
@@ -5,7 +5,7 @@ import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { logAdminAction } from "@/lib/audit-log"; import { logAdminAction } from "@/lib/audit-log";
import { isValidUploadPath } from "@/lib/uploads"; import { isValidUploadPath } from "@/lib/uploads";
import { requireAdmin, str, num, strArray } from "./_shared"; import { requireAdmin, fail, str, num, strArray } from "./_shared";
export async function createHarvestAction(formData: FormData): Promise<void> { export async function createHarvestAction(formData: FormData): Promise<void> {
const admin = await requireAdmin(); const admin = await requireAdmin();
@@ -68,6 +68,45 @@ export async function updateHarvestAction(formData: FormData): Promise<void> {
redirect("/admin/ernten"); redirect("/admin/ernten");
} }
/**
* Loescht eine Ernte unwiderruflich. harvestId ist auf Batch UND
* harvestId/batchId auf Product jeweils onDelete: Restrict (siehe
* prisma/schema.prisma) — die Datenbank wuerde ein direktes delete() also
* ohnehin ablehnen, sobald noch Chargen oder Produkte auf diese Ernte
* verweisen. Die Zaehlung hier VOR dem Loeschversuch liefert dafuer eine
* konkrete, verstaendliche Fehlermeldung statt eines rohen Datenbankfehlers.
*/
export async function deleteHarvestAction(formData: FormData): Promise<void> {
const admin = await requireAdmin();
const id = str(formData, "id");
const harvest = await prisma.harvest.findUnique({
where: { id },
include: { _count: { select: { batches: true, products: true } } },
});
if (!harvest) fail("/admin/ernten", "Ernte nicht gefunden.");
if (harvest._count.batches > 0 || harvest._count.products > 0) {
fail(
"/admin/ernten",
`Diese Ernte hat noch ${harvest._count.batches} Charge(n) und ${harvest._count.products} Produkt(e). Lösche oder verschiebe diese zuerst.`
);
}
await prisma.harvest.delete({ where: { id } });
await logAdminAction(admin, {
action: "harvest.delete",
entityType: "Harvest",
entityId: id,
detail: `${harvest.type} ${harvest.year}`,
});
revalidatePath("/admin/ernten");
revalidatePath("/ernten");
redirect("/admin/ernten");
}
/** /**
* Isolierte "Veroeffentlichen"/"Verbergen"-Aktion, getrennt von * Isolierte "Veroeffentlichen"/"Verbergen"-Aktion, getrennt von
* updateHarvestAction — so kann der Status per Knopfdruck direkt aus der * updateHarvestAction — so kann der Status per Knopfdruck direkt aus der
@@ -136,3 +175,43 @@ export async function updateBatchAction(formData: FormData): Promise<void> {
revalidatePath("/admin/chargen"); revalidatePath("/admin/chargen");
redirect("/admin/chargen"); redirect("/admin/chargen");
} }
/**
* Loescht eine Charge unwiderruflich. productId/batchId auf OrderItem sind
* onDelete: Restrict (siehe prisma/schema.prisma) — eine Charge, die schon
* in mindestens einer Bestellposition verwendet wurde, darf aus
* Rechnungs-/Nachweispflicht NIE geloescht werden (Chargen-Code steht auf
* dem QR-Code des Glases und in Rechnungen). Batch.products ist ebenfalls
* Restrict. Die Zaehlung hier VOR dem Loeschversuch liefert dafuer eine
* konkrete, verstaendliche Fehlermeldung statt eines rohen Datenbankfehlers.
*/
export async function deleteBatchAction(formData: FormData): Promise<void> {
const admin = await requireAdmin();
const id = str(formData, "id");
const batch = await prisma.batch.findUnique({
where: { id },
include: { _count: { select: { products: true, orderItems: true } } },
});
if (!batch) fail("/admin/chargen", "Charge nicht gefunden.");
if (batch._count.orderItems > 0) {
fail(
"/admin/chargen",
`Charge "${batch.code}" wurde bereits in ${batch._count.orderItems} Bestellposition(en) verwendet und kann deshalb nicht gelöscht werden.`
);
}
if (batch._count.products > 0) {
fail(
"/admin/chargen",
`Charge "${batch.code}" ist noch ${batch._count.products} Produkt(en) zugeordnet. Löse diese Zuordnung zuerst.`
);
}
await prisma.batch.delete({ where: { id } });
await logAdminAction(admin, { action: "batch.delete", entityType: "Batch", entityId: id, detail: batch.code });
revalidatePath("/admin/chargen");
redirect("/admin/chargen");
}
+22 -5
View File
@@ -1,17 +1,19 @@
import Link from "next/link"; import Link from "next/link";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { BackLink } from "@/components/back-link"; import { BackLink } from "@/components/back-link";
import { ConfirmForm } from "@/components/confirm-form";
import { formatDate } from "@/lib/format"; import { formatDate } from "@/lib/format";
import { createBatchAction } from "../actions"; import { createBatchAction, deleteBatchAction } from "../actions";
import { getLocale } from "@/i18n/locale"; import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary"; import { getDictionary } from "@/i18n/get-dictionary";
export default async function AdminBatchesPage() { export default async function AdminBatchesPage(props: PageProps<"/admin/chargen">) {
const t = getDictionary(await getLocale()); const t = getDictionary(await getLocale());
const { error } = await props.searchParams;
const [batches, harvests] = await Promise.all([ const [batches, harvests] = await Promise.all([
prisma.batch.findMany({ prisma.batch.findMany({
orderBy: { fillDate: "desc" }, orderBy: { fillDate: "desc" },
include: { harvest: true }, include: { harvest: true, _count: { select: { products: true, orderItems: true } } },
}), }),
prisma.harvest.findMany({ orderBy: [{ year: "desc" }] }), prisma.harvest.findMany({ orderBy: [{ year: "desc" }] }),
]); ]);
@@ -20,6 +22,9 @@ export default async function AdminBatchesPage() {
<div> <div>
<BackLink href="/admin" label={t.adminBatchesPage.backToDashboard} /> <BackLink href="/admin" label={t.adminBatchesPage.backToDashboard} />
<h1 className="text-2xl text-forest mb-8">{t.adminBatchesPage.title}</h1> <h1 className="text-2xl text-forest mb-8">{t.adminBatchesPage.title}</h1>
{error ? (
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
) : null}
<div className="overflow-x-auto bg-background border border-ink/10"> <div className="overflow-x-auto bg-background border border-ink/10">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead className="text-left text-ink/65 border-b border-ink/10"> <thead className="text-left text-ink/65 border-b border-ink/10">
@@ -44,10 +49,22 @@ export default async function AdminBatchesPage() {
<td className="p-4">{formatDate(batch.bestBeforeDate)}</td> <td className="p-4">{formatDate(batch.bestBeforeDate)}</td>
<td className="p-4">{batch.glassCount}</td> <td className="p-4">{batch.glassCount}</td>
<td className="p-4">{batch.remainingGlasses}</td> <td className="p-4">{batch.remainingGlasses}</td>
<td className="p-4"> <td className="p-4 whitespace-nowrap">
<Link href={`/admin/chargen/${batch.id}`} className="text-forest underline"> <Link href={`/admin/chargen/${batch.id}`} className="text-forest underline mr-4">
{t.adminBatchesPage.edit} {t.adminBatchesPage.edit}
</Link> </Link>
{batch._count.products === 0 && batch._count.orderItems === 0 ? (
<ConfirmForm
action={deleteBatchAction}
confirmMessage={t.adminBatchesPage.deleteConfirm(batch.code)}
className="inline"
>
<input type="hidden" name="id" value={batch.id} />
<button type="submit" className="text-red-700 underline">
{t.adminBatchesPage.delete}
</button>
</ConfirmForm>
) : null}
</td> </td>
</tr> </tr>
))} ))}
+24 -3
View File
@@ -1,14 +1,20 @@
import Link from "next/link"; import Link from "next/link";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { BackLink } from "@/components/back-link"; import { BackLink } from "@/components/back-link";
import { ConfirmForm } from "@/components/confirm-form";
import { formatDate } from "@/lib/format"; import { formatDate } from "@/lib/format";
import { createHarvestAction, toggleHarvestPublishedAction, syncHarvestsFromApiCoreAction } from "../actions"; import {
createHarvestAction,
toggleHarvestPublishedAction,
syncHarvestsFromApiCoreAction,
deleteHarvestAction,
} from "../actions";
import { getLocale } from "@/i18n/locale"; import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary"; import { getDictionary } from "@/i18n/get-dictionary";
export default async function AdminHarvestsPage(props: PageProps<"/admin/ernten">) { export default async function AdminHarvestsPage(props: PageProps<"/admin/ernten">) {
const t = getDictionary(await getLocale()); const t = getDictionary(await getLocale());
const { synced, updated, skipped, failed, syncError, batchSynced, batchSkipped, batchFailed, batchLoadError } = const { synced, updated, skipped, failed, syncError, batchSynced, batchSkipped, batchFailed, batchLoadError, error } =
await props.searchParams; await props.searchParams;
const harvests = await prisma.harvest.findMany({ const harvests = await prisma.harvest.findMany({
orderBy: [{ year: "desc" }, { harvestDate: "desc" }], orderBy: [{ year: "desc" }, { harvestDate: "desc" }],
@@ -20,6 +26,9 @@ export default async function AdminHarvestsPage(props: PageProps<"/admin/ernten"
<BackLink href="/admin" label={t.adminHarvestsPage.backToDashboard} /> <BackLink href="/admin" label={t.adminHarvestsPage.backToDashboard} />
<h1 className="text-2xl text-forest mb-8">{t.adminHarvestsPage.title}</h1> <h1 className="text-2xl text-forest mb-8">{t.adminHarvestsPage.title}</h1>
{error ? (
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
) : null}
{synced !== undefined ? ( {synced !== undefined ? (
<p className="mb-6 text-sm text-forest bg-forest/5 border border-forest/20 px-4 py-3"> <p className="mb-6 text-sm text-forest bg-forest/5 border border-forest/20 px-4 py-3">
{t.adminHarvestsPage.syncResult(Number(synced), Number(updated ?? 0), Number(skipped ?? 0), Number(failed ?? 0))} {t.adminHarvestsPage.syncResult(Number(synced), Number(updated ?? 0), Number(skipped ?? 0), Number(failed ?? 0))}
@@ -82,10 +91,22 @@ export default async function AdminHarvestsPage(props: PageProps<"/admin/ernten"
<form action={toggleHarvestPublishedAction} className="inline"> <form action={toggleHarvestPublishedAction} className="inline">
<input type="hidden" name="id" value={harvest.id} /> <input type="hidden" name="id" value={harvest.id} />
{harvest.isPublished ? null : <input type="hidden" name="isPublished" value="on" />} {harvest.isPublished ? null : <input type="hidden" name="isPublished" value="on" />}
<button type="submit" className="text-forest underline"> <button type="submit" className="text-forest underline mr-4">
{harvest.isPublished ? t.adminHarvestsPage.unpublish : t.adminHarvestsPage.publish} {harvest.isPublished ? t.adminHarvestsPage.unpublish : t.adminHarvestsPage.publish}
</button> </button>
</form> </form>
{harvest._count.batches === 0 && harvest._count.products === 0 ? (
<ConfirmForm
action={deleteHarvestAction}
confirmMessage={t.adminHarvestsPage.deleteConfirm(`${harvest.type} ${harvest.year}`)}
className="inline"
>
<input type="hidden" name="id" value={harvest.id} />
<button type="submit" className="text-red-700 underline">
{t.adminHarvestsPage.delete}
</button>
</ConfirmForm>
) : null}
</td> </td>
</tr> </tr>
))} ))}
+9 -1
View File
@@ -70,8 +70,16 @@ export function AdminImageField({ name, label, initialValue }: AdminImageFieldPr
accept="image/jpeg,image/png,image/webp,image/avif" accept="image/jpeg,image/png,image/webp,image/avif"
onChange={handleFileChange} onChange={handleFileChange}
disabled={status === "uploading"} disabled={status === "uploading"}
className="block text-xs" className="hidden"
/> />
<button
type="button"
onClick={() => inputRef.current?.click()}
disabled={status === "uploading"}
className="border border-ink/20 bg-background px-3 py-2 text-xs hover:border-forest disabled:opacity-50"
>
{t.adminImageField.chooseFile}
</button>
{status === "uploading" ? ( {status === "uploading" ? (
<p className="mt-1 text-xs text-ink/70">{t.adminImageField.uploading}</p> <p className="mt-1 text-xs text-ink/70">{t.adminImageField.uploading}</p>
) : null} ) : null}
+9 -1
View File
@@ -88,8 +88,16 @@ export function AdminImageListField({ name, label, initialValues }: AdminImageLi
accept="image/jpeg,image/png,image/webp,image/avif" accept="image/jpeg,image/png,image/webp,image/avif"
onChange={handleFileChange} onChange={handleFileChange}
disabled={status === "uploading"} disabled={status === "uploading"}
className="block text-xs" className="hidden"
/> />
<button
type="button"
onClick={() => inputRef.current?.click()}
disabled={status === "uploading"}
className="border border-ink/20 bg-background px-3 py-2 text-xs hover:border-forest disabled:opacity-50"
>
{t.adminImageField.chooseFile}
</button>
{status === "uploading" ? ( {status === "uploading" ? (
<p className="mt-1 text-xs text-ink/70">{t.adminImageField.uploading}</p> <p className="mt-1 text-xs text-ink/70">{t.adminImageField.uploading}</p>
) : null} ) : null}
+9 -1
View File
@@ -68,8 +68,16 @@ export function AdminVideoField({ name, label, initialValue }: AdminVideoFieldPr
accept="video/mp4,video/webm" accept="video/mp4,video/webm"
onChange={handleFileChange} onChange={handleFileChange}
disabled={status === "uploading"} disabled={status === "uploading"}
className="block text-xs" className="hidden"
/> />
<button
type="button"
onClick={() => inputRef.current?.click()}
disabled={status === "uploading"}
className="border border-ink/20 bg-background px-3 py-2 text-xs hover:border-forest disabled:opacity-50"
>
{t.adminVideoField.chooseFile}
</button>
{status === "uploading" ? ( {status === "uploading" ? (
<p className="mt-1 text-xs text-ink/70">{t.adminVideoField.uploading}</p> <p className="mt-1 text-xs text-ink/70">{t.adminVideoField.uploading}</p>
) : null} ) : null}
+6
View File
@@ -821,6 +821,8 @@ export const de: Dictionary = {
colTotalGlasses: "Gläser gesamt", colTotalGlasses: "Gläser gesamt",
colRemaining: "Verbleibend", colRemaining: "Verbleibend",
edit: "Bearbeiten", edit: "Bearbeiten",
delete: "Löschen",
deleteConfirm: (code) => `Charge "${code}" wirklich löschen? Dies kann nicht rückgängig gemacht werden.`,
noBatchesYet: "Noch keine Chargen angelegt.", noBatchesYet: "Noch keine Chargen angelegt.",
newBatchHeading: "Neue Charge anlegen", newBatchHeading: "Neue Charge anlegen",
codeLabel: "Chargen-Code", codeLabel: "Chargen-Code",
@@ -887,6 +889,8 @@ export const de: Dictionary = {
publish: "Veröffentlichen", publish: "Veröffentlichen",
unpublish: "Verbergen", unpublish: "Verbergen",
edit: "Bearbeiten", edit: "Bearbeiten",
delete: "Löschen",
deleteConfirm: (label) => `"${label}" wirklich löschen? Dies kann nicht rückgängig gemacht werden.`,
noHarvestsYet: "Noch keine Ernten angelegt.", noHarvestsYet: "Noch keine Ernten angelegt.",
newHarvestHeading: "Neue Ernte anlegen", newHarvestHeading: "Neue Ernte anlegen",
typeLabel: "Typ (z.B. Frühtracht)", typeLabel: "Typ (z.B. Frühtracht)",
@@ -1129,6 +1133,7 @@ export const de: Dictionary = {
}, },
adminImageField: { adminImageField: {
none: "Kein Bild", none: "Kein Bild",
chooseFile: "Datei auswählen",
uploading: "Wird hochgeladen…", uploading: "Wird hochgeladen…",
remove: "Bild entfernen", remove: "Bild entfernen",
removeImageAt: (position) => `Bild ${position} entfernen`, removeImageAt: (position) => `Bild ${position} entfernen`,
@@ -1136,6 +1141,7 @@ export const de: Dictionary = {
}, },
adminVideoField: { adminVideoField: {
none: "Kein Video", none: "Kein Video",
chooseFile: "Datei auswählen",
uploading: "Wird hochgeladen…", uploading: "Wird hochgeladen…",
remove: "Video entfernen", remove: "Video entfernen",
uploadError: "Der Upload ist fehlgeschlagen.", uploadError: "Der Upload ist fehlgeschlagen.",
+6
View File
@@ -821,6 +821,8 @@ export const en: Dictionary = {
colTotalGlasses: "Total jars", colTotalGlasses: "Total jars",
colRemaining: "Remaining", colRemaining: "Remaining",
edit: "Edit", edit: "Edit",
delete: "Delete",
deleteConfirm: (code) => `Really delete batch "${code}"? This cannot be undone.`,
noBatchesYet: "No batches created yet.", noBatchesYet: "No batches created yet.",
newBatchHeading: "Create new batch", newBatchHeading: "Create new batch",
codeLabel: "Batch code", codeLabel: "Batch code",
@@ -887,6 +889,8 @@ export const en: Dictionary = {
publish: "Publish", publish: "Publish",
unpublish: "Unpublish", unpublish: "Unpublish",
edit: "Edit", edit: "Edit",
delete: "Delete",
deleteConfirm: (label) => `Really delete "${label}"? This cannot be undone.`,
noHarvestsYet: "No harvests created yet.", noHarvestsYet: "No harvests created yet.",
newHarvestHeading: "Create new harvest", newHarvestHeading: "Create new harvest",
typeLabel: "Type (e.g. Spring flow)", typeLabel: "Type (e.g. Spring flow)",
@@ -1129,6 +1133,7 @@ export const en: Dictionary = {
}, },
adminImageField: { adminImageField: {
none: "No image", none: "No image",
chooseFile: "Choose file",
uploading: "Uploading…", uploading: "Uploading…",
remove: "Remove image", remove: "Remove image",
removeImageAt: (position) => `Remove image ${position}`, removeImageAt: (position) => `Remove image ${position}`,
@@ -1136,6 +1141,7 @@ export const en: Dictionary = {
}, },
adminVideoField: { adminVideoField: {
none: "No video", none: "No video",
chooseFile: "Choose file",
uploading: "Uploading…", uploading: "Uploading…",
remove: "Remove video", remove: "Remove video",
uploadError: "The upload failed.", uploadError: "The upload failed.",
+6
View File
@@ -523,6 +523,8 @@ export interface Dictionary {
colTotalGlasses: string; colTotalGlasses: string;
colRemaining: string; colRemaining: string;
edit: string; edit: string;
delete: string;
deleteConfirm: (code: string) => string;
noBatchesYet: string; noBatchesYet: string;
newBatchHeading: string; newBatchHeading: string;
codeLabel: string; codeLabel: string;
@@ -589,6 +591,8 @@ export interface Dictionary {
publish: string; publish: string;
unpublish: string; unpublish: string;
edit: string; edit: string;
delete: string;
deleteConfirm: (label: string) => string;
noHarvestsYet: string; noHarvestsYet: string;
newHarvestHeading: string; newHarvestHeading: string;
typeLabel: string; typeLabel: string;
@@ -830,6 +834,7 @@ export interface Dictionary {
* in admin/inhalte, admin/produkte, admin/ernten). */ * in admin/inhalte, admin/produkte, admin/ernten). */
adminImageField: { adminImageField: {
none: string; none: string;
chooseFile: string;
uploading: string; uploading: string;
remove: string; remove: string;
removeImageAt: (position: number) => string; removeImageAt: (position: number) => string;
@@ -839,6 +844,7 @@ export interface Dictionary {
* admin/inhalte). */ * admin/inhalte). */
adminVideoField: { adminVideoField: {
none: string; none: string;
chooseFile: string;
uploading: string; uploading: string;
remove: string; remove: string;
uploadError: string; uploadError: string;
+9 -7
View File
@@ -33,14 +33,16 @@ export const UPLOAD_DIR = path.resolve(/*turbopackIgnore: true*/ process.env.UPL
export const MAX_UPLOAD_BYTES = 8 * 1024 * 1024; export const MAX_UPLOAD_BYTES = 8 * 1024 * 1024;
/** /**
* 40 MB: ein kurzes (wenige Sekunden), fuer Web komprimiertes Hero-Video * 100 MB: deckt auch ein laengeres oder nicht web-optimiertes Hero-Video
* passt bequem darunter. Grosszuegiger als MAX_UPLOAD_BYTES (Bilder), weil * (z.B. direkt von einer Kamera/einem Handy exportiert, oft 40-80 MB) ab.
* Video-Dateien inhaerent groesser sind — Videos werden aber (anders als * Grosszuegiger als MAX_UPLOAD_BYTES (Bilder), weil Video-Dateien inhaerent
* Bilder) NICHT neu enkodiert (dafuer gibt es hier keine sharp-Entsprechung * groesser sind — Videos werden aber (anders als Bilder) NICHT neu enkodiert
* ohne eine ffmpeg-Abhaengigkeit), die Groessengrenze ist daher die einzige * (dafuer gibt es hier keine sharp-Entsprechung ohne eine ffmpeg-
* Bremse gegen uebermaessig grosse Uploads. * Abhaengigkeit), die Groessengrenze ist daher die einzige Bremse gegen
* uebermaessig grosse Uploads. Muss zum "proxy-body-size" in
* k8s/06-ingress.yaml passen (dort grosszuegiger bemessen).
*/ */
export const MAX_UPLOAD_VIDEO_BYTES = 40 * 1024 * 1024; export const MAX_UPLOAD_VIDEO_BYTES = 100 * 1024 * 1024;
export type AllowedImageExtension = "jpg" | "png" | "webp" | "avif"; export type AllowedImageExtension = "jpg" | "png" | "webp" | "avif";
export type AllowedVideoExtension = "mp4" | "webm"; export type AllowedVideoExtension = "mp4" | "webm";