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, '
        $1
      '); + + return `

      ${html}

      `; + } + function handleAction(action: string) { if (file) { onAction(action, file); @@ -136,6 +232,18 @@
      {#if isImage && imageUrl} {file.name} + {:else if isVideo} + {#if presignedUrl} + + + {:else} +
      + +

      Video wird geladen…

      +
      + {/if} {:else if isAudio}
      + {:else if isPdf} + {#if presignedUrl} + + {:else} +
      + +

      PDF wird geladen…

      +
      + {/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;