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:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
95
src/lib/components/LikeButton.svelte
Normal file
95
src/lib/components/LikeButton.svelte
Normal 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>
|
||||
227
src/lib/components/VideoPlayer.svelte
Normal file
227
src/lib/components/VideoPlayer.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user