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

@@ -1,6 +1,8 @@
<script lang="ts">
import { Button } from '$lib/components/button';
import { Badge } from '$lib/components/badge';
import LikeButton from '$lib/components/LikeButton.svelte';
import VideoPlayer from '$lib/components/VideoPlayer.svelte';
interface FindCardProps {
id: string;
@@ -16,11 +18,23 @@
url: string;
thumbnailUrl: string;
}>;
likeCount?: number;
isLiked?: boolean;
onExplore?: (id: string) => void;
}
let { id, title, description, category, locationName, user, media, onExplore }: FindCardProps =
$props();
let {
id,
title,
description,
category,
locationName,
user,
media,
likeCount = 0,
isLiked = false,
onExplore
}: FindCardProps = $props();
function handleExplore() {
onExplore?.(id);
@@ -43,9 +57,14 @@
class="media-image"
/>
{:else}
<video src={media[0].url} poster={media[0].thumbnailUrl} muted class="media-video">
<track kind="captions" />
</video>
<VideoPlayer
src={media[0].url}
poster={media[0].thumbnailUrl}
muted={true}
autoplay={false}
controls={false}
class="media-video"
/>
{/if}
{:else}
<div class="no-media">
@@ -110,6 +129,9 @@
<span class="author-text">@{user.username}</span>
</div>
</div>
<div class="like-container">
<LikeButton findId={id} {isLiked} {likeCount} size="sm" />
</div>
</div>
</div>
@@ -137,13 +159,17 @@
overflow: hidden;
}
.media-image,
.media-video {
.media-image {
width: 100%;
height: 100%;
object-fit: cover;
}
:global(.media-video) {
width: 100%;
height: 100%;
}
.no-media {
width: 100%;
height: 100%;
@@ -195,6 +221,10 @@
align-items: center;
}
.like-container {
flex-shrink: 0;
}
.location-info {
display: flex;
flex-direction: column;

View File

@@ -1,5 +1,7 @@
<script lang="ts">
import Modal from '$lib/components/Modal.svelte';
import LikeButton from '$lib/components/LikeButton.svelte';
import VideoPlayer from '$lib/components/VideoPlayer.svelte';
interface Find {
id: string;
@@ -19,6 +21,8 @@
url: string;
thumbnailUrl: string;
}>;
likeCount?: number;
isLiked?: boolean;
}
interface Props {
@@ -112,15 +116,11 @@
{#if find.media[currentMediaIndex].type === 'photo'}
<img src={find.media[currentMediaIndex].url} alt={find.title} class="media-image" />
{:else}
<video
<VideoPlayer
src={find.media[currentMediaIndex].url}
controls
class="media-video"
poster={find.media[currentMediaIndex].thumbnailUrl}
>
<track kind="captions" />
Your browser does not support the video tag.
</video>
class="media-video"
/>
{/if}
{#if find.media.length > 1}
@@ -186,6 +186,14 @@
{/if}
<div class="actions">
<LikeButton
findId={find.id}
isLiked={find.isLiked || false}
likeCount={find.likeCount || 0}
size="default"
class="like-action"
/>
<button class="button primary" onclick={getDirections}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<path
@@ -215,16 +223,6 @@
stroke-linecap="round"
stroke-linejoin="round"
/>
<line
x1="12"
y1="2"
x2="12"
y2="15"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
Share
</button>
@@ -293,13 +291,17 @@
background: #f5f5f5;
}
.media-image,
.media-video {
.media-image {
width: 100%;
height: 100%;
object-fit: cover;
}
:global(.media-video) {
width: 100%;
height: 100%;
}
.media-nav {
position: absolute;
top: 50%;
@@ -383,6 +385,10 @@
justify-content: center;
}
.actions :global(.like-action) {
flex: 0 0 auto;
}
@media (max-width: 640px) {
.find-title {
font-size: 1.3rem;

View File

@@ -10,6 +10,8 @@
user: {
username: string;
};
likeCount?: number;
isLiked?: boolean;
media?: Array<{
type: string;
url: string;
@@ -58,6 +60,8 @@
locationName={find.locationName}
user={find.user}
media={find.media}
likeCount={find.likeCount}
isLiked={find.isLiked}
onExplore={handleFindExplore}
/>
{/each}

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>

View File

@@ -0,0 +1,227 @@
<script lang="ts">
import { Button } from '$lib/components/button';
import { Play, Pause, Volume2, VolumeX, Maximize } from '@lucide/svelte';
interface Props {
src: string;
poster?: string;
class?: string;
autoplay?: boolean;
muted?: boolean;
controls?: boolean;
}
let {
src,
poster,
class: className = '',
autoplay = false,
muted = false,
controls = true
}: Props = $props();
let videoElement: HTMLVideoElement;
let isPlaying = $state(false);
let isMuted = $state(muted);
let currentTime = $state(0);
let duration = $state(0);
let isLoading = $state(true);
let showControls = $state(true);
let controlsTimeout: ReturnType<typeof setTimeout>;
function togglePlayPause() {
if (isPlaying) {
videoElement.pause();
} else {
videoElement.play();
}
}
function toggleMute() {
videoElement.muted = !videoElement.muted;
isMuted = videoElement.muted;
}
function handleTimeUpdate() {
currentTime = videoElement.currentTime;
}
function handleLoadedMetadata() {
duration = videoElement.duration;
isLoading = false;
}
function handlePlay() {
isPlaying = true;
}
function handlePause() {
isPlaying = false;
}
function handleSeek(event: Event) {
const target = event.target as HTMLInputElement;
const time = (parseFloat(target.value) / 100) * duration;
videoElement.currentTime = time;
}
function handleMouseMove() {
if (!controls) return;
showControls = true;
clearTimeout(controlsTimeout);
controlsTimeout = setTimeout(() => {
if (isPlaying) {
showControls = false;
}
}, 3000);
}
function toggleFullscreen() {
if (videoElement.requestFullscreen) {
videoElement.requestFullscreen();
}
}
function formatTime(time: number): string {
const minutes = Math.floor(time / 60);
const seconds = Math.floor(time % 60);
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
}
</script>
<div
class="group relative overflow-hidden rounded-lg bg-black {className}"
role="application"
onmousemove={handleMouseMove}
onmouseleave={() => {
if (isPlaying && controls) showControls = false;
}}
>
<video
bind:this={videoElement}
class="h-full w-full object-cover"
{src}
{poster}
{autoplay}
muted={isMuted}
preload="metadata"
onplay={handlePlay}
onpause={handlePause}
ontimeupdate={handleTimeUpdate}
onloadedmetadata={handleLoadedMetadata}
onclick={togglePlayPause}
>
<track kind="captions" />
</video>
{#if isLoading}
<div class="absolute inset-0 flex items-center justify-center bg-black/50">
<div
class="h-8 w-8 animate-spin rounded-full border-2 border-white border-t-transparent"
></div>
</div>
{/if}
{#if controls && showControls}
<div
class="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent opacity-0 transition-opacity group-hover:opacity-100"
>
<!-- Center play/pause button -->
<div class="absolute inset-0 flex items-center justify-center">
<Button
variant="ghost"
size="lg"
class="h-16 w-16 rounded-full bg-black/30 text-white hover:bg-black/50"
onclick={togglePlayPause}
>
{#if isPlaying}
<Pause class="h-8 w-8" />
{:else}
<Play class="ml-1 h-8 w-8" />
{/if}
</Button>
</div>
<!-- Bottom controls -->
<div class="absolute right-0 bottom-0 left-0 p-4">
<!-- Progress bar -->
<div class="mb-2">
<input
type="range"
min="0"
max="100"
value={duration > 0 ? (currentTime / duration) * 100 : 0}
oninput={handleSeek}
class="slider h-1 w-full cursor-pointer appearance-none rounded-lg bg-white/30"
/>
</div>
<!-- Control buttons -->
<div class="flex items-center justify-between text-white">
<div class="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
class="text-white hover:bg-white/20"
onclick={togglePlayPause}
>
{#if isPlaying}
<Pause class="h-4 w-4" />
{:else}
<Play class="h-4 w-4" />
{/if}
</Button>
<Button
variant="ghost"
size="sm"
class="text-white hover:bg-white/20"
onclick={toggleMute}
>
{#if isMuted}
<VolumeX class="h-4 w-4" />
{:else}
<Volume2 class="h-4 w-4" />
{/if}
</Button>
<span class="text-sm">
{formatTime(currentTime)} / {formatTime(duration)}
</span>
</div>
<Button
variant="ghost"
size="sm"
class="text-white hover:bg-white/20"
onclick={toggleFullscreen}
>
<Maximize class="h-4 w-4" />
</Button>
</div>
</div>
</div>
{/if}
</div>
<style>
.slider::-webkit-slider-thumb {
appearance: none;
width: 12px;
height: 12px;
border-radius: 50%;
background: white;
cursor: pointer;
box-shadow: 0 0 2px rgba(0, 0, 0, 0.6);
}
.slider::-moz-range-thumb {
width: 12px;
height: 12px;
border-radius: 50%;
background: white;
cursor: pointer;
border: none;
box-shadow: 0 0 2px rgba(0, 0, 0, 0.6);
}
</style>