feat: add FilesBlock interface and component integration
- 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:
@@ -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";
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user