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,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];