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

@@ -51,6 +51,7 @@ export const handle: Handle = async ({ event, resolve }) => {
"style-src 'self' 'unsafe-inline' fonts.googleapis.com; " +
"font-src 'self' fonts.gstatic.com; " +
"img-src 'self' data: blob: *.openstreetmap.org *.tile.openstreetmap.org *.r2.cloudflarestorage.com; " +
"media-src 'self' *.r2.cloudflarestorage.com; " +
"connect-src 'self' *.openstreetmap.org; " +
"frame-ancestors 'none'; " +
"base-uri 'self'; " +

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>

View File

@@ -45,6 +45,8 @@ export const findMedia = pgTable('find_media', {
type: text('type').notNull(), // 'photo' or 'video'
url: text('url').notNull(),
thumbnailUrl: text('thumbnail_url'),
fallbackUrl: text('fallback_url'), // JPEG fallback for WebP images
fallbackThumbnailUrl: text('fallback_thumbnail_url'), // JPEG fallback for WebP thumbnails
orderIndex: integer('order_index').default(0),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull()
});

View File

@@ -8,15 +8,29 @@ export async function processAndUploadImage(
file: File,
findId: string,
index: number
): Promise<{ url: string; thumbnailUrl: string }> {
): Promise<{
url: string;
thumbnailUrl: string;
fallbackUrl?: string;
fallbackThumbnailUrl?: string;
}> {
const buffer = Buffer.from(await file.arrayBuffer());
// Generate unique filename
const timestamp = Date.now();
const filename = `finds/${findId}/image-${index}-${timestamp}`;
// Process full-size image (resize if too large, optimize)
const processedImage = await sharp(buffer)
// Process full-size image in WebP format (with JPEG fallback)
const processedWebP = await sharp(buffer)
.resize(MAX_IMAGE_SIZE, MAX_IMAGE_SIZE, {
fit: 'inside',
withoutEnlargement: true
})
.webp({ quality: 85, effort: 4 })
.toBuffer();
// Generate JPEG fallback for older browsers
const processedJPEG = await sharp(buffer)
.resize(MAX_IMAGE_SIZE, MAX_IMAGE_SIZE, {
fit: 'inside',
withoutEnlargement: true
@@ -24,8 +38,17 @@ export async function processAndUploadImage(
.jpeg({ quality: 85, progressive: true })
.toBuffer();
// Generate thumbnail
const thumbnail = await sharp(buffer)
// Generate thumbnail in WebP format
const thumbnailWebP = await sharp(buffer)
.resize(THUMBNAIL_SIZE, THUMBNAIL_SIZE, {
fit: 'cover',
position: 'centre'
})
.webp({ quality: 80, effort: 4 })
.toBuffer();
// Generate JPEG thumbnail fallback
const thumbnailJPEG = await sharp(buffer)
.resize(THUMBNAIL_SIZE, THUMBNAIL_SIZE, {
fit: 'cover',
position: 'centre'
@@ -33,38 +56,70 @@ export async function processAndUploadImage(
.jpeg({ quality: 80 })
.toBuffer();
// Upload both to R2
const imageFile = new File([new Uint8Array(processedImage)], `${filename}.jpg`, {
// Upload all variants to R2
const webpFile = new File([new Uint8Array(processedWebP)], `${filename}.webp`, {
type: 'image/webp'
});
const jpegFile = new File([new Uint8Array(processedJPEG)], `${filename}.jpg`, {
type: 'image/jpeg'
});
const thumbFile = new File([new Uint8Array(thumbnail)], `${filename}-thumb.jpg`, {
const thumbWebPFile = new File([new Uint8Array(thumbnailWebP)], `${filename}-thumb.webp`, {
type: 'image/webp'
});
const thumbJPEGFile = new File([new Uint8Array(thumbnailJPEG)], `${filename}-thumb.jpg`, {
type: 'image/jpeg'
});
const [imagePath, thumbPath] = await Promise.all([
uploadToR2(imageFile, `${filename}.jpg`, 'image/jpeg'),
uploadToR2(thumbFile, `${filename}-thumb.jpg`, 'image/jpeg')
const [webpPath, jpegPath, thumbWebPPath, thumbJPEGPath] = await Promise.all([
uploadToR2(webpFile, `${filename}.webp`, 'image/webp'),
uploadToR2(jpegFile, `${filename}.jpg`, 'image/jpeg'),
uploadToR2(thumbWebPFile, `${filename}-thumb.webp`, 'image/webp'),
uploadToR2(thumbJPEGFile, `${filename}-thumb.jpg`, 'image/jpeg')
]);
// Return the R2 paths (not signed URLs) - signed URLs will be generated when needed
return { url: imagePath, thumbnailUrl: thumbPath };
// Return WebP URLs as primary, JPEG as fallback (client can choose based on browser support)
return {
url: webpPath,
thumbnailUrl: thumbWebPPath,
fallbackUrl: jpegPath,
fallbackThumbnailUrl: thumbJPEGPath
};
}
export async function processAndUploadVideo(
file: File,
findId: string,
index: number
): Promise<{ url: string; thumbnailUrl: string }> {
): Promise<{
url: string;
thumbnailUrl: string;
fallbackUrl?: string;
fallbackThumbnailUrl?: string;
}> {
const timestamp = Date.now();
const filename = `finds/${findId}/video-${index}-${timestamp}.mp4`;
const baseFilename = `finds/${findId}/video-${index}-${timestamp}`;
// Upload video directly (no processing on server to save resources)
const videoPath = await uploadToR2(file, filename, 'video/mp4');
// Convert to MP4 if needed for better compatibility
let videoPath: string;
// For video thumbnail, generate on client-side or use placeholder
// This keeps server-side processing minimal
const thumbnailUrl = `/video-placeholder.jpg`; // Use static placeholder
if (file.type === 'video/mp4') {
// Upload MP4 directly
videoPath = await uploadToR2(file, `${baseFilename}.mp4`, 'video/mp4');
} else {
// For other formats, upload as-is for now (future: convert with ffmpeg)
const extension = file.type === 'video/quicktime' ? '.mov' : '.mp4';
videoPath = await uploadToR2(file, `${baseFilename}${extension}`, file.type);
}
// Return the R2 path (not signed URL) - signed URLs will be generated when needed
return { url: videoPath, thumbnailUrl };
// Create a simple thumbnail using a static placeholder for now
// TODO: Implement proper video thumbnail extraction with ffmpeg or client-side canvas
const thumbnailUrl = '/video-placeholder.svg';
return {
url: videoPath,
thumbnailUrl,
// For videos, we can return the same URL as fallback since MP4 has broad support
fallbackUrl: videoPath,
fallbackThumbnailUrl: thumbnailUrl
};
}

View File

@@ -20,6 +20,8 @@
createdAt: string; // Will be converted to Date type, but is a string from api
userId: string;
username: string;
likeCount?: number;
isLikedByUser?: boolean;
media: Array<{
id: string;
findId: string;
@@ -46,6 +48,8 @@
id: string;
username: string;
};
likeCount?: number;
isLiked?: boolean;
media?: Array<{
type: string;
url: string;
@@ -67,6 +71,8 @@
id: string;
username: string;
};
likeCount?: number;
isLiked?: boolean;
media?: Array<{
type: string;
url: string;
@@ -88,6 +94,8 @@
id: serverFind.userId,
username: serverFind.username
},
likeCount: serverFind.likeCount,
isLiked: serverFind.isLikedByUser,
media: serverFind.media?.map(
(m: { type: string; url: string; thumbnailUrl: string | null }) => ({
type: m.type,
@@ -118,6 +126,8 @@
category: find.category,
createdAt: find.createdAt.toISOString(),
user: find.user,
likeCount: find.likeCount,
isLiked: find.isLiked,
media: find.media?.map((m) => ({
type: m.type,
url: m.url,

View File

@@ -1,7 +1,7 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { db } from '$lib/server/db';
import { find, findMedia, user } from '$lib/server/db/schema';
import { find, findMedia, user, findLike } from '$lib/server/db/schema';
import { eq, and, sql, desc } from 'drizzle-orm';
import { encodeBase64url } from '@oslojs/encoding';
import { getSignedR2Url } from '$lib/server/r2';
@@ -51,7 +51,7 @@ export const GET: RequestHandler = async ({ url, locals }) => {
}
}
// Get all finds with filtering
// Get all finds with filtering, like counts, and user's liked status
const finds = await db
.select({
id: find.id,
@@ -64,11 +64,19 @@ export const GET: RequestHandler = async ({ url, locals }) => {
isPublic: find.isPublic,
createdAt: find.createdAt,
userId: find.userId,
username: user.username
username: user.username,
likeCount: sql<number>`COALESCE(COUNT(DISTINCT ${findLike.id}), 0)`,
isLikedByUser: sql<boolean>`CASE WHEN EXISTS(
SELECT 1 FROM ${findLike}
WHERE ${findLike.findId} = ${find.id}
AND ${findLike.userId} = ${locals.user.id}
) THEN 1 ELSE 0 END`
})
.from(find)
.innerJoin(user, eq(find.userId, user.id))
.leftJoin(findLike, eq(find.id, findLike.findId))
.where(whereConditions)
.groupBy(find.id, user.username)
.orderBy(order === 'desc' ? desc(find.createdAt) : find.createdAt)
.limit(100);
@@ -141,7 +149,8 @@ export const GET: RequestHandler = async ({ url, locals }) => {
return {
...findItem,
media: mediaWithSignedUrls
media: mediaWithSignedUrls,
isLikedByUser: Boolean(findItem.isLikedByUser)
};
})
);

View File

@@ -0,0 +1,80 @@
import { json, error } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { findLike, find } from '$lib/server/db/schema';
import { eq, and } from 'drizzle-orm';
import { encodeBase64url } from '@oslojs/encoding';
function generateLikeId(): string {
const bytes = crypto.getRandomValues(new Uint8Array(15));
return encodeBase64url(bytes);
}
export async function POST({
params,
locals
}: {
params: { findId: string };
locals: { user: { id: string } };
}) {
if (!locals.user) {
throw error(401, 'Unauthorized');
}
const findId = params.findId;
if (!findId) {
throw error(400, 'Find ID is required');
}
// Check if find exists
const existingFind = await db.select().from(find).where(eq(find.id, findId)).limit(1);
if (existingFind.length === 0) {
throw error(404, 'Find not found');
}
// Check if user already liked this find
const existingLike = await db
.select()
.from(findLike)
.where(and(eq(findLike.findId, findId), eq(findLike.userId, locals.user.id)))
.limit(1);
if (existingLike.length > 0) {
throw error(409, 'Find already liked');
}
// Create new like
const likeId = generateLikeId();
await db.insert(findLike).values({
id: likeId,
findId,
userId: locals.user.id
});
return json({ success: true, likeId });
}
export async function DELETE({
params,
locals
}: {
params: { findId: string };
locals: { user: { id: string } };
}) {
if (!locals.user) {
throw error(401, 'Unauthorized');
}
const findId = params.findId;
if (!findId) {
throw error(400, 'Find ID is required');
}
// Remove like
await db
.delete(findLike)
.where(and(eq(findLike.findId, findId), eq(findLike.userId, locals.user.id)));
return json({ success: true });
}

View File

@@ -26,7 +26,13 @@ export const POST: RequestHandler = async ({ request, locals }) => {
throw error(400, `Must upload between 1 and ${MAX_FILES} files`);
}
const uploadedMedia: Array<{ type: string; url: string; thumbnailUrl: string }> = [];
const uploadedMedia: Array<{
type: string;
url: string;
thumbnailUrl: string;
fallbackUrl?: string;
fallbackThumbnailUrl?: string;
}> = [];
for (let i = 0; i < files.length; i++) {
const file = files[i];