feat: Add previews for all supported file types
This commit is contained in:
parent
7434d98fc1
commit
c77f5fb877
8 changed files with 1530 additions and 34 deletions
10
src/app.css
10
src/app.css
|
|
@ -343,6 +343,16 @@
|
||||||
overscroll-behavior: contain;
|
overscroll-behavior: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Large modal variant for file previews */
|
||||||
|
.modal-large .modal-box {
|
||||||
|
@apply max-w-[90vw] max-h-[90vh] p-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Extra large modal for maximum viewing space */
|
||||||
|
.modal-xlarge .modal-box {
|
||||||
|
@apply max-w-[95vw] max-h-[95vh] p-2;
|
||||||
|
}
|
||||||
|
|
||||||
.modal-top {
|
.modal-top {
|
||||||
@apply place-items-start;
|
@apply place-items-start;
|
||||||
|
|
||||||
|
|
|
||||||
512
src/lib/components/ui/file-preview/audio-preview.svelte
Normal file
512
src/lib/components/ui/file-preview/audio-preview.svelte
Normal file
|
|
@ -0,0 +1,512 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { Attachment } from '$lib/types';
|
||||||
|
import { cn } from '$lib/utils/utils';
|
||||||
|
import { Modal } from '$lib/components/ui/modal';
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import DownloadIcon from '~icons/lucide/download';
|
||||||
|
import ExternalLinkIcon from '~icons/lucide/external-link';
|
||||||
|
import XIcon from '~icons/lucide/x';
|
||||||
|
import MaximizeIcon from '~icons/lucide/maximize-2';
|
||||||
|
import Tooltip from '$lib/components/ui/tooltip.svelte';
|
||||||
|
import { formatFileSize, formatTime, openInNewTab } from '$lib/utils/file';
|
||||||
|
import { untrack } from 'svelte';
|
||||||
|
|
||||||
|
let { attachment, isUserMessage = false, compact = false } = $props<{
|
||||||
|
attachment: Attachment;
|
||||||
|
isUserMessage?: boolean;
|
||||||
|
compact?: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
let audioUrl = $derived(attachment.url);
|
||||||
|
let fileName = $derived(attachment.fileName);
|
||||||
|
let fileSize = $derived(attachment.size);
|
||||||
|
|
||||||
|
let isPlaying = $state(false);
|
||||||
|
let currentTime = $state(0);
|
||||||
|
let duration = $state(0);
|
||||||
|
let volume = $state(0.7);
|
||||||
|
let isMuted = $state(false);
|
||||||
|
let audioElement: HTMLAudioElement | undefined;
|
||||||
|
let modalOpen = $state(false);
|
||||||
|
|
||||||
|
|
||||||
|
const togglePlay = () => {
|
||||||
|
if (audioElement) {
|
||||||
|
if (audioElement.paused) {
|
||||||
|
audioElement.play();
|
||||||
|
isPlaying = true;
|
||||||
|
} else {
|
||||||
|
audioElement.pause();
|
||||||
|
isPlaying = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleMute = () => {
|
||||||
|
if (audioElement) {
|
||||||
|
audioElement.muted = !audioElement.muted;
|
||||||
|
isMuted = audioElement.muted;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTimeUpdate = () => {
|
||||||
|
if (audioElement) {
|
||||||
|
currentTime = audioElement.currentTime;
|
||||||
|
duration = audioElement.duration || 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSeek = (e: Event) => {
|
||||||
|
const target = e.target as HTMLInputElement;
|
||||||
|
const newTime = parseFloat(target.value);
|
||||||
|
if (audioElement) {
|
||||||
|
audioElement.currentTime = newTime;
|
||||||
|
currentTime = newTime;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVolumeChange = (e: Event) => {
|
||||||
|
const target = e.target as HTMLInputElement;
|
||||||
|
const newVolume = parseFloat(target.value);
|
||||||
|
if (audioElement) {
|
||||||
|
audioElement.volume = newVolume;
|
||||||
|
volume = newVolume;
|
||||||
|
isMuted = newVolume === 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Real waveform analysis using Web Audio API
|
||||||
|
let baseWaveformBars = $state(Array(40).fill(0.5));
|
||||||
|
let baseModalBars = $state(Array(80).fill(0.5));
|
||||||
|
|
||||||
|
async function analyzeAudio() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(audioUrl);
|
||||||
|
const arrayBuffer = await response.arrayBuffer();
|
||||||
|
const audioContext = new AudioContext();
|
||||||
|
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
|
||||||
|
|
||||||
|
// Get audio data from first channel
|
||||||
|
const channelData = audioBuffer.getChannelData(0);
|
||||||
|
const samples = channelData.length;
|
||||||
|
|
||||||
|
// Generate waveform for 40 bars
|
||||||
|
const samplesPerBar40 = Math.floor(samples / 40);
|
||||||
|
const waveform40 = Array(40).fill(0);
|
||||||
|
|
||||||
|
for (let i = 0; i < 40; i++) {
|
||||||
|
let sum = 0;
|
||||||
|
const start = i * samplesPerBar40;
|
||||||
|
const end = Math.min(start + samplesPerBar40, samples);
|
||||||
|
|
||||||
|
// Calculate RMS (root mean square) for this segment
|
||||||
|
for (let j = start; j < end; j++) {
|
||||||
|
sum += channelData[j] * channelData[j];
|
||||||
|
}
|
||||||
|
waveform40[i] = Math.min(1, Math.sqrt(sum / (end - start)) * 3); // Scale for visibility
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate waveform for 80 bars
|
||||||
|
const samplesPerBar80 = Math.floor(samples / 80);
|
||||||
|
const waveform80 = Array(80).fill(0);
|
||||||
|
|
||||||
|
for (let i = 0; i < 80; i++) {
|
||||||
|
let sum = 0;
|
||||||
|
const start = i * samplesPerBar80;
|
||||||
|
const end = Math.min(start + samplesPerBar80, samples);
|
||||||
|
|
||||||
|
for (let j = start; j < end; j++) {
|
||||||
|
sum += channelData[j] * channelData[j];
|
||||||
|
}
|
||||||
|
waveform80[i] = Math.min(1, Math.sqrt(sum / (end - start)) * 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
baseWaveformBars = waveform40;
|
||||||
|
baseModalBars = waveform80;
|
||||||
|
audioContext.close();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Could not analyze audio, using fallback waveform:', error);
|
||||||
|
// Fallback to pattern-based waveform
|
||||||
|
const generateFallback = (bars) => Array.from({ length: bars }, (_, i) => {
|
||||||
|
const baseHeight = Math.sin(i * 0.3) * 0.3 + 0.5;
|
||||||
|
const noise = (Math.random() - 0.5) * 0.3;
|
||||||
|
return Math.max(0.1, Math.min(1, baseHeight + noise));
|
||||||
|
});
|
||||||
|
baseWaveformBars = generateFallback(40);
|
||||||
|
baseModalBars = generateFallback(80);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Analyze audio when component loads
|
||||||
|
$effect(() => {
|
||||||
|
if (audioUrl) {
|
||||||
|
analyzeAudio();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let animationFrame: number;
|
||||||
|
let animatedBars = $state([...baseWaveformBars]);
|
||||||
|
let animatedModalBars = $state([...baseModalBars]);
|
||||||
|
|
||||||
|
// Calculate progress outside of the each loop to avoid performance issues
|
||||||
|
let progress = $derived(currentTime / (duration || 1));
|
||||||
|
|
||||||
|
// Safe animation that doesn't cause reactive loops
|
||||||
|
$effect(() => {
|
||||||
|
if (isPlaying) {
|
||||||
|
const animate = () => {
|
||||||
|
const time = Date.now() * 0.01;
|
||||||
|
|
||||||
|
// Create new array instead of mutating existing one
|
||||||
|
const newBars = baseWaveformBars.map((baseHeight, i) => {
|
||||||
|
const activeBar = Math.floor(progress * baseWaveformBars.length);
|
||||||
|
|
||||||
|
if (i <= activeBar) {
|
||||||
|
// Add subtle animation to played portion
|
||||||
|
const variation = (Math.sin(time + i * 0.5) * 0.1);
|
||||||
|
return Math.max(0.3, Math.min(1, baseHeight + variation));
|
||||||
|
}
|
||||||
|
// Keep unplayed portion at reduced height
|
||||||
|
return baseHeight * 0.7;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Animate modal bars too
|
||||||
|
const newModalBars = baseModalBars.map((baseHeight, i) => {
|
||||||
|
const activeBar = Math.floor(progress * baseModalBars.length);
|
||||||
|
|
||||||
|
if (i <= activeBar) {
|
||||||
|
// Add subtle animation to played portion
|
||||||
|
const variation = (Math.sin(time + i * 0.3) * 0.1);
|
||||||
|
return Math.max(0.3, Math.min(1, baseHeight + variation));
|
||||||
|
}
|
||||||
|
// Keep unplayed portion at reduced height
|
||||||
|
return baseHeight * 0.7;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Use untrack to prevent reactive updates from triggering this effect
|
||||||
|
untrack(() => {
|
||||||
|
animatedBars = newBars;
|
||||||
|
animatedModalBars = newModalBars;
|
||||||
|
});
|
||||||
|
animationFrame = requestAnimationFrame(animate);
|
||||||
|
};
|
||||||
|
|
||||||
|
animationFrame = requestAnimationFrame(animate);
|
||||||
|
} else {
|
||||||
|
if (animationFrame) {
|
||||||
|
cancelAnimationFrame(animationFrame);
|
||||||
|
}
|
||||||
|
// Reset to base heights when not playing
|
||||||
|
animatedBars = [...baseWaveformBars];
|
||||||
|
animatedModalBars = [...baseModalBars];
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (animationFrame) {
|
||||||
|
cancelAnimationFrame(animationFrame);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<audio
|
||||||
|
bind:this={audioElement}
|
||||||
|
src={audioUrl}
|
||||||
|
onplay={() => (isPlaying = true)}
|
||||||
|
onpause={() => (isPlaying = false)}
|
||||||
|
ontimeupdate={handleTimeUpdate}
|
||||||
|
onloadedmetadata={() => {
|
||||||
|
if (audioElement) {
|
||||||
|
duration = audioElement.duration || 0;
|
||||||
|
audioElement.volume = volume;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onvolumechange={() => {
|
||||||
|
if (audioElement) {
|
||||||
|
volume = audioElement.volume;
|
||||||
|
isMuted = audioElement.muted;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onended={() => (isPlaying = false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
{#if compact}
|
||||||
|
<!-- Compact view for inline display -->
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onclick={togglePlay}
|
||||||
|
class="flex-shrink-0 w-10 h-10 bg-primary/10 hover:bg-primary/20 rounded-full flex items-center justify-center transition-colors"
|
||||||
|
aria-label={isPlaying ? 'Pause audio' : 'Play audio'}
|
||||||
|
>
|
||||||
|
{#if isPlaying}
|
||||||
|
<svg class="w-4 h-4 text-primary" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zM7 8a1 1 0 012 0v4a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v4a1 1 0 102 0V8a1 1 0 00-1-1z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
{:else}
|
||||||
|
<svg class="w-4 h-4 text-primary" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="text-sm font-medium text-foreground truncate">{fileName}</div>
|
||||||
|
<div class="text-xs text-muted-foreground">
|
||||||
|
Audio • {formatFileSize(fileSize)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onclick={() => (modalOpen = true)}
|
||||||
|
class="flex-shrink-0 p-1 rounded hover:bg-accent transition-colors"
|
||||||
|
aria-label="Open audio in large modal"
|
||||||
|
>
|
||||||
|
<MaximizeIcon class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- Full view for dedicated audio display -->
|
||||||
|
<div class="bg-card border border-border rounded-lg p-4">
|
||||||
|
<!-- Audio header with icon and info -->
|
||||||
|
<div class="flex items-center gap-3 mb-4">
|
||||||
|
<div class="flex-shrink-0 w-12 h-12 bg-primary/10 rounded-full flex items-center justify-center">
|
||||||
|
<svg class="w-6 h-6 text-primary" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="text-sm font-medium text-foreground truncate">{fileName}</div>
|
||||||
|
<div class="text-xs text-muted-foreground">
|
||||||
|
{formatFileSize(fileSize)} • {formatTime(duration)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onclick={() => (modalOpen = true)}
|
||||||
|
class="flex-shrink-0 p-2 rounded hover:bg-accent transition-colors"
|
||||||
|
aria-label="Open audio in large modal"
|
||||||
|
>
|
||||||
|
<MaximizeIcon class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
href={audioUrl}
|
||||||
|
download={fileName}
|
||||||
|
class="flex-shrink-0 p-2 rounded hover:bg-accent transition-colors"
|
||||||
|
aria-label={`Download ${fileName}`}
|
||||||
|
>
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Waveform visualization -->
|
||||||
|
<div class="mb-4 h-16 flex items-center gap-1 px-2">
|
||||||
|
{#each animatedBars as height, i}
|
||||||
|
{@const isActive = i <= Math.floor(progress * animatedBars.length)}
|
||||||
|
<div
|
||||||
|
class="flex-1 rounded-sm transition-all duration-200 {isActive ? 'bg-primary' : 'bg-primary/20'}"
|
||||||
|
style="height: {height * 100}%"
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Progress bar -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max={duration || 100}
|
||||||
|
value={currentTime}
|
||||||
|
oninput={handleSeek}
|
||||||
|
class="w-full h-2 bg-muted rounded-lg appearance-none cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:bg-primary [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:cursor-pointer hover:[&::-webkit-slider-thumb]:scale-110 transition-transform"
|
||||||
|
aria-label="Audio progress"
|
||||||
|
/>
|
||||||
|
<div class="flex justify-between text-xs text-muted-foreground mt-1">
|
||||||
|
<span>{formatTime(currentTime)}</span>
|
||||||
|
<span>{formatTime(duration)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Control buttons -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onclick={togglePlay}
|
||||||
|
class="w-10 h-10 bg-primary hover:bg-primary/90 rounded-full flex items-center justify-center transition-colors"
|
||||||
|
aria-label={isPlaying ? 'Pause audio' : 'Play audio'}
|
||||||
|
>
|
||||||
|
{#if isPlaying}
|
||||||
|
<svg class="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zM7 8a1 1 0 012 0v4a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v4a1 1 0 102 0V8a1 1 0 00-1-1z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
{:else}
|
||||||
|
<svg class="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onclick={toggleMute}
|
||||||
|
class="w-8 h-8 bg-muted hover:bg-muted/80 rounded-full flex items-center justify-center transition-colors"
|
||||||
|
aria-label={isMuted ? 'Unmute audio' : 'Mute audio'}
|
||||||
|
>
|
||||||
|
{#if isMuted}
|
||||||
|
<svg class="w-4 h-4 text-foreground" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M9.383 3.076A1 1 0 0110 4v12a1 1 0 01-1.707.707L4.586 13H2a1 1 0 01-1-1V8a1 1 0 011-1h2.586l3.707-3.707a1 1 0 011.09-.217zM12.293 7.293a1 1 0 011.414 0L15 8.586l1.293-1.293a1 1 0 111.414 1.414L16.414 10l1.293 1.293a1 1 0 01-1.414 1.414L15 11.414l-1.293 1.293a1 1 0 01-1.414-1.414L13.586 10l-1.293-1.293a1 1 0 010-1.414z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
{:else}
|
||||||
|
<svg class="w-4 h-4 text-foreground" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M9.383 3.076A1 1 0 0110 4v12a1 1 0 01-1.707.707L4.586 13H2a1 1 0 01-1-1V8a1 1 0 011-1h2.586l3.707-3.707a1 1 0 011.09-.217zM14.657 2.929a1 1 0 011.414 0A9.972 9.972 0 0119 10a9.972 9.972 0 01-2.929 7.071 1 1 0 01-1.414-1.414A7.971 7.971 0 0017 10c0-2.21-.894-4.208-2.343-5.657a1 1 0 010-1.414zm-2.829 2.828a1 1 0 011.415 0A5.983 5.983 0 0115 10a5.984 5.984 0 01-1.757 4.243 1 1 0 01-1.415-1.415A3.984 3.984 0 0013 10a3.983 3.983 0 00-1.172-2.828 1 1 0 010-1.415z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="1"
|
||||||
|
step="0.1"
|
||||||
|
value={volume}
|
||||||
|
oninput={handleVolumeChange}
|
||||||
|
class="w-20 h-2 bg-muted rounded-lg appearance-none cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:bg-foreground [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:cursor-pointer"
|
||||||
|
aria-label="Volume control"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Modal for enhanced audio viewing -->
|
||||||
|
<Modal bind:open={modalOpen} class="modal-large">
|
||||||
|
<div class="flex items-center justify-between p-2">
|
||||||
|
<h2 class="text-lg font-semibold truncate">{fileName}</h2>
|
||||||
|
<div class="flex items-center gap-2 flex-shrink-0">
|
||||||
|
<Tooltip>
|
||||||
|
{#snippet trigger(tooltip)}
|
||||||
|
<Button
|
||||||
|
size="iconSm"
|
||||||
|
variant="outline"
|
||||||
|
download={fileName}
|
||||||
|
href={audioUrl}
|
||||||
|
{...tooltip.trigger}
|
||||||
|
>
|
||||||
|
<DownloadIcon class="size-4" />
|
||||||
|
</Button>
|
||||||
|
{/snippet}
|
||||||
|
Download audio
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip>
|
||||||
|
{#snippet trigger(tooltip)}
|
||||||
|
<Button size="iconSm" variant="outline" onclick={() => openInNewTab(audioUrl)} {...tooltip.trigger}>
|
||||||
|
<ExternalLinkIcon class="size-4" />
|
||||||
|
</Button>
|
||||||
|
{/snippet}
|
||||||
|
Open in new tab
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip>
|
||||||
|
{#snippet trigger(tooltip)}
|
||||||
|
<Button
|
||||||
|
size="iconSm"
|
||||||
|
variant="outline"
|
||||||
|
onclick={() => (modalOpen = false)}
|
||||||
|
{...tooltip.trigger}
|
||||||
|
>
|
||||||
|
<XIcon class="size-4" />
|
||||||
|
</Button>
|
||||||
|
{/snippet}
|
||||||
|
Close
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-6 p-6">
|
||||||
|
<!-- Enhanced waveform visualization -->
|
||||||
|
<div class="h-24 flex items-center gap-1 px-4 bg-muted/20 rounded-lg">
|
||||||
|
{#each animatedModalBars as height, i}
|
||||||
|
{@const isActive = i <= Math.floor(progress * animatedModalBars.length)}
|
||||||
|
<div
|
||||||
|
class="flex-1 rounded-sm transition-all duration-300 {isActive ? 'bg-primary' : 'bg-primary/30'}"
|
||||||
|
style="height: {height * 100}%"
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Enhanced audio player -->
|
||||||
|
<div class="bg-card border border-border rounded-lg p-6">
|
||||||
|
<!-- Progress bar -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max={duration || 100}
|
||||||
|
value={currentTime}
|
||||||
|
oninput={handleSeek}
|
||||||
|
class="w-full h-3 bg-muted rounded-lg appearance-none cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-5 [&::-webkit-slider-thumb]:h-5 [&::-webkit-slider-thumb]:bg-primary [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:cursor-pointer hover:[&::-webkit-slider-thumb]:scale-110 transition-transform"
|
||||||
|
aria-label="Audio progress"
|
||||||
|
/>
|
||||||
|
<div class="flex justify-between text-sm text-muted-foreground mt-2">
|
||||||
|
<span>{formatTime(currentTime)}</span>
|
||||||
|
<span>{formatTime(duration)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Control buttons -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<button
|
||||||
|
onclick={togglePlay}
|
||||||
|
class="w-12 h-12 bg-primary hover:bg-primary/90 rounded-full flex items-center justify-center transition-colors"
|
||||||
|
aria-label={isPlaying ? 'Pause audio' : 'Play audio'}
|
||||||
|
>
|
||||||
|
{#if isPlaying}
|
||||||
|
<svg class="w-6 h-6 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zM7 8a1 1 0 012 0v4a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v4a1 1 0 102 0V8a1 1 0 00-1-1z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
{:else}
|
||||||
|
<svg class="w-6 h-6 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onclick={toggleMute}
|
||||||
|
class="w-10 h-10 bg-muted hover:bg-muted/80 rounded-full flex items-center justify-center transition-colors"
|
||||||
|
aria-label={isMuted ? 'Unmute audio' : 'Mute audio'}
|
||||||
|
>
|
||||||
|
{#if isMuted}
|
||||||
|
<svg class="w-5 h-5 text-foreground" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M9.383 3.076A1 1 0 0110 4v12a1 1 0 01-1.707.707L4.586 13H2a1 1 0 01-1-1V8a1 1 0 011-1h2.586l3.707-3.707a1 1 0 011.09-.217zM12.293 7.293a1 1 0 011.414 0L15 8.586l1.293-1.293a1 1 0 111.414 1.414L16.414 10l1.293 1.293a1 1 0 01-1.414 1.414L15 11.414l-1.293 1.293a1 1 0 01-1.414-1.414L13.586 10l-1.293-1.293a1 1 0 010-1.414z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
{:else}
|
||||||
|
<svg class="w-5 h-5 text-foreground" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M9.383 3.076A1 1 0 0110 4v12a1 1 0 01-1.707.707L4.586 13H2a1 1 0 01-1-1V8a1 1 0 011-1h2.586l3.707-3.707a1 1 0 011.09-.217zM14.657 2.929a1 1 0 011.414 0A9.972 9.972 0 0119 10a9.972 9.972 0 01-2.929 7.071 1 1 0 01-1.414-1.414A7.971 7.971 0 0017 10c0-2.21-.894-4.208-2.343-5.657a1 1 0 010-1.414zm-2.829 2.828a1 1 0 011.415 0A5.983 5.983 0 0115 10a5.984 5.984 0 01-1.757 4.243 1 1 0 01-1.415-1.415A3.984 3.984 0 0013 10a3.983 3.983 0 00-1.172-2.828 1 1 0 010-1.415z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="1"
|
||||||
|
step="0.1"
|
||||||
|
value={volume}
|
||||||
|
oninput={handleVolumeChange}
|
||||||
|
class="w-24 h-2 bg-muted rounded-lg appearance-none cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:bg-foreground [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:cursor-pointer"
|
||||||
|
aria-label="Volume control"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-sm text-muted-foreground">
|
||||||
|
{formatFileSize(fileSize)} • {formatTime(duration)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
379
src/lib/components/ui/file-preview/document-preview.svelte
Normal file
379
src/lib/components/ui/file-preview/document-preview.svelte
Normal file
|
|
@ -0,0 +1,379 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { Attachment } from '$lib/types';
|
||||||
|
import { cn } from '$lib/utils/utils';
|
||||||
|
import { Modal } from '$lib/components/ui/modal';
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import DownloadIcon from '~icons/lucide/download';
|
||||||
|
import ExternalLinkIcon from '~icons/lucide/external-link';
|
||||||
|
import XIcon from '~icons/lucide/x';
|
||||||
|
import MaximizeIcon from '~icons/lucide/maximize-2';
|
||||||
|
import Tooltip from '$lib/components/ui/tooltip.svelte';
|
||||||
|
import { formatFileSize, openInNewTab, getFileExtension } from '$lib/utils/file';
|
||||||
|
|
||||||
|
let { attachment, isUserMessage = false, compact = false } = $props<{
|
||||||
|
attachment: Attachment;
|
||||||
|
isUserMessage?: boolean;
|
||||||
|
compact?: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
let documentUrl = $derived(attachment.url);
|
||||||
|
let fileName = $derived(attachment.fileName);
|
||||||
|
let fileSize = $derived(attachment.size);
|
||||||
|
let mimeType = $derived(attachment.mimeType);
|
||||||
|
let modalOpen = $state(false);
|
||||||
|
|
||||||
|
|
||||||
|
const getFileIcon = () => {
|
||||||
|
const extension = getFileExtension(fileName);
|
||||||
|
|
||||||
|
// PDF files
|
||||||
|
if (extension === 'pdf') {
|
||||||
|
return {
|
||||||
|
icon: '📄',
|
||||||
|
color: 'text-red-500',
|
||||||
|
bgColor: 'bg-red-50',
|
||||||
|
label: 'PDF Document'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Text files
|
||||||
|
if (['txt', 'md', 'markdown', 'rtf'].includes(extension)) {
|
||||||
|
return {
|
||||||
|
icon: '📝',
|
||||||
|
color: 'text-blue-500',
|
||||||
|
bgColor: 'bg-blue-50',
|
||||||
|
label: 'Text Document'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Word documents
|
||||||
|
if (['doc', 'docx'].includes(extension)) {
|
||||||
|
return {
|
||||||
|
icon: '📘',
|
||||||
|
color: 'text-blue-600',
|
||||||
|
bgColor: 'bg-blue-50',
|
||||||
|
label: 'Word Document'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Excel spreadsheets
|
||||||
|
if (['xls', 'xlsx', 'csv'].includes(extension)) {
|
||||||
|
return {
|
||||||
|
icon: '📗',
|
||||||
|
color: 'text-green-600',
|
||||||
|
bgColor: 'bg-green-50',
|
||||||
|
label: 'Spreadsheet'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// PowerPoint presentations
|
||||||
|
if (['ppt', 'pptx'].includes(extension)) {
|
||||||
|
return {
|
||||||
|
icon: '📙',
|
||||||
|
color: 'text-orange-600',
|
||||||
|
bgColor: 'bg-orange-50',
|
||||||
|
label: 'Presentation'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Code files
|
||||||
|
if (['js', 'ts', 'jsx', 'tsx', 'html', 'css', 'scss', 'json', 'xml', 'py', 'java', 'cpp', 'c', 'cs', 'php', 'rb', 'go', 'rs', 'swift', 'kt', 'sql'].includes(extension)) {
|
||||||
|
return {
|
||||||
|
icon: '💻',
|
||||||
|
color: 'text-purple-600',
|
||||||
|
bgColor: 'bg-purple-50',
|
||||||
|
label: 'Code File'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Archive files
|
||||||
|
if (['zip', 'rar', '7z', 'tar', 'gz', 'bz2'].includes(extension)) {
|
||||||
|
return {
|
||||||
|
icon: '📦',
|
||||||
|
color: 'text-yellow-600',
|
||||||
|
bgColor: 'bg-yellow-50',
|
||||||
|
label: 'Archive'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default document
|
||||||
|
return {
|
||||||
|
icon: '📄',
|
||||||
|
color: 'text-gray-600',
|
||||||
|
bgColor: 'bg-gray-50',
|
||||||
|
label: 'Document'
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
let fileDetails = $derived(getFileIcon());
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
{#if compact}
|
||||||
|
<!-- Compact view for inline display -->
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class={`flex-shrink-0 w-10 h-10 ${fileDetails.bgColor} rounded-lg flex items-center justify-center`}>
|
||||||
|
<span class="text-lg">{fileDetails.icon}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="text-sm font-medium text-foreground truncate">{fileName}</div>
|
||||||
|
<div class="text-xs text-muted-foreground">
|
||||||
|
{fileDetails.label} • {formatFileSize(fileSize)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onclick={() => (modalOpen = true)}
|
||||||
|
class="flex-shrink-0 p-1 rounded hover:bg-accent transition-colors"
|
||||||
|
aria-label="Open document in large modal"
|
||||||
|
>
|
||||||
|
<MaximizeIcon class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- Full view for dedicated document display -->
|
||||||
|
<div class="bg-card border border-border rounded-lg p-4">
|
||||||
|
<div class="flex items-start gap-4">
|
||||||
|
<!-- Document icon -->
|
||||||
|
<div class={`flex-shrink-0 w-16 h-16 ${fileDetails.bgColor} rounded-xl flex items-center justify-center`}>
|
||||||
|
<span class="text-2xl">{fileDetails.icon}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Document info -->
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="flex items-start justify-between gap-2">
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<h3 class="text-sm font-semibold text-foreground truncate">
|
||||||
|
{fileName}
|
||||||
|
</h3>
|
||||||
|
<p class="text-xs text-muted-foreground mt-1">
|
||||||
|
{fileDetails.label}
|
||||||
|
</p>
|
||||||
|
<div class="flex items-center gap-4 mt-2 text-xs text-muted-foreground">
|
||||||
|
<span>{formatFileSize(fileSize)}</span>
|
||||||
|
<span>{mimeType}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action buttons -->
|
||||||
|
<div class="flex items-center gap-2 flex-shrink-0">
|
||||||
|
<!-- Maximize button -->
|
||||||
|
<button
|
||||||
|
onclick={() => (modalOpen = true)}
|
||||||
|
class="p-2 rounded-lg hover:bg-accent transition-colors"
|
||||||
|
aria-label={`Open ${fileName} in large modal`}
|
||||||
|
title="Open in large modal"
|
||||||
|
>
|
||||||
|
<MaximizeIcon class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if mimeType.startsWith('image/') || mimeType === 'application/pdf'}
|
||||||
|
<!-- Preview button for viewable documents -->
|
||||||
|
<button
|
||||||
|
onclick={() => openInNewTab(documentUrl)}
|
||||||
|
class="p-2 rounded-lg hover:bg-accent transition-colors"
|
||||||
|
aria-label={`Preview ${fileName}`}
|
||||||
|
title="Preview document"
|
||||||
|
>
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Download button -->
|
||||||
|
<a
|
||||||
|
href={documentUrl}
|
||||||
|
download={fileName}
|
||||||
|
class="p-2 rounded-lg hover:bg-accent transition-colors"
|
||||||
|
aria-label={`Download ${fileName}`}
|
||||||
|
title="Download document"
|
||||||
|
>
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Additional metadata -->
|
||||||
|
<div class="mt-3 pt-3 border-t border-border">
|
||||||
|
<div class="flex items-center justify-between text-xs">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="inline-flex items-center px-2 py-1 rounded-full bg-muted text-muted-foreground">
|
||||||
|
{fileDetails.label}
|
||||||
|
</span>
|
||||||
|
{#if fileName.includes('.')}
|
||||||
|
<span class="inline-flex items-center px-2 py-1 rounded-full bg-muted text-muted-foreground">
|
||||||
|
.{fileName.split('.').pop()?.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-muted-foreground">
|
||||||
|
{formatFileSize(fileSize)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Preview hint for viewable documents -->
|
||||||
|
{#if mimeType.startsWith('image/') || mimeType === 'application/pdf'}
|
||||||
|
<div class="mt-3 p-3 bg-muted/50 rounded-lg">
|
||||||
|
<div class="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<span>Click the eye icon to preview this document in a new tab</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Modal for enhanced document viewing -->
|
||||||
|
<Modal bind:open={modalOpen} class="modal-large">
|
||||||
|
<div class="flex items-center justify-between p-2">
|
||||||
|
<h2 class="text-lg font-semibold truncate">{fileName}</h2>
|
||||||
|
<div class="flex items-center gap-2 flex-shrink-0">
|
||||||
|
<Tooltip>
|
||||||
|
{#snippet trigger(tooltip)}
|
||||||
|
<Button
|
||||||
|
size="iconSm"
|
||||||
|
variant="outline"
|
||||||
|
download={fileName}
|
||||||
|
href={documentUrl}
|
||||||
|
{...tooltip.trigger}
|
||||||
|
>
|
||||||
|
<DownloadIcon class="size-4" />
|
||||||
|
</Button>
|
||||||
|
{/snippet}
|
||||||
|
Download document
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip>
|
||||||
|
{#snippet trigger(tooltip)}
|
||||||
|
<Button size="iconSm" variant="outline" onclick={() => openInNewTab(documentUrl)} {...tooltip.trigger}>
|
||||||
|
<ExternalLinkIcon class="size-4" />
|
||||||
|
</Button>
|
||||||
|
{/snippet}
|
||||||
|
Open in new tab
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip>
|
||||||
|
{#snippet trigger(tooltip)}
|
||||||
|
<Button
|
||||||
|
size="iconSm"
|
||||||
|
variant="outline"
|
||||||
|
onclick={() => (modalOpen = false)}
|
||||||
|
{...tooltip.trigger}
|
||||||
|
>
|
||||||
|
<XIcon class="size-4" />
|
||||||
|
</Button>
|
||||||
|
{/snippet}
|
||||||
|
Close
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-6 p-6">
|
||||||
|
<!-- Document header with enhanced info -->
|
||||||
|
<div class="flex items-start gap-6">
|
||||||
|
<div class={`flex-shrink-0 w-20 h-20 ${fileDetails.bgColor} rounded-2xl flex items-center justify-center`}>
|
||||||
|
<span class="text-3xl">{fileDetails.icon}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<h3 class="text-xl font-semibold text-foreground truncate mb-2">
|
||||||
|
{fileName}
|
||||||
|
</h3>
|
||||||
|
<div class="flex flex-wrap items-center gap-4 text-sm text-muted-foreground">
|
||||||
|
<span class="inline-flex items-center px-3 py-1 rounded-full bg-muted text-muted-foreground">
|
||||||
|
{fileDetails.label}
|
||||||
|
</span>
|
||||||
|
{#if fileName.includes('.')}
|
||||||
|
<span class="inline-flex items-center px-3 py-1 rounded-full bg-muted text-muted-foreground">
|
||||||
|
.{fileName.split('.').pop()?.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
<span>{formatFileSize(fileSize)}</span>
|
||||||
|
<span>{mimeType}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Document preview area -->
|
||||||
|
<div class="bg-muted/20 rounded-lg p-8 min-h-[400px] flex items-center justify-center">
|
||||||
|
{#if mimeType.startsWith('image/')}
|
||||||
|
<!-- Image preview -->
|
||||||
|
<img
|
||||||
|
src={documentUrl}
|
||||||
|
alt={fileName}
|
||||||
|
class="max-h-[60vh] max-w-full rounded-lg object-contain shadow-lg"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
{:else if mimeType === 'application/pdf'}
|
||||||
|
<!-- PDF preview -->
|
||||||
|
<div class="text-center">
|
||||||
|
<div class={`w-16 h-16 ${fileDetails.bgColor} rounded-full flex items-center justify-center mx-auto mb-4`}>
|
||||||
|
<span class="text-2xl">{fileDetails.icon}</span>
|
||||||
|
</div>
|
||||||
|
<h4 class="text-lg font-medium mb-2">PDF Document</h4>
|
||||||
|
<p class="text-muted-foreground mb-4">
|
||||||
|
This PDF document will open in a new tab for the best viewing experience.
|
||||||
|
</p>
|
||||||
|
<Button onclick={() => openInNewTab(documentUrl)} variant="outline">
|
||||||
|
Open PDF in new tab
|
||||||
|
<ExternalLinkIcon class="w-4 h-4 ml-2" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- Generic document preview -->
|
||||||
|
<div class="text-center">
|
||||||
|
<div class={`w-16 h-16 ${fileDetails.bgColor} rounded-full flex items-center justify-center mx-auto mb-4`}>
|
||||||
|
<span class="text-2xl">{fileDetails.icon}</span>
|
||||||
|
</div>
|
||||||
|
<h4 class="text-lg font-medium mb-2">{fileDetails.label}</h4>
|
||||||
|
<p class="text-muted-foreground mb-4">
|
||||||
|
This {fileDetails.label.toLowerCase()} can be downloaded for viewing in the appropriate application.
|
||||||
|
</p>
|
||||||
|
<div class="flex items-center justify-center gap-3">
|
||||||
|
<Button onclick={() => openInNewTab(documentUrl)} variant="outline">
|
||||||
|
Open in new tab
|
||||||
|
<ExternalLinkIcon class="w-4 h-4 ml-2" />
|
||||||
|
</Button>
|
||||||
|
<Button download={fileName} href={documentUrl}>
|
||||||
|
Download
|
||||||
|
<DownloadIcon class="w-4 h-4 ml-2" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Document details -->
|
||||||
|
<div class="bg-card border border-border rounded-lg p-4">
|
||||||
|
<h4 class="font-medium mb-3">Document Details</h4>
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<div class="text-muted-foreground">Type</div>
|
||||||
|
<div class="font-medium">{fileDetails.label}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-muted-foreground">Size</div>
|
||||||
|
<div class="font-medium">{formatFileSize(fileSize)}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-muted-foreground">Format</div>
|
||||||
|
<div class="font-medium">{mimeType}</div>
|
||||||
|
</div>
|
||||||
|
{#if fileName.includes('.')}
|
||||||
|
<div>
|
||||||
|
<div class="text-muted-foreground">Extension</div>
|
||||||
|
<div class="font-medium">.{fileName.split('.').pop()?.toUpperCase()}</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
97
src/lib/components/ui/file-preview/file-preview.svelte
Normal file
97
src/lib/components/ui/file-preview/file-preview.svelte
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { Attachment } from '$lib/types';
|
||||||
|
import ImagePreview from './image-preview.svelte';
|
||||||
|
import VideoPreview from './video-preview.svelte';
|
||||||
|
import AudioPreview from './audio-preview.svelte';
|
||||||
|
import DocumentPreview from './document-preview.svelte';
|
||||||
|
import { cn } from '$lib/utils/utils';
|
||||||
|
import { formatFileSize } from '$lib/utils/file';
|
||||||
|
|
||||||
|
let { attachment, isUserMessage = false, compact = false } = $props<{
|
||||||
|
attachment: Attachment;
|
||||||
|
isUserMessage?: boolean;
|
||||||
|
compact?: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
let fileType = $derived(attachment.type);
|
||||||
|
let fileName = $derived(attachment.fileName);
|
||||||
|
let fileSize = $derived(attachment.size);
|
||||||
|
let mimeType = $derived(attachment.mimeType);
|
||||||
|
|
||||||
|
// Get appropriate icon based on file type
|
||||||
|
function getFileIcon() {
|
||||||
|
switch (fileType) {
|
||||||
|
case 'image':
|
||||||
|
return '🖼️';
|
||||||
|
case 'video':
|
||||||
|
return '🎥';
|
||||||
|
case 'audio':
|
||||||
|
return '🎵';
|
||||||
|
case 'document':
|
||||||
|
return '📄';
|
||||||
|
default:
|
||||||
|
return '📎';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class={cn(
|
||||||
|
'group relative flex flex-col overflow-hidden rounded-lg border transition-all duration-200',
|
||||||
|
compact
|
||||||
|
? 'bg-background/50 border-border/50 p-2 hover:bg-accent/5'
|
||||||
|
: 'bg-card border-border p-4 hover:border-border/80 hover:shadow-sm',
|
||||||
|
isUserMessage && 'bg-primary/5 border-primary/20'
|
||||||
|
)}
|
||||||
|
role="figure"
|
||||||
|
aria-label={`File attachment: ${fileName}`}
|
||||||
|
>
|
||||||
|
{#if fileType === 'image'}
|
||||||
|
<ImagePreview
|
||||||
|
{attachment}
|
||||||
|
{isUserMessage}
|
||||||
|
{compact}
|
||||||
|
alt={fileName}
|
||||||
|
/>
|
||||||
|
{:else if fileType === 'video'}
|
||||||
|
<VideoPreview
|
||||||
|
{attachment}
|
||||||
|
{isUserMessage}
|
||||||
|
{compact}
|
||||||
|
/>
|
||||||
|
{:else if fileType === 'audio'}
|
||||||
|
<AudioPreview
|
||||||
|
{attachment}
|
||||||
|
{isUserMessage}
|
||||||
|
{compact}
|
||||||
|
/>
|
||||||
|
{:else if fileType === 'document'}
|
||||||
|
<DocumentPreview
|
||||||
|
{attachment}
|
||||||
|
{isUserMessage}
|
||||||
|
{compact}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<!-- Fallback for unknown file types -->
|
||||||
|
<div class="flex items-center gap-3 p-4">
|
||||||
|
<div class="flex-shrink-0 text-2xl">{getFileIcon()}</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="text-sm font-medium text-foreground truncate">{fileName}</div>
|
||||||
|
<div class="text-xs text-muted-foreground">
|
||||||
|
{formatFileSize(fileSize)} • {mimeType}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href={attachment.url}
|
||||||
|
download={fileName}
|
||||||
|
class="flex-shrink-0 p-2 rounded hover:bg-accent transition-colors"
|
||||||
|
aria-label={`Download ${fileName}`}
|
||||||
|
>
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
149
src/lib/components/ui/file-preview/image-preview.svelte
Normal file
149
src/lib/components/ui/file-preview/image-preview.svelte
Normal file
|
|
@ -0,0 +1,149 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { Attachment } from '$lib/types';
|
||||||
|
import { cn } from '$lib/utils/utils';
|
||||||
|
import { Modal } from '$lib/components/ui/modal';
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import DownloadIcon from '~icons/lucide/download';
|
||||||
|
import ExternalLinkIcon from '~icons/lucide/external-link';
|
||||||
|
import XIcon from '~icons/lucide/x';
|
||||||
|
import Tooltip from '$lib/components/ui/tooltip.svelte';
|
||||||
|
import { openInNewTab } from '$lib/utils/file';
|
||||||
|
|
||||||
|
let { attachment, isUserMessage = false, compact = false, alt = '' } = $props<{
|
||||||
|
attachment: Attachment;
|
||||||
|
isUserMessage?: boolean;
|
||||||
|
compact?: boolean;
|
||||||
|
alt?: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
let modalOpen = $state(false);
|
||||||
|
|
||||||
|
let imageUrl = $derived(attachment.url);
|
||||||
|
let fileName = $derived(attachment.fileName);
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="relative group">
|
||||||
|
{#if compact}
|
||||||
|
<!-- Compact view for inline display -->
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
class="relative overflow-hidden rounded cursor-pointer hover:opacity-90 transition-opacity"
|
||||||
|
onclick={() => (modalOpen = true)}
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
onkeydown={(e) => e.key === 'Enter' && (modalOpen = true)}
|
||||||
|
aria-label={`View full size image: ${fileName}`}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={imageUrl}
|
||||||
|
alt={alt}
|
||||||
|
class="w-16 h-16 object-cover rounded"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<div class="absolute inset-0 bg-black/20 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||||
|
<svg class="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0zM10 7v3m0 0v3m0-3h3m-3 0H7" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="text-sm font-medium text-foreground truncate">{fileName}</div>
|
||||||
|
<div class="text-xs text-muted-foreground">Image</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- Full view for dedicated image display -->
|
||||||
|
<div
|
||||||
|
class="relative overflow-hidden rounded-lg cursor-pointer hover:opacity-90 transition-opacity"
|
||||||
|
onclick={() => (modalOpen = true)}
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
onkeydown={(e) => e.key === 'Enter' && (modalOpen = true)}
|
||||||
|
aria-label={`View full size image: ${fileName}`}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={imageUrl}
|
||||||
|
alt={alt}
|
||||||
|
class="w-full h-auto max-h-[400px] object-contain rounded-lg bg-muted/20"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<div class="absolute inset-0 bg-black/20 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||||
|
<svg class="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0zM10 7v3m0 0v3m0-3h3m-3 0H7" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Image info overlay -->
|
||||||
|
<div class="mt-2 flex items-center justify-between">
|
||||||
|
<div class="text-sm text-muted-foreground truncate">
|
||||||
|
{fileName}
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href={imageUrl}
|
||||||
|
download={fileName}
|
||||||
|
class="p-1 rounded hover:bg-accent transition-colors"
|
||||||
|
aria-label={`Download ${fileName}`}
|
||||||
|
onclick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Modal for full-size view -->
|
||||||
|
<Modal bind:open={modalOpen} class="modal-large">
|
||||||
|
<div class="flex items-center justify-between p-2">
|
||||||
|
<h2 class="text-lg font-semibold truncate">{fileName}</h2>
|
||||||
|
<div class="flex items-center gap-2 flex-shrink-0">
|
||||||
|
<Tooltip>
|
||||||
|
{#snippet trigger(tooltip)}
|
||||||
|
<Button
|
||||||
|
size="iconSm"
|
||||||
|
variant="outline"
|
||||||
|
download={fileName}
|
||||||
|
href={imageUrl}
|
||||||
|
{...tooltip.trigger}
|
||||||
|
>
|
||||||
|
<DownloadIcon class="size-4" />
|
||||||
|
</Button>
|
||||||
|
{/snippet}
|
||||||
|
Download image
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip>
|
||||||
|
{#snippet trigger(tooltip)}
|
||||||
|
<Button size="iconSm" variant="outline" onclick={() => openInNewTab(imageUrl)} {...tooltip.trigger}>
|
||||||
|
<ExternalLinkIcon class="size-4" />
|
||||||
|
</Button>
|
||||||
|
{/snippet}
|
||||||
|
Open in new tab
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip>
|
||||||
|
{#snippet trigger(tooltip)}
|
||||||
|
<Button
|
||||||
|
size="iconSm"
|
||||||
|
variant="outline"
|
||||||
|
onclick={() => (modalOpen = false)}
|
||||||
|
{...tooltip.trigger}
|
||||||
|
>
|
||||||
|
<XIcon class="size-4" />
|
||||||
|
</Button>
|
||||||
|
{/snippet}
|
||||||
|
Close
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-center p-4 bg-muted/20 rounded-lg min-h-[400px]">
|
||||||
|
<img
|
||||||
|
src={imageUrl}
|
||||||
|
alt={alt}
|
||||||
|
class="max-h-[75vh] max-w-full rounded-lg object-contain shadow-lg"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
321
src/lib/components/ui/file-preview/video-preview.svelte
Normal file
321
src/lib/components/ui/file-preview/video-preview.svelte
Normal file
|
|
@ -0,0 +1,321 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { Attachment } from '$lib/types';
|
||||||
|
import { cn } from '$lib/utils/utils';
|
||||||
|
import { Modal } from '$lib/components/ui/modal';
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import DownloadIcon from '~icons/lucide/download';
|
||||||
|
import ExternalLinkIcon from '~icons/lucide/external-link';
|
||||||
|
import XIcon from '~icons/lucide/x';
|
||||||
|
import MaximizeIcon from '~icons/lucide/maximize-2';
|
||||||
|
import Tooltip from '$lib/components/ui/tooltip.svelte';
|
||||||
|
import { formatFileSize, formatTime, openInNewTab } from '$lib/utils/file';
|
||||||
|
|
||||||
|
let { attachment, isUserMessage = false, compact = false } = $props<{
|
||||||
|
attachment: Attachment;
|
||||||
|
isUserMessage?: boolean;
|
||||||
|
compact?: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
let videoUrl = $derived(attachment.url);
|
||||||
|
let fileName = $derived(attachment.fileName);
|
||||||
|
let fileSize = $derived(attachment.size);
|
||||||
|
|
||||||
|
let isPlaying = $state(false);
|
||||||
|
let currentTime = $state(0);
|
||||||
|
let duration = $state(0);
|
||||||
|
let volume = $state(1);
|
||||||
|
let isMuted = $state(false);
|
||||||
|
let videoElement: HTMLVideoElement | undefined;
|
||||||
|
let modalOpen = $state(false);
|
||||||
|
|
||||||
|
|
||||||
|
const togglePlay = () => {
|
||||||
|
if (videoElement) {
|
||||||
|
if (videoElement.paused) {
|
||||||
|
videoElement.play();
|
||||||
|
isPlaying = true;
|
||||||
|
} else {
|
||||||
|
videoElement.pause();
|
||||||
|
isPlaying = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleMute = () => {
|
||||||
|
if (videoElement) {
|
||||||
|
videoElement.muted = !videoElement.muted;
|
||||||
|
isMuted = videoElement.muted;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTimeUpdate = () => {
|
||||||
|
if (videoElement) {
|
||||||
|
currentTime = videoElement.currentTime;
|
||||||
|
duration = videoElement.duration || 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSeek = (e: Event) => {
|
||||||
|
const target = e.target as HTMLInputElement;
|
||||||
|
const newTime = parseFloat(target.value);
|
||||||
|
if (videoElement) {
|
||||||
|
videoElement.currentTime = newTime;
|
||||||
|
currentTime = newTime;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVolumeChange = (e: Event) => {
|
||||||
|
const target = e.target as HTMLInputElement;
|
||||||
|
const newVolume = parseFloat(target.value);
|
||||||
|
if (videoElement) {
|
||||||
|
videoElement.volume = newVolume;
|
||||||
|
volume = newVolume;
|
||||||
|
isMuted = newVolume === 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
{#if compact}
|
||||||
|
<!-- Compact view for inline display -->
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="relative">
|
||||||
|
<video
|
||||||
|
bind:this={videoElement}
|
||||||
|
src={videoUrl}
|
||||||
|
class="w-16 h-16 object-cover rounded bg-muted/20"
|
||||||
|
poster=""
|
||||||
|
onplay={() => (isPlaying = true)}
|
||||||
|
onpause={() => (isPlaying = false)}
|
||||||
|
ontimeupdate={handleTimeUpdate}
|
||||||
|
onloadedmetadata={() => {
|
||||||
|
if (videoElement) duration = videoElement.duration || 0;
|
||||||
|
}}
|
||||||
|
onvolumechange={() => {
|
||||||
|
if (videoElement) {
|
||||||
|
volume = videoElement.volume;
|
||||||
|
isMuted = videoElement.muted;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onclick={togglePlay}
|
||||||
|
class="absolute inset-0 flex items-center justify-center bg-black/50 rounded opacity-0 hover:opacity-100 transition-opacity"
|
||||||
|
aria-label={isPlaying ? 'Pause video' : 'Play video'}
|
||||||
|
>
|
||||||
|
{#if isPlaying}
|
||||||
|
<svg class="w-4 h-4 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zM7 8a1 1 0 012 0v4a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v4a1 1 0 102 0V8a1 1 0 00-1-1z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
{:else}
|
||||||
|
<svg class="w-4 h-4 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="text-sm font-medium text-foreground truncate">{fileName}</div>
|
||||||
|
<div class="text-xs text-muted-foreground">
|
||||||
|
Video • {formatFileSize(fileSize)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onclick={() => (modalOpen = true)}
|
||||||
|
class="flex-shrink-0 p-1 rounded hover:bg-accent transition-colors"
|
||||||
|
aria-label="Open video in large modal"
|
||||||
|
>
|
||||||
|
<MaximizeIcon class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- Full view for dedicated video display -->
|
||||||
|
<div class="relative overflow-hidden rounded-lg bg-muted/20">
|
||||||
|
<video
|
||||||
|
bind:this={videoElement}
|
||||||
|
src={videoUrl}
|
||||||
|
class="w-full h-auto max-h-[400px] object-contain"
|
||||||
|
controls={false}
|
||||||
|
onplay={() => (isPlaying = true)}
|
||||||
|
onpause={() => (isPlaying = false)}
|
||||||
|
ontimeupdate={handleTimeUpdate}
|
||||||
|
onloadedmetadata={() => {
|
||||||
|
if (videoElement) duration = videoElement.duration || 0;
|
||||||
|
}}
|
||||||
|
onvolumechange={() => {
|
||||||
|
if (videoElement) {
|
||||||
|
volume = videoElement.volume;
|
||||||
|
isMuted = videoElement.muted;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Custom video controls overlay -->
|
||||||
|
<div class="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-4">
|
||||||
|
<!-- Progress bar -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max={duration || 100}
|
||||||
|
value={currentTime}
|
||||||
|
oninput={handleSeek}
|
||||||
|
class="w-full h-1 bg-white/30 rounded-lg appearance-none cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:cursor-pointer"
|
||||||
|
aria-label="Video progress"
|
||||||
|
/>
|
||||||
|
<div class="flex justify-between text-xs text-white/80 mt-1">
|
||||||
|
<span>{formatTime(currentTime)}</span>
|
||||||
|
<span>{formatTime(duration)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Control buttons -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onclick={togglePlay}
|
||||||
|
class="w-8 h-8 bg-white/20 hover:bg-white/30 rounded-full flex items-center justify-center transition-colors"
|
||||||
|
aria-label={isPlaying ? 'Pause video' : 'Play video'}
|
||||||
|
>
|
||||||
|
{#if isPlaying}
|
||||||
|
<svg class="w-4 h-4 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zM7 8a1 1 0 012 0v4a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v4a1 1 0 102 0V8a1 1 0 00-1-1z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
{:else}
|
||||||
|
<svg class="w-4 h-4 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onclick={toggleMute}
|
||||||
|
class="w-8 h-8 bg-white/20 hover:bg-white/30 rounded-full flex items-center justify-center transition-colors"
|
||||||
|
aria-label={isMuted ? 'Unmute video' : 'Mute video'}
|
||||||
|
>
|
||||||
|
{#if isMuted}
|
||||||
|
<svg class="w-4 h-4 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M9.383 3.076A1 1 0 0110 4v12a1 1 0 01-1.707.707L4.586 13H2a1 1 0 01-1-1V8a1 1 0 011-1h2.586l3.707-3.707a1 1 0 011.09-.217zM12.293 7.293a1 1 0 011.414 0L15 8.586l1.293-1.293a1 1 0 111.414 1.414L16.414 10l1.293 1.293a1 1 0 01-1.414 1.414L15 11.414l-1.293 1.293a1 1 0 01-1.414-1.414L13.586 10l-1.293-1.293a1 1 0 010-1.414z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
{:else}
|
||||||
|
<svg class="w-4 h-4 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M9.383 3.076A1 1 0 0110 4v12a1 1 0 01-1.707.707L4.586 13H2a1 1 0 01-1-1V8a1 1 0 011-1h2.586l3.707-3.707a1 1 0 011.09-.217zM12.293 7.293a1 1 0 011.414 0L15 8.586l1.293-1.293a1 1 0 111.414 1.414L16.414 10l1.293 1.293a1 1 0 01-1.414 1.414L15 11.414l-1.293 1.293a1 1 0 01-1.414-1.414L13.586 10l-1.293-1.293a1 1 0 010-1.414z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="1"
|
||||||
|
step="0.1"
|
||||||
|
value={volume}
|
||||||
|
oninput={handleVolumeChange}
|
||||||
|
class="w-16 h-1 bg-white/30 rounded-lg appearance-none cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-2 [&::-webkit-slider-thumb]:h-2 [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:cursor-pointer"
|
||||||
|
aria-label="Volume control"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href={videoUrl}
|
||||||
|
download={fileName}
|
||||||
|
class="w-8 h-8 bg-white/20 hover:bg-white/30 rounded-full flex items-center justify-center transition-colors"
|
||||||
|
aria-label={`Download ${fileName}`}
|
||||||
|
>
|
||||||
|
<svg class="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Video info -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="text-sm text-muted-foreground">
|
||||||
|
{fileName} • {formatFileSize(fileSize)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onclick={() => (modalOpen = true)}
|
||||||
|
class="flex-shrink-0 p-2 rounded hover:bg-accent transition-colors"
|
||||||
|
aria-label="Open video in large modal"
|
||||||
|
>
|
||||||
|
<MaximizeIcon class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Modal for full-screen video viewing -->
|
||||||
|
<Modal bind:open={modalOpen} class="modal-large">
|
||||||
|
<div class="flex items-center justify-between p-2">
|
||||||
|
<h2 class="text-lg font-semibold truncate">{fileName}</h2>
|
||||||
|
<div class="flex items-center gap-2 flex-shrink-0">
|
||||||
|
<Tooltip>
|
||||||
|
{#snippet trigger(tooltip)}
|
||||||
|
<Button
|
||||||
|
size="iconSm"
|
||||||
|
variant="outline"
|
||||||
|
download={fileName}
|
||||||
|
href={videoUrl}
|
||||||
|
{...tooltip.trigger}
|
||||||
|
>
|
||||||
|
<DownloadIcon class="size-4" />
|
||||||
|
</Button>
|
||||||
|
{/snippet}
|
||||||
|
Download video
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip>
|
||||||
|
{#snippet trigger(tooltip)}
|
||||||
|
<Button size="iconSm" variant="outline" onclick={() => openInNewTab(videoUrl)} {...tooltip.trigger}>
|
||||||
|
<ExternalLinkIcon class="size-4" />
|
||||||
|
</Button>
|
||||||
|
{/snippet}
|
||||||
|
Open in new tab
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip>
|
||||||
|
{#snippet trigger(tooltip)}
|
||||||
|
<Button
|
||||||
|
size="iconSm"
|
||||||
|
variant="outline"
|
||||||
|
onclick={() => (modalOpen = false)}
|
||||||
|
{...tooltip.trigger}
|
||||||
|
>
|
||||||
|
<XIcon class="size-4" />
|
||||||
|
</Button>
|
||||||
|
{/snippet}
|
||||||
|
Close
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-center p-4 bg-black rounded-lg min-h-[500px]">
|
||||||
|
<video
|
||||||
|
bind:this={videoElement}
|
||||||
|
src={videoUrl}
|
||||||
|
class="max-h-[80vh] max-w-full rounded-lg"
|
||||||
|
controls={true}
|
||||||
|
autoplay={false}
|
||||||
|
onplay={() => (isPlaying = true)}
|
||||||
|
onpause={() => (isPlaying = false)}
|
||||||
|
ontimeupdate={() => {
|
||||||
|
if (videoElement) {
|
||||||
|
currentTime = videoElement.currentTime;
|
||||||
|
duration = videoElement.duration || 0;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onloadedmetadata={() => {
|
||||||
|
if (videoElement) {
|
||||||
|
duration = videoElement.duration || 0;
|
||||||
|
videoElement.volume = volume;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onvolumechange={() => {
|
||||||
|
if (videoElement) {
|
||||||
|
volume = videoElement.volume;
|
||||||
|
isMuted = videoElement.muted;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
36
src/lib/utils/file.ts
Normal file
36
src/lib/utils/file.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
/**
|
||||||
|
* Utility functions for file operations and formatting
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format file size in bytes to human readable string
|
||||||
|
*/
|
||||||
|
export function formatFileSize(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format time in seconds to MM:SS format
|
||||||
|
*/
|
||||||
|
export function formatTime(seconds: number): string {
|
||||||
|
const mins = Math.floor(seconds / 60);
|
||||||
|
const secs = Math.floor(seconds % 60);
|
||||||
|
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open URL in new tab
|
||||||
|
*/
|
||||||
|
export function openInNewTab(url: string): void {
|
||||||
|
window.open(url, '_blank');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get file extension from filename
|
||||||
|
*/
|
||||||
|
export function getFileExtension(fileName: string): string {
|
||||||
|
return fileName.split('.').pop()?.toLowerCase() || '';
|
||||||
|
}
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
import { CopyButton } from '$lib/components/ui/copy-button';
|
import { CopyButton } from '$lib/components/ui/copy-button';
|
||||||
import '../../../markdown.css';
|
import '../../../markdown.css';
|
||||||
import MarkdownRenderer from './markdown-renderer.svelte';
|
import MarkdownRenderer from './markdown-renderer.svelte';
|
||||||
import { ImageModal } from '$lib/components/ui/image-modal';
|
import FilePreview from '$lib/components/ui/file-preview/file-preview.svelte';
|
||||||
import { sanitizeHtml } from '$lib/utils/markdown-it';
|
import { sanitizeHtml } from '$lib/utils/markdown-it';
|
||||||
import { on } from 'svelte/events';
|
import { on } from 'svelte/events';
|
||||||
import { isHtmlElement } from '$lib/utils/is';
|
import { isHtmlElement } from '$lib/utils/is';
|
||||||
|
|
@ -46,19 +46,6 @@
|
||||||
|
|
||||||
let { message }: Props = $props();
|
let { message }: Props = $props();
|
||||||
|
|
||||||
let imageModal = $state<{ open: boolean; imageUrl: string; fileName: string }>({
|
|
||||||
open: false,
|
|
||||||
imageUrl: '',
|
|
||||||
fileName: '',
|
|
||||||
});
|
|
||||||
|
|
||||||
function openImageModal(imageUrl: string, fileName: string) {
|
|
||||||
imageModal = {
|
|
||||||
open: true,
|
|
||||||
imageUrl,
|
|
||||||
fileName,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createBranchedConversation() {
|
async function createBranchedConversation() {
|
||||||
const res = await ResultAsync.fromPromise(
|
const res = await ResultAsync.fromPromise(
|
||||||
|
|
@ -132,20 +119,32 @@
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{#if message.images && message.images.length > 0}
|
{#if message.attachments && message.attachments.length > 0}
|
||||||
|
<div class="mb-2 flex flex-wrap gap-2">
|
||||||
|
{#each message.attachments as attachment (attachment.storage_id)}
|
||||||
|
<FilePreview
|
||||||
|
{attachment}
|
||||||
|
isUserMessage={message.role === 'user'}
|
||||||
|
compact={true}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else if message.images && message.images.length > 0}
|
||||||
|
<!-- Legacy image support -->
|
||||||
<div class="mb-2 flex flex-wrap gap-2">
|
<div class="mb-2 flex flex-wrap gap-2">
|
||||||
{#each message.images as image (image.storage_id)}
|
{#each message.images as image (image.storage_id)}
|
||||||
<button
|
<FilePreview
|
||||||
type="button"
|
attachment={{
|
||||||
onclick={() => openImageModal(image.url, image.fileName || 'image')}
|
type: 'image',
|
||||||
class="rounded-lg"
|
url: image.url,
|
||||||
>
|
fileName: image.fileName || 'image',
|
||||||
<img
|
mimeType: image.mimeType || 'image/jpeg',
|
||||||
src={image.url}
|
size: image.size || 0,
|
||||||
alt={image.fileName || 'Uploaded'}
|
storage_id: image.storage_id
|
||||||
class="max-w-xs rounded-lg transition-opacity hover:opacity-80"
|
}}
|
||||||
|
isUserMessage={message.role === 'user'}
|
||||||
|
compact={true}
|
||||||
/>
|
/>
|
||||||
</button>
|
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -309,13 +308,6 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if message.images && message.images.length > 0}
|
|
||||||
<ImageModal
|
|
||||||
bind:open={imageModal.open}
|
|
||||||
imageUrl={imageModal.imageUrl}
|
|
||||||
fileName={imageModal.fileName}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#snippet siteIcon({ url }: { url: URL })}
|
{#snippet siteIcon({ url }: { url: URL })}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue