mirror of
https://github.com/Since-3/since3-portal.git
synced 2026-10-12 20:51:17 +00:00
added efficenciy
This commit is contained in:
parent
bc9b0b925e
commit
7ac5713193
8 changed files with 168 additions and 38 deletions
49
app/api/documents/[id]/thumbnail/route.ts
Normal file
49
app/api/documents/[id]/thumbnail/route.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
import * as z from "zod"
|
||||||
|
import { getSession } from "@/lib/supabase/dal"
|
||||||
|
import { createClient } from "@/lib/supabase/server"
|
||||||
|
import { DOCUMENTS_BUCKET, MAX_THUMBNAIL_BYTES, previewKind } from "@/modules/shared/schemas/documents"
|
||||||
|
|
||||||
|
const notFound = () => new Response(null, { status: 404, headers: { "Cache-Control": "no-store" } })
|
||||||
|
|
||||||
|
// Feste URL pro Dokument statt ablaufender Signed URL: der Browser cached das Vorschaubild dauerhaft.
|
||||||
|
// Zugriff prüft RLS (Tabelle + Storage) bei jedem nicht gecachten Aufruf.
|
||||||
|
export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const user = await getSession()
|
||||||
|
if (!user) return new Response(null, { status: 401, headers: { "Cache-Control": "no-store" } })
|
||||||
|
|
||||||
|
const { id } = await params
|
||||||
|
if (!z.string().uuid().safeParse(id).success) return notFound()
|
||||||
|
|
||||||
|
const supabase = await createClient()
|
||||||
|
const { data: doc } = await supabase
|
||||||
|
.from("documents")
|
||||||
|
.select("storage_path, thumbnail_path, mime_type")
|
||||||
|
.eq("id", id)
|
||||||
|
.maybeSingle()
|
||||||
|
if (!doc) return notFound()
|
||||||
|
|
||||||
|
if (doc.thumbnail_path) {
|
||||||
|
const { data: blob, error } = await supabase.storage.from(DOCUMENTS_BUCKET).download(doc.thumbnail_path)
|
||||||
|
if (error || !blob || blob.size > MAX_THUMBNAIL_BYTES) return notFound()
|
||||||
|
|
||||||
|
return new Response(blob, {
|
||||||
|
headers: {
|
||||||
|
// Inhalt stammt vom Nutzer und wird von unserer Domain ausgeliefert: Typ fest setzen, Sniffing und Skripte aus.
|
||||||
|
"Content-Type": "image/jpeg",
|
||||||
|
"X-Content-Type-Options": "nosniff",
|
||||||
|
"Content-Security-Policy": "default-src 'none'; sandbox",
|
||||||
|
"Cache-Control": "private, max-age=31536000, immutable",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ältere Bilder ohne Vorschaubild: kurz gültige Weiterleitung aufs Original, bewusst nicht gecached.
|
||||||
|
if (previewKind(doc.mime_type) === "image") {
|
||||||
|
const { data } = await supabase.storage.from(DOCUMENTS_BUCKET).createSignedUrl(doc.storage_path, 5 * 60)
|
||||||
|
if (data?.signedUrl) {
|
||||||
|
return new Response(null, { status: 302, headers: { Location: data.signedUrl, "Cache-Control": "no-store" } })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return notFound()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
-- Kleine Vorschaubilder (JPEG, im Browser beim Upload erzeugt) neben dem Original
|
||||||
|
alter table public.documents
|
||||||
|
add column if not exists thumbnail_path text;
|
||||||
|
|
||||||
|
alter table public.documents
|
||||||
|
add constraint documents_thumbnail_path_matches_client
|
||||||
|
check (thumbnail_path is null or split_part(thumbnail_path, '/', 1) = client_id::text);
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
import * as z from "zod";
|
import * as z from "zod";
|
||||||
import { createClient } from "@/lib/supabase/server";
|
import { createClient } from "@/lib/supabase/server";
|
||||||
|
|
||||||
|
|
@ -49,11 +50,14 @@ export async function login(
|
||||||
return { error: "Profil konnte nicht geladen werden. Bitte kontaktiere den Support." }
|
return { error: "Profil konnte nicht geladen werden. Bitte kontaktiere den Support." }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Zwischengespeicherte Seiten eines vorherigen Nutzers im selben Tab verwerfen.
|
||||||
|
revalidatePath("/", "layout")
|
||||||
redirect(profile.role === "admin" ? "/admin/dashboard" : "/client/dashboard")
|
redirect(profile.role === "admin" ? "/admin/dashboard" : "/client/dashboard")
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function logout() {
|
export async function logout() {
|
||||||
const supabase = await createClient();
|
const supabase = await createClient();
|
||||||
await supabase.auth.signOut();
|
await supabase.auth.signOut();
|
||||||
|
revalidatePath("/", "layout");
|
||||||
redirect("/");
|
redirect("/");
|
||||||
}
|
}
|
||||||
|
|
@ -9,6 +9,7 @@ import {
|
||||||
DOCUMENTS_BUCKET,
|
DOCUMENTS_BUCKET,
|
||||||
FolderNameSchema,
|
FolderNameSchema,
|
||||||
MAX_FILES_PER_UPLOAD,
|
MAX_FILES_PER_UPLOAD,
|
||||||
|
MAX_THUMBNAIL_BYTES,
|
||||||
UploadFileSchema,
|
UploadFileSchema,
|
||||||
} from "@/modules/shared/schemas/documents";
|
} from "@/modules/shared/schemas/documents";
|
||||||
|
|
||||||
|
|
@ -95,7 +96,7 @@ async function assertFolder(clientId: string, folderId: string) {
|
||||||
return Boolean(data);
|
return Boolean(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PreparedUpload = { documentId: string; path: string };
|
export type PreparedUpload = { documentId: string; path: string; thumbnailPath: string };
|
||||||
|
|
||||||
// Schritt 1: Pfade vergeben. Die Dateien lädt der Browser direkt in den Storage (Vercel-Limit ~4,5 MB pro Request).
|
// Schritt 1: Pfade vergeben. Die Dateien lädt der Browser direkt in den Storage (Vercel-Limit ~4,5 MB pro Request).
|
||||||
export async function prepareUpload(
|
export async function prepareUpload(
|
||||||
|
|
@ -118,25 +119,45 @@ export async function prepareUpload(
|
||||||
return {
|
return {
|
||||||
uploads: files.map((file) => {
|
uploads: files.map((file) => {
|
||||||
const documentId = crypto.randomUUID();
|
const documentId = crypto.randomUUID();
|
||||||
return { documentId, path: `${access.clientId}/${documentId}/${safeFileName(file.name)}` };
|
return {
|
||||||
|
documentId,
|
||||||
|
path: `${access.clientId}/${documentId}/${safeFileName(file.name)}`,
|
||||||
|
thumbnailPath: thumbnailPathFor(access.clientId, documentId),
|
||||||
|
};
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Eigener Unterordner: kann nie mit einem hochgeladenen Dateinamen kollidieren (safeFileName enthält kein "/").
|
||||||
|
function thumbnailPathFor(clientId: string, documentId: string) {
|
||||||
|
return `${clientId}/${documentId}/thumbnail/preview.jpg`;
|
||||||
|
}
|
||||||
|
|
||||||
const FinalizeSchema = z
|
const FinalizeSchema = z
|
||||||
.array(UploadFileSchema.extend({ documentId: z.string().uuid(), path: z.string().min(1).max(400) }))
|
.array(
|
||||||
|
UploadFileSchema.extend({
|
||||||
|
documentId: z.string().uuid(),
|
||||||
|
path: z.string().min(1).max(400),
|
||||||
|
hasThumbnail: z.boolean(),
|
||||||
|
})
|
||||||
|
)
|
||||||
.min(1)
|
.min(1)
|
||||||
.max(MAX_FILES_PER_UPLOAD);
|
.max(MAX_FILES_PER_UPLOAD);
|
||||||
|
|
||||||
// Nur Dateien ohne Dokument-Datensatz entfernen – sonst könnte ein Aufruf mit dem Pfad einer
|
// Nur Dateien ohne Dokument-Datensatz entfernen – sonst könnte ein Aufruf mit dem Pfad einer
|
||||||
// bestehenden Datei (Konflikt beim Insert) genau diese Datei löschen. Ohne Gewissheit: nichts löschen.
|
// bestehenden Datei (Konflikt beim Insert) genau diese Datei löschen. Ohne Gewissheit: nichts löschen.
|
||||||
async function removeOrphanedObjects(paths: string[]) {
|
async function removeOrphanedObjects(clientId: string, uploads: { documentId: string; path: string }[]) {
|
||||||
const supabase = await createClient();
|
const supabase = await createClient();
|
||||||
const { data: existing, error } = await supabase.from("documents").select("storage_path").in("storage_path", paths);
|
const { data: existing, error } = await supabase
|
||||||
|
.from("documents")
|
||||||
|
.select("storage_path")
|
||||||
|
.in("storage_path", uploads.map((upload) => upload.path));
|
||||||
if (error) return;
|
if (error) return;
|
||||||
|
|
||||||
const referenced = new Set((existing ?? []).map((row) => row.storage_path as string));
|
const referenced = new Set((existing ?? []).map((row) => row.storage_path as string));
|
||||||
const orphaned = paths.filter((path) => !referenced.has(path));
|
const orphaned = uploads
|
||||||
|
.filter((upload) => !referenced.has(upload.path))
|
||||||
|
.flatMap((upload) => [upload.path, thumbnailPathFor(clientId, upload.documentId)]);
|
||||||
if (orphaned.length > 0) await supabase.storage.from(DOCUMENTS_BUCKET).remove(orphaned);
|
if (orphaned.length > 0) await supabase.storage.from(DOCUMENTS_BUCKET).remove(orphaned);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -144,7 +165,7 @@ async function removeOrphanedObjects(paths: string[]) {
|
||||||
export async function finalizeUpload(
|
export async function finalizeUpload(
|
||||||
profileId: string,
|
profileId: string,
|
||||||
folderId: string,
|
folderId: string,
|
||||||
uploads: { documentId: string; path: string; name: string; size: number; type: string }[]
|
uploads: { documentId: string; path: string; name: string; size: number; type: string; hasThumbnail: boolean }[]
|
||||||
): Promise<DocumentActionState> {
|
): Promise<DocumentActionState> {
|
||||||
const access = await resolveAccess(profileId);
|
const access = await resolveAccess(profileId);
|
||||||
if ("error" in access) return access;
|
if ("error" in access) return access;
|
||||||
|
|
@ -165,7 +186,19 @@ export async function finalizeUpload(
|
||||||
const stored = await Promise.all(
|
const stored = await Promise.all(
|
||||||
parsed.data.map(async (upload) => {
|
parsed.data.map(async (upload) => {
|
||||||
const { data, error } = await supabase.storage.from(DOCUMENTS_BUCKET).info(upload.path);
|
const { data, error } = await supabase.storage.from(DOCUMENTS_BUCKET).info(upload.path);
|
||||||
return error || !data ? null : { upload, size: data.size ?? null, contentType: data.contentType ?? null };
|
if (error || !data) return null;
|
||||||
|
|
||||||
|
// Vorschaubild ist optional: fehlt es, wird einfach keins gespeichert.
|
||||||
|
const thumbnailPath = thumbnailPathFor(access.clientId, upload.documentId);
|
||||||
|
const thumbnail = upload.hasThumbnail ? await supabase.storage.from(DOCUMENTS_BUCKET).info(thumbnailPath) : null;
|
||||||
|
const hasThumbnail = Boolean(thumbnail?.data && (thumbnail.data.size ?? 0) <= MAX_THUMBNAIL_BYTES);
|
||||||
|
|
||||||
|
return {
|
||||||
|
upload,
|
||||||
|
size: data.size ?? null,
|
||||||
|
contentType: data.contentType ?? null,
|
||||||
|
thumbnailPath: hasThumbnail ? thumbnailPath : null,
|
||||||
|
};
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
const verified = stored.filter((entry): entry is NonNullable<typeof entry> => entry !== null);
|
const verified = stored.filter((entry): entry is NonNullable<typeof entry> => entry !== null);
|
||||||
|
|
@ -180,12 +213,13 @@ export async function finalizeUpload(
|
||||||
storage_path: entry.upload.path,
|
storage_path: entry.upload.path,
|
||||||
mime_type: entry.contentType,
|
mime_type: entry.contentType,
|
||||||
size_bytes: entry.size,
|
size_bytes: entry.size,
|
||||||
|
thumbnail_path: entry.thumbnailPath,
|
||||||
uploaded_by: session.userId,
|
uploaded_by: session.userId,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
await removeOrphanedObjects(parsed.data.map((upload) => upload.path));
|
await removeOrphanedObjects(access.clientId, parsed.data);
|
||||||
return { error: "Dateien konnten nicht gespeichert werden." };
|
return { error: "Dateien konnten nicht gespeichert werden." };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -237,11 +271,12 @@ export async function deleteDocument(profileId: string, documentId: string): Pro
|
||||||
.delete()
|
.delete()
|
||||||
.eq("id", documentId)
|
.eq("id", documentId)
|
||||||
.eq("client_id", access.clientId)
|
.eq("client_id", access.clientId)
|
||||||
.select("storage_path");
|
.select("storage_path, thumbnail_path");
|
||||||
|
|
||||||
if (error || !data?.length) return { error: "Datei konnte nicht gelöscht werden." };
|
if (error || !data?.length) return { error: "Datei konnte nicht gelöscht werden." };
|
||||||
|
|
||||||
const { error: storageError } = await supabase.storage.from(DOCUMENTS_BUCKET).remove([data[0].storage_path]);
|
const paths = [data[0].storage_path, data[0].thumbnail_path].filter((path): path is string => Boolean(path));
|
||||||
|
const { error: storageError } = await supabase.storage.from(DOCUMENTS_BUCKET).remove(paths);
|
||||||
|
|
||||||
revalidateDocuments(profileId);
|
revalidateDocuments(profileId);
|
||||||
if (storageError) return { error: "Eintrag gelöscht, die Datei selbst konnte aber nicht entfernt werden." };
|
if (storageError) return { error: "Eintrag gelöscht, die Datei selbst konnte aber nicht entfernt werden." };
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
import "server-only"
|
import "server-only"
|
||||||
import { createClient } from "@/lib/supabase/server"
|
import { createClient } from "@/lib/supabase/server"
|
||||||
import { DOCUMENTS_BUCKET, previewKind, type DocumentItem, type Folder } from "@/modules/shared/schemas/documents"
|
import { previewKind, type DocumentItem, type Folder } from "@/modules/shared/schemas/documents"
|
||||||
|
|
||||||
const THUMBNAIL_TTL_SECONDS = 60 * 60
|
|
||||||
|
|
||||||
export async function getFolders(clientId: string): Promise<Folder[]> {
|
export async function getFolders(clientId: string): Promise<Folder[]> {
|
||||||
const supabase = await createClient()
|
const supabase = await createClient()
|
||||||
|
|
@ -39,33 +37,21 @@ export async function getDocuments(clientId: string, folderId: string): Promise<
|
||||||
const supabase = await createClient()
|
const supabase = await createClient()
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from("documents")
|
.from("documents")
|
||||||
.select("id, name, mime_type, size_bytes, created_at, storage_path")
|
.select("id, name, mime_type, size_bytes, created_at, thumbnail_path")
|
||||||
.eq("client_id", clientId)
|
.eq("client_id", clientId)
|
||||||
.eq("folder_id", folderId)
|
.eq("folder_id", folderId)
|
||||||
.order("created_at", { ascending: false })
|
.order("created_at", { ascending: false })
|
||||||
|
|
||||||
if (error) throw new Error("Dokumente konnten nicht geladen werden.")
|
if (error) throw new Error("Dokumente konnten nicht geladen werden.")
|
||||||
|
|
||||||
const imagePaths = data
|
|
||||||
.filter((row) => previewKind(row.mime_type) === "image")
|
|
||||||
.map((row) => row.storage_path as string)
|
|
||||||
|
|
||||||
const thumbnails = new Map<string, string>()
|
|
||||||
if (imagePaths.length > 0) {
|
|
||||||
const { data: signed } = await supabase.storage
|
|
||||||
.from(DOCUMENTS_BUCKET)
|
|
||||||
.createSignedUrls(imagePaths, THUMBNAIL_TTL_SECONDS)
|
|
||||||
for (const entry of signed ?? []) {
|
|
||||||
if (entry.path && entry.signedUrl) thumbnails.set(entry.path, entry.signedUrl)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return data.map((row) => ({
|
return data.map((row) => ({
|
||||||
id: row.id,
|
id: row.id,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
mimeType: row.mime_type,
|
mimeType: row.mime_type,
|
||||||
sizeBytes: row.size_bytes === null ? null : Number(row.size_bytes),
|
sizeBytes: row.size_bytes === null ? null : Number(row.size_bytes),
|
||||||
createdAt: row.created_at,
|
createdAt: row.created_at,
|
||||||
thumbnailUrl: thumbnails.get(row.storage_path) ?? null,
|
// Feste URL (nicht ablaufend), damit der Browser das Vorschaubild dauerhaft cachen kann.
|
||||||
|
thumbnailUrl:
|
||||||
|
row.thumbnail_path || previewKind(row.mime_type) === "image" ? `/api/documents/${row.id}/thumbnail` : null,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@ import * as z from "zod";
|
||||||
export const MAX_FILE_BYTES = 50 * 1024 * 1024;
|
export const MAX_FILE_BYTES = 50 * 1024 * 1024;
|
||||||
export const MAX_FILES_PER_UPLOAD = 20;
|
export const MAX_FILES_PER_UPLOAD = 20;
|
||||||
export const DOCUMENTS_BUCKET = "client-documents";
|
export const DOCUMENTS_BUCKET = "client-documents";
|
||||||
|
export const MAX_THUMBNAIL_BYTES = 512 * 1024;
|
||||||
|
export const THUMBNAIL_MAX_EDGE = 480;
|
||||||
|
|
||||||
export type Folder = {
|
export type Folder = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,8 @@ import {
|
||||||
DOCUMENTS_BUCKET,
|
DOCUMENTS_BUCKET,
|
||||||
MAX_FILES_PER_UPLOAD,
|
MAX_FILES_PER_UPLOAD,
|
||||||
MAX_FILE_BYTES,
|
MAX_FILE_BYTES,
|
||||||
|
MAX_THUMBNAIL_BYTES,
|
||||||
|
THUMBNAIL_MAX_EDGE,
|
||||||
fileKind,
|
fileKind,
|
||||||
previewKind,
|
previewKind,
|
||||||
formatFileSize,
|
formatFileSize,
|
||||||
|
|
@ -45,6 +47,32 @@ function uniqueName(name: string, used: Set<string>) {
|
||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const THUMBNAIL_SOURCE_TYPES = new Set(["image/png", "image/jpeg", "image/webp", "image/gif"]);
|
||||||
|
|
||||||
|
// Kleines JPEG (max. 480 px) statt des Originals als Vorschaubild. JPEG, weil Safari WebP per Canvas nicht zuverlässig erzeugt.
|
||||||
|
async function createThumbnail(file: File): Promise<Blob | null> {
|
||||||
|
if (!THUMBNAIL_SOURCE_TYPES.has(file.type)) return null;
|
||||||
|
try {
|
||||||
|
const bitmap = await createImageBitmap(file, { imageOrientation: "from-image" });
|
||||||
|
const scale = Math.min(1, THUMBNAIL_MAX_EDGE / Math.max(bitmap.width, bitmap.height));
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = Math.max(1, Math.round(bitmap.width * scale));
|
||||||
|
canvas.height = Math.max(1, Math.round(bitmap.height * scale));
|
||||||
|
|
||||||
|
const context = canvas.getContext("2d");
|
||||||
|
if (!context) return null;
|
||||||
|
context.fillStyle = "#ffffff";
|
||||||
|
context.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
context.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
|
||||||
|
bitmap.close();
|
||||||
|
|
||||||
|
const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, "image/jpeg", 0.8));
|
||||||
|
return blob && blob.size <= MAX_THUMBNAIL_BYTES ? blob : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function runLimited<T>(items: T[], limit: number, worker: (item: T) => Promise<void>) {
|
async function runLimited<T>(items: T[], limit: number, worker: (item: T) => Promise<void>) {
|
||||||
let index = 0;
|
let index = 0;
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
|
|
@ -249,16 +277,27 @@ export function DocumentsBrowser({
|
||||||
|
|
||||||
const supabase = createClient();
|
const supabase = createClient();
|
||||||
const jobs = prepared.uploads.map((target, index) => ({ target, file: files[index] }));
|
const jobs = prepared.uploads.map((target, index) => ({ target, file: files[index] }));
|
||||||
const succeeded: typeof jobs = [];
|
const succeeded: (typeof jobs[number] & { hasThumbnail: boolean })[] = [];
|
||||||
const failed: string[] = [];
|
const failed: string[] = [];
|
||||||
setUpload({ done: 0, total: jobs.length });
|
setUpload({ done: 0, total: jobs.length });
|
||||||
|
|
||||||
await runLimited(jobs, PARALLEL_UPLOADS, async (job) => {
|
await runLimited(jobs, PARALLEL_UPLOADS, async (job) => {
|
||||||
const { error } = await supabase.storage
|
const bucket = supabase.storage.from(DOCUMENTS_BUCKET);
|
||||||
.from(DOCUMENTS_BUCKET)
|
const { error } = await bucket.upload(job.target.path, job.file, {
|
||||||
.upload(job.target.path, job.file, { contentType: job.file.type || undefined, upsert: false });
|
contentType: job.file.type || undefined,
|
||||||
if (error) failed.push(job.file.name);
|
upsert: false,
|
||||||
else succeeded.push(job);
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
failed.push(job.file.name);
|
||||||
|
} else {
|
||||||
|
// Vorschaubild ist nur Komfort: schlägt es fehl, bleibt das Original trotzdem hochgeladen.
|
||||||
|
const thumbnail = await createThumbnail(job.file);
|
||||||
|
const thumbnailUploaded = thumbnail
|
||||||
|
? !(await bucket.upload(job.target.thumbnailPath, thumbnail, { contentType: "image/jpeg", upsert: false })).error
|
||||||
|
: false;
|
||||||
|
succeeded.push({ ...job, hasThumbnail: thumbnailUploaded });
|
||||||
|
}
|
||||||
setUpload((current) => (current ? { ...current, done: current.done + 1 } : current));
|
setUpload((current) => (current ? { ...current, done: current.done + 1 } : current));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -266,12 +305,13 @@ export function DocumentsBrowser({
|
||||||
const result = await finalizeUpload(
|
const result = await finalizeUpload(
|
||||||
profileId,
|
profileId,
|
||||||
folderId,
|
folderId,
|
||||||
succeeded.map(({ target, file }) => ({
|
succeeded.map(({ target, file, hasThumbnail }) => ({
|
||||||
documentId: target.documentId,
|
documentId: target.documentId,
|
||||||
path: target.path,
|
path: target.path,
|
||||||
name: file.name,
|
name: file.name,
|
||||||
size: file.size,
|
size: file.size,
|
||||||
type: file.type,
|
type: file.type,
|
||||||
|
hasThumbnail,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
if (result && "error" in result) failed.push(...succeeded.map((job) => job.file.name));
|
if (result && "error" in result) failed.push(...succeeded.map((job) => job.file.name));
|
||||||
|
|
@ -422,7 +462,7 @@ export function DocumentsBrowser({
|
||||||
className="flex flex-col text-left outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
className="flex flex-col text-left outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
title={doc.name}
|
title={doc.name}
|
||||||
>
|
>
|
||||||
<div className="flex aspect-[4/3] items-center justify-center bg-surface">
|
<div className="flex aspect-4/3 items-center justify-center bg-surface">
|
||||||
{doc.thumbnailUrl ? (
|
{doc.thumbnailUrl ? (
|
||||||
// eslint-disable-next-line @next/next/no-img-element -- signierte, kurzlebige Storage-URL
|
// eslint-disable-next-line @next/next/no-img-element -- signierte, kurzlebige Storage-URL
|
||||||
<img src={doc.thumbnailUrl} alt="" loading="lazy" className="size-full object-cover" />
|
<img src={doc.thumbnailUrl} alt="" loading="lazy" className="size-full object-cover" />
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,13 @@ const nextConfig: NextConfig = {
|
||||||
outputFileTracingIncludes: {
|
outputFileTracingIncludes: {
|
||||||
"/api/invoices/*": ["./modules/shared/pdf/assets/**/*"],
|
"/api/invoices/*": ["./modules/shared/pdf/assets/**/*"],
|
||||||
},
|
},
|
||||||
|
experimental: {
|
||||||
|
// Bereits besuchte Seiten 5 Min. im Browser halten. Eigene Änderungen leeren den Cache sofort
|
||||||
|
// (revalidatePath in den Actions), ebenso Login/Logout (Cookie-Änderung).
|
||||||
|
staleTimes: {
|
||||||
|
dynamic: 300,
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue