feat:video player, like button, and media fallbacks

Add VideoPlayer and LikeButton components with optimistic UI and /server
endpoints for likes. Update media processor to emit WebP and JPEG
fallbacks, store fallback URLs in the DB (migration + snapshot), add
video placeholder asset, and relax CSP media-src for R2.
This commit is contained in:
2025-10-14 18:31:55 +02:00
parent b4515d1d6a
commit 067e228393
19 changed files with 1252 additions and 233 deletions

View File

@@ -0,0 +1,95 @@
<script lang="ts">
import { Button } from '$lib/components/button';
import { Heart } from '@lucide/svelte';
import { toast } from 'svelte-sonner';
interface Props {
findId: string;
isLiked?: boolean;
likeCount?: number;
size?: 'sm' | 'default' | 'lg';
class?: string;
}
let {
findId,
isLiked = false,
likeCount = 0,
size = 'default',
class: className = ''
}: Props = $props();
let isLoading = $state(false);
let currentIsLiked = $state(isLiked);
let currentLikeCount = $state(likeCount);
async function toggleLike() {
if (isLoading) return;
const previousLiked = currentIsLiked;
const previousCount = currentLikeCount;
// Optimistic update
currentIsLiked = !currentIsLiked;
currentLikeCount += currentIsLiked ? 1 : -1;
isLoading = true;
try {
const method = currentIsLiked ? 'POST' : 'DELETE';
const response = await fetch(`/api/finds/${findId}/like`, {
method,
headers: {
'Content-Type': 'application/json'
}
});
if (!response.ok) {
const error = (await response.json()) as { message?: string };
throw new Error(error.message || 'Failed to update like');
}
// Success - optimistic update was correct
} catch (error: unknown) {
// Revert optimistic update on error
currentIsLiked = previousLiked;
currentLikeCount = previousCount;
console.error('Error updating like:', error);
toast.error('Failed to update like. Please try again.');
} finally {
isLoading = false;
}
}
// Update internal state when props change
$effect(() => {
currentIsLiked = isLiked;
currentLikeCount = likeCount;
});
</script>
<Button
variant="ghost"
{size}
class="group gap-1.5 {className}"
onclick={toggleLike}
disabled={isLoading}
>
<Heart
class="h-4 w-4 transition-all duration-200 {currentIsLiked
? 'scale-110 fill-red-500 text-red-500'
: 'text-gray-500 group-hover:scale-105 group-hover:text-red-400'} {isLoading
? 'animate-pulse'
: ''}"
/>
{#if currentLikeCount > 0}
<span
class="text-sm font-medium transition-colors {currentIsLiked
? 'text-red-500'
: 'text-gray-500 group-hover:text-red-400'}"
>
{currentLikeCount}
</span>
{/if}
</Button>