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:
@@ -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'; " +
|
||||
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
});
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
80
src/routes/api/finds/[findId]/like/+server.ts
Normal file
80
src/routes/api/finds/[findId]/like/+server.ts
Normal 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 });
|
||||
}
|
||||
@@ -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];
|
||||
|
||||
Reference in New Issue
Block a user