feat: add FilesBlock interface and component integration
Deploy / verify (push) Successful in 46s
Deploy / deploy (push) Successful in 1m8s

- Introduced FilesBlockItem and FilesBlockData interfaces to support file handling in the CMS.
- Integrated FilesBlock component into ContentRows.svelte for rendering file blocks.
- Updated block type handling to include support for files, enhancing content versatility.
This commit is contained in:
Peter Meier
2026-04-22 10:23:20 +02:00
parent bedfe597ab
commit 5b5ec7c659
6 changed files with 481 additions and 0 deletions
+23
View File
@@ -119,6 +119,29 @@ export interface ImageGalleryBlockData {
layout?: BlockLayout;
}
/** Einzelne Datei in einem files-Block (CMS file-Field). */
export interface FilesBlockItem {
_type?: "file";
src?: string;
title?: string;
description?: string;
/** Aus Sidecar gemerged (size in Bytes). */
size?: number;
mime?: string;
filename?: string;
}
/** Datei-Block (_type: "files"). items = Array von file-Feldern. */
export interface FilesBlockData {
_type?: "files";
_slug?: string;
headline?: string;
description?: string;
items?: Array<string | FilesBlockItem>;
forceDownload?: boolean;
layout?: BlockLayout;
}
/** YouTube-Video (_type: "youtube_video"). */
export interface YoutubeVideoBlockData {
_type?: "youtube_video";
+4
View File
@@ -5,6 +5,7 @@
import IframeBlock from "./blocks/IframeBlock.svelte";
import ImageBlock from "./blocks/ImageBlock.svelte";
import ImageGalleryBlock from "./blocks/ImageGalleryBlock.svelte";
import FilesBlock from "./blocks/FilesBlock.svelte";
import YoutubeVideoBlock from "./blocks/YoutubeVideoBlock.svelte";
import QuoteBlock from "./blocks/QuoteBlock.svelte";
import QuoteCarouselBlock from "./blocks/QuoteCarouselBlock.svelte";
@@ -27,6 +28,7 @@
IframeBlockData,
ImageBlockData,
ImageGalleryBlockData,
FilesBlockData,
YoutubeVideoBlockData,
QuoteBlockData,
QuoteCarouselBlockData,
@@ -112,6 +114,8 @@
<ImageBlock block={item as ImageBlockData} />
{:else if blockType(item) === "image_gallery"}
<ImageGalleryBlock block={item as ImageGalleryBlockData} />
{:else if blockType(item) === "files"}
<FilesBlock block={item as FilesBlockData} />
{:else if blockType(item) === "youtube_video"}
<YoutubeVideoBlock block={item as YoutubeVideoBlockData} />
{:else if blockType(item) === "quote"}
+126
View File
@@ -0,0 +1,126 @@
<script lang="ts">
import Icon from "@iconify/svelte";
import "$lib/iconify-offline";
import { getBlockLayoutClasses } from "$lib/block-layout";
import type { FilesBlockData } from "$lib/block-types";
import {
extractCmsFileField,
fileExtFromUrl,
formatFileSize,
getCmsFileUrl,
} from "$lib/rusty-file";
let { block }: { block: FilesBlockData } = $props();
const layoutClasses = $derived(
block.layout ? getBlockLayoutClasses(block.layout) : "col-span-12 mb-4",
);
type Resolved = {
href: string;
label: string;
description?: string;
sizeLabel: string | null;
ext: string | null;
iconName: string;
};
function iconForExt(ext: string | null): string {
switch (ext) {
case "pdf":
return "mdi:file-pdf-box";
case "doc":
case "docx":
case "rtf":
case "odt":
return "mdi:file-word-box";
case "xls":
case "xlsx":
case "ods":
case "csv":
return "mdi:file-excel-box";
case "ppt":
case "pptx":
return "mdi:file-powerpoint-box";
case "zip":
case "tar":
case "gz":
case "7z":
return "mdi:folder-zip";
case "jpg":
case "jpeg":
case "png":
case "webp":
case "gif":
case "svg":
return "mdi:file-image";
case "txt":
case "md":
return "mdi:file-document-outline";
default:
return "mdi:file-outline";
}
}
const resolved = $derived(
((block.items ?? []) as unknown[])
.map((raw): Resolved | null => {
const field = extractCmsFileField(raw);
if (!field) return null;
const filename = field.filename ?? field.url.split("/").pop();
const href = getCmsFileUrl(field.url, {
filename,
download: !!block.forceDownload,
});
const ext = fileExtFromUrl(field.url);
const label = field.title ?? filename ?? field.url;
return {
href,
label,
description: field.description,
sizeLabel: formatFileSize(field.size),
ext,
iconName: iconForExt(ext),
};
})
.filter((r): r is Resolved => r !== null),
);
</script>
<div class="files-block {layoutClasses}" data-block-type="files" data-block-slug={block._slug}>
{#if block.headline}
<h3 class="mb-3 text-lg font-semibold">{block.headline}</h3>
{/if}
{#if block.description}
<p class="mb-3 text-sm text-zinc-700">{block.description}</p>
{/if}
{#if resolved.length === 0}
<p class="text-sm text-zinc-500">Keine Dateien verfügbar.</p>
{:else}
<ul class="flex flex-col gap-2">
{#each resolved as f}
<li>
<a
href={f.href}
class="group flex items-start gap-3 rounded-md border border-zinc-200 bg-white px-3 py-2 hover:border-zinc-400 hover:bg-zinc-50 transition-colors"
target="_blank"
rel="noopener noreferrer"
>
<Icon icon={f.iconName} class="size-6 shrink-0 text-zinc-600" aria-hidden="true" />
<span class="flex flex-col min-w-0">
<span class="font-medium text-zinc-900 group-hover:underline truncate">{f.label}</span>
{#if f.description}
<span class="text-sm text-zinc-600">{f.description}</span>
{/if}
<span class="text-xs text-zinc-500">
{#if f.ext}<span class="uppercase">{f.ext}</span>{/if}
{#if f.ext && f.sizeLabel} · {/if}
{#if f.sizeLabel}{f.sizeLabel}{/if}
</span>
</span>
</a>
</li>
{/each}
</ul>
{/if}
</div>
+128
View File
@@ -0,0 +1,128 @@
/**
* Persistenter Disk-Cache für CMS-Dateien (PDF, DOCX, ...).
* Analog zu image-cache.server.ts: Datei einmal vom CMS holen, auf Disk
* speichern, danach direkt von dort ausliefern.
*/
import { createHash } from 'node:crypto';
import { existsSync, mkdirSync } from 'node:fs';
import { readFile, readdir, writeFile } from 'node:fs/promises';
import { extname, join } from 'node:path';
const CACHE_DIR = process.env.FILE_CACHE_DIR?.trim() || '.cache/files';
const MAX_MEMORY_ENTRIES = Number(process.env.FILE_CACHE_MAX_MEMORY) || 50;
export interface CachedFile {
buffer: Buffer;
contentType: string;
ext: string;
}
const memoryCache = new Map<string, CachedFile>();
let resolvedDir: string | null = null;
function ensureCacheDir(): string {
const dir = resolvedDir ?? join(process.cwd(), CACHE_DIR);
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
resolvedDir = dir;
return dir;
}
export function buildFileCacheKey(src: string): string {
return createHash('sha256').update(src).digest('hex').slice(0, 24);
}
const EXT_TO_CT: Record<string, string> = {
pdf: 'application/pdf',
doc: 'application/msword',
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
xls: 'application/vnd.ms-excel',
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
ppt: 'application/vnd.ms-powerpoint',
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
odt: 'application/vnd.oasis.opendocument.text',
ods: 'application/vnd.oasis.opendocument.spreadsheet',
zip: 'application/zip',
txt: 'text/plain; charset=utf-8',
csv: 'text/csv; charset=utf-8',
json: 'application/json',
xml: 'application/xml',
rtf: 'application/rtf',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
png: 'image/png',
webp: 'image/webp',
gif: 'image/gif',
svg: 'image/svg+xml',
};
export function contentTypeFromExt(ext: string): string {
return EXT_TO_CT[ext.toLowerCase()] ?? 'application/octet-stream';
}
export function extFromContentType(ct: string): string | null {
const lower = ct.toLowerCase();
for (const [ext, mime] of Object.entries(EXT_TO_CT)) {
if (lower.startsWith(mime.split(';')[0])) return ext;
}
return null;
}
export function extFromSrc(src: string): string | null {
try {
const path = src.split('?')[0].split('#')[0];
const e = extname(path).slice(1).toLowerCase();
return e || null;
} catch {
return null;
}
}
function rememberInMemory(key: string, cached: CachedFile): CachedFile {
if (memoryCache.size >= MAX_MEMORY_ENTRIES) {
const oldest = memoryCache.keys().next().value;
if (oldest !== undefined) memoryCache.delete(oldest);
}
memoryCache.set(key, cached);
return cached;
}
export async function getCachedFile(key: string): Promise<CachedFile | null> {
const mem = memoryCache.get(key);
if (mem) return mem;
const dir = ensureCacheDir();
try {
const files = await readdir(dir);
const prefix = `${key}.`;
const name = files.find((f) => f.startsWith(prefix));
if (!name) return null;
const buf = await readFile(join(dir, name));
const ext = name.slice(prefix.length);
return rememberInMemory(key, {
buffer: buf,
contentType: contentTypeFromExt(ext),
ext,
});
} catch {
return null;
}
}
export async function cacheFile(
key: string,
buffer: ArrayBuffer | Buffer,
contentType: string,
srcHint?: string,
): Promise<CachedFile> {
const dir = ensureCacheDir();
const buf = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer);
const ext =
extFromContentType(contentType) ??
(srcHint ? extFromSrc(srcHint) : null) ??
'bin';
const cached: CachedFile = { buffer: buf, contentType, ext };
rememberInMemory(key, cached);
await writeFile(join(dir, `${key}.${ext}`), buf);
return cached;
}
+97
View File
@@ -0,0 +1,97 @@
/**
* Datei-URLs für SvelteKit: /cms-files mit persistentem Server-Disk-Cache.
* Analog zu rusty-image.ts, aber für generische Dateien (PDF, DOCX, ...).
*/
export interface CmsFileField {
url: string;
title?: string;
description?: string;
size?: number;
mime?: string;
filename?: string;
}
/**
* Extrahiert URL/Title/Description aus einem CMS-File-Field
* (string oder { src, title?, description?, mime?, size?, ... }).
*/
export function extractCmsFileField(field: unknown): CmsFileField | null {
if (typeof field === 'string') {
const s = field.trim();
if (!s) return null;
return { url: s.startsWith('//') ? `https:${s}` : s };
}
if (field && typeof field === 'object') {
const o = field as {
src?: string;
file?: { url?: string };
title?: string;
description?: string;
size?: number;
mime?: string;
filename?: string;
};
const rawUrl =
typeof o.src === 'string' && o.src.trim()
? o.src
: typeof o.file?.url === 'string' && o.file.url.trim()
? o.file.url
: undefined;
if (!rawUrl) return null;
const url = rawUrl.startsWith('//') ? `https:${rawUrl}` : rawUrl;
return {
url,
...(o.title ? { title: o.title } : {}),
...(o.description ? { description: o.description } : {}),
...(typeof o.size === 'number' ? { size: o.size } : {}),
...(o.mime ? { mime: o.mime } : {}),
...(o.filename ? { filename: o.filename } : {}),
};
}
return null;
}
export interface CmsFileUrlOptions {
/** Filename hint for Content-Disposition header. */
filename?: string;
/** Force download (Content-Disposition: attachment). */
download?: boolean;
}
/**
* Lokale Datei-URL: /cms-files?src=…&name=…&dl=…
* `src` = volle Datei-URL oder CMS-Asset-Pfad (`/api/assets/...`).
*/
export function getCmsFileUrl(src: string, opts: CmsFileUrlOptions = {}): string {
const params = new URLSearchParams();
params.set('src', src);
if (opts.filename) params.set('name', opts.filename);
if (opts.download) params.set('dl', '1');
return `/cms-files?${params.toString()}`;
}
const SIZE_UNITS = ['B', 'KB', 'MB', 'GB'];
export function formatFileSize(bytes?: number): string | null {
if (typeof bytes !== 'number' || !Number.isFinite(bytes) || bytes <= 0) return null;
let b = bytes;
let i = 0;
while (b >= 1024 && i < SIZE_UNITS.length - 1) {
b /= 1024;
i++;
}
const rounded = b >= 10 || i === 0 ? Math.round(b) : Math.round(b * 10) / 10;
return `${rounded} ${SIZE_UNITS[i]}`;
}
export function fileExtFromUrl(url: string): string | null {
try {
const path = url.split('?')[0].split('#')[0];
const last = path.split('/').pop() ?? '';
const dot = last.lastIndexOf('.');
return dot >= 0 ? last.slice(dot + 1).toLowerCase() : null;
} catch {
return null;
}
}
+103
View File
@@ -0,0 +1,103 @@
import type { RequestHandler } from './$types';
import { env } from '$env/dynamic/public';
import {
buildFileCacheKey,
cacheFile,
contentTypeFromExt,
extFromSrc,
getCachedFile,
} from '$lib/file-cache.server';
const IMMUTABLE_HEADERS = {
'cache-control': 'public, max-age=31536000, immutable',
} as const;
function getCmsBaseUrl(): string {
return (
env.PUBLIC_CMS_URL ||
import.meta.env.PUBLIC_CMS_URL ||
'http://localhost:3000'
).replace(/\/$/, '');
}
function resolveSourceUrl(src: string): string {
if (src.startsWith('http://') || src.startsWith('https://')) return src;
if (src.startsWith('//')) return `https:${src}`;
if (src.startsWith('/')) return `${getCmsBaseUrl()}${src}`;
return `${getCmsBaseUrl()}/api/assets/${encodeURIComponent(src)}`;
}
function buildResponseHeaders(
contentType: string,
download: string | null,
filename?: string,
): HeadersInit {
const headers: Record<string, string> = {
'content-type': contentType,
...IMMUTABLE_HEADERS,
};
if (download === '1' || download === 'true') {
const safe = (filename ?? 'download').replace(/"/g, '');
headers['content-disposition'] = `attachment; filename="${safe}"`;
} else if (filename) {
const safe = filename.replace(/"/g, '');
headers['content-disposition'] = `inline; filename="${safe}"`;
}
return headers;
}
export const GET: RequestHandler = async ({ url }) => {
const src = url.searchParams.get('src');
if (!src) {
return new Response('Missing "src" parameter', { status: 400 });
}
const download = url.searchParams.get('dl');
const filenameParam = url.searchParams.get('name') ?? undefined;
const inferredName = (() => {
try {
const path = src.split('?')[0].split('#')[0];
const last = path.split('/').pop();
return last ? decodeURIComponent(last) : undefined;
} catch {
return undefined;
}
})();
const filename = filenameParam ?? inferredName;
const key = buildFileCacheKey(src);
const cached = await getCachedFile(key);
if (cached) {
return new Response(new Uint8Array(cached.buffer), {
headers: buildResponseHeaders(cached.contentType, download, filename),
});
}
const sourceUrl = resolveSourceUrl(src);
let res: Response;
try {
res = await fetch(sourceUrl);
} catch (err) {
console.error('[cms-files] CMS fetch failed:', err);
return new Response('CMS not reachable', { status: 502 });
}
if (!res.ok) {
return new Response('File fetch failed', { status: res.status });
}
const buffer = await res.arrayBuffer();
const upstreamCt = res.headers.get('content-type') ?? '';
const ext = extFromSrc(src);
const contentType = upstreamCt && !upstreamCt.startsWith('application/octet-stream')
? upstreamCt
: ext
? contentTypeFromExt(ext)
: upstreamCt || 'application/octet-stream';
const result = await cacheFile(key, buffer, contentType, src);
return new Response(new Uint8Array(result.buffer), {
headers: buildResponseHeaders(result.contentType, download, filename),
});
};