diff --git a/apps/storage/apps/web/src/lib/components/files/FilePreviewModal.svelte b/apps/storage/apps/web/src/lib/components/files/FilePreviewModal.svelte
index b2d4fb699..61f716e9a 100644
--- a/apps/storage/apps/web/src/lib/components/files/FilePreviewModal.svelte
+++ b/apps/storage/apps/web/src/lib/components/files/FilePreviewModal.svelte
@@ -17,8 +17,10 @@
Pause,
} from '@manacore/shared-icons';
import type { StorageFile } from '$lib/api/client';
+ import { authStore } from '$lib/stores/auth.svelte';
import FileVersionsModal from './FileVersionsModal.svelte';
import { audioPlayerStore, getAudioFiles } from '$lib/stores/audio-player.svelte';
+ import { browser } from '$app/environment';
interface Props {
open: boolean;
@@ -35,18 +37,78 @@
let isImage = $derived(file?.mimeType.startsWith('image/') ?? false);
let isAudio = $derived(file?.mimeType.startsWith('audio/') ?? false);
+ let isVideo = $derived(file?.mimeType.startsWith('video/') ?? false);
+ let isPdf = $derived(file?.mimeType === 'application/pdf');
let isTextOrCode = $derived(
file?.mimeType.startsWith('text/') ||
file?.mimeType.includes('javascript') ||
file?.mimeType.includes('json') ||
file?.mimeType.includes('xml') ||
+ file?.mimeType.includes('yaml') ||
+ file?.mimeType.includes('markdown') ||
false
);
+ let isMarkdown = $derived(
+ file?.name.endsWith('.md') || file?.mimeType.includes('markdown') || false
+ );
let imageUrl = $derived(
isImage && file ? `http://localhost:3016/api/v1/files/${file.id}/download` : null
);
+ /** Presigned URL for video/PDF preview */
+ let presignedUrl = $state(null);
+ let textContent = $state(null);
+ let textLoading = $state(false);
+
+ // Fetch presigned URL for video/PDF when file changes
+ $effect(() => {
+ presignedUrl = null;
+ textContent = null;
+
+ if (!file || !browser) return;
+
+ if (isVideo || isPdf) {
+ fetchPresignedUrl(file.id);
+ } else if (isTextOrCode) {
+ fetchTextContent(file.id);
+ }
+ });
+
+ async function fetchPresignedUrl(fileId: string) {
+ try {
+ const token = await authStore.getAccessToken();
+ const res = await fetch(`http://localhost:3016/api/v1/files/${fileId}/download?url=true`, {
+ headers: { Authorization: `Bearer ${token}` },
+ });
+ if (res.ok) {
+ const data = await res.json();
+ presignedUrl = data.url;
+ }
+ } catch (e) {
+ console.warn('Failed to get presigned URL:', e);
+ }
+ }
+
+ async function fetchTextContent(fileId: string) {
+ textLoading = true;
+ try {
+ const token = await authStore.getAccessToken();
+ const res = await fetch(`http://localhost:3016/api/v1/files/${fileId}/download`, {
+ headers: { Authorization: `Bearer ${token}` },
+ });
+ if (res.ok) {
+ const text = await res.text();
+ // Limit preview to ~50KB to avoid performance issues
+ textContent = text.length > 50000 ? text.slice(0, 50000) + '\n\n… (gekürzt)' : text;
+ }
+ } catch (e) {
+ console.warn('Failed to fetch text content:', e);
+ } finally {
+ textLoading = false;
+ }
+ }
+
/** Check if this file is currently playing in the global player */
let isCurrentlyPlaying = $derived(
audioPlayerStore.currentFile?.id === file?.id && audioPlayerStore.isPlaying
@@ -106,6 +168,40 @@
}
}
+ /** Simple markdown to HTML renderer (no external dependency) */
+ function renderMarkdown(md: string): string {
+ let html = md
+ // Escape HTML
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ // Code blocks (``` ... ```)
+ .replace(/```(\w*)\n([\s\S]*?)```/g, '$2
')
+ // Inline code
+ .replace(/`([^`]+)`/g, '$1')
+ // Headers
+ .replace(/^#### (.+)$/gm, '$1
')
+ .replace(/^### (.+)$/gm, '$1
')
+ .replace(/^## (.+)$/gm, '$1
')
+ .replace(/^# (.+)$/gm, '$1
')
+ // Bold & italic
+ .replace(/\*\*(.+?)\*\*/g, '$1')
+ .replace(/\*(.+?)\*/g, '$1')
+ // Links
+ .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1')
+ // Unordered lists
+ .replace(/^[*-] (.+)$/gm, '$1')
+ // Horizontal rules
+ .replace(/^---$/gm, '
')
+ // Paragraphs (double newlines)
+ .replace(/\n\n/g, '
');
+
+ // Wrap loose
in
+ html = html.replace(/((?:- .*<\/li>\s*)+)/g, '');
+
+ return `
${html}
`;
+ }
+
function handleAction(action: string) {
if (file) {
onAction(action, file);
@@ -136,6 +232,18 @@
{#if isImage && imageUrl}

+ {:else if isVideo}
+ {#if presignedUrl}
+
+
+ {:else}
+
+
+
Video wird geladen…
+
+ {/if}
{:else if isAudio}
+ {:else if isPdf}
+ {#if presignedUrl}
+
+ {:else}
+
+ {/if}
{:else if isTextOrCode}
-
-
-
Vorschau nicht verfügbar
-
+ {#if textLoading}
+
+
+
Inhalt wird geladen…
+
+ {:else if textContent !== null}
+ {#if isMarkdown}
+
+ {@html renderMarkdown(textContent)}
+
+ {:else}
+
{textContent}
+ {/if}
+ {:else}
+
+
+
Vorschau nicht verfügbar
+
+ {/if}
{:else}
-
+
{/if}
@@ -255,7 +388,7 @@
border-radius: var(--radius-xl);
box-shadow: var(--shadow-xl);
width: 100%;
- max-width: 600px;
+ max-width: 700px;
max-height: 90vh;
margin: 1rem;
display: flex;
@@ -323,6 +456,111 @@
object-fit: contain;
}
+ .video-preview {
+ width: 100%;
+ max-height: 400px;
+ border-radius: var(--radius-md);
+ background: #000;
+ }
+
+ .pdf-preview {
+ width: 100%;
+ height: 500px;
+ border: none;
+ border-radius: var(--radius-md);
+ }
+
+ .text-preview {
+ width: 100%;
+ max-height: 400px;
+ overflow: auto;
+ padding: 1rem;
+ margin: 0;
+ font-size: 0.8125rem;
+ line-height: 1.6;
+ color: rgb(var(--color-text-primary));
+ background: rgb(var(--color-surface));
+ border-radius: var(--radius-md);
+ text-align: left;
+ white-space: pre-wrap;
+ word-break: break-word;
+ font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
+ tab-size: 4;
+ }
+
+ .text-preview code {
+ font-family: inherit;
+ font-size: inherit;
+ }
+
+ .markdown-preview {
+ font-family: inherit;
+ white-space: normal;
+ }
+
+ .markdown-preview :global(h1) {
+ font-size: 1.375rem;
+ font-weight: 700;
+ margin: 0 0 0.5rem;
+ }
+
+ .markdown-preview :global(h2) {
+ font-size: 1.125rem;
+ font-weight: 600;
+ margin: 1rem 0 0.375rem;
+ }
+
+ .markdown-preview :global(h3) {
+ font-size: 1rem;
+ font-weight: 600;
+ margin: 0.75rem 0 0.25rem;
+ }
+
+ .markdown-preview :global(code) {
+ font-family: 'SF Mono', 'Fira Code', monospace;
+ font-size: 0.8em;
+ padding: 0.125rem 0.375rem;
+ background: rgb(var(--color-border) / 0.5);
+ border-radius: var(--radius-sm);
+ }
+
+ .markdown-preview :global(pre) {
+ background: rgb(var(--color-surface-elevated));
+ padding: 0.75rem;
+ border-radius: var(--radius-md);
+ overflow-x: auto;
+ margin: 0.5rem 0;
+ }
+
+ .markdown-preview :global(pre code) {
+ padding: 0;
+ background: none;
+ }
+
+ .markdown-preview :global(ul) {
+ padding-left: 1.25rem;
+ margin: 0.375rem 0;
+ }
+
+ .markdown-preview :global(li) {
+ margin: 0.125rem 0;
+ }
+
+ .markdown-preview :global(hr) {
+ border: none;
+ border-top: 1px solid rgb(var(--color-border));
+ margin: 0.75rem 0;
+ }
+
+ .markdown-preview :global(a) {
+ color: rgb(var(--color-primary));
+ text-decoration: underline;
+ }
+
+ .markdown-preview :global(strong) {
+ font-weight: 600;
+ }
+
.audio-preview {
display: flex;
flex-direction: column;