Compare commits
3
Commits
1df62f20c8
...
a3b11cc8e2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a3b11cc8e2 | ||
|
|
c7286041a1 | ||
|
|
6a815e85fe |
No files matched your search
+5
-5
@@ -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:
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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
@@ -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";
|
||||||
|
|||||||
Reference in new issue
Block a user