diff --git a/packages/bundle/src/endpoint/index.ts b/packages/bundle/src/endpoint/index.ts index 8a08517..4656a4b 100644 --- a/packages/bundle/src/endpoint/index.ts +++ b/packages/bundle/src/endpoint/index.ts @@ -249,5 +249,177 @@ export default { res.status(500).json({ error: error.message || "Failed to delete recording" }); } }); + + // POST /sexy/videos/:id/like - Like a video + router.post("/videos/:id/like", async (req, res) => { + const accountability = req.accountability; + if (!accountability?.user) { + return res.status(401).json({ error: "Unauthorized" }); + } + + const videoId = req.params.id; + const userId = accountability.user; + + try { + const likesService = new ItemsService("sexy_video_likes", { + schema: await getSchema(), + accountability, + }); + + // Check if already liked + const existing = await likesService.readByQuery({ + filter: { video_id: videoId, user_id: userId }, + limit: 1, + }); + + if (existing.length > 0) { + return res.status(400).json({ error: "Already liked" }); + } + + // Create like + await likesService.createOne({ + video_id: videoId, + user_id: userId, + }); + + // Increment likes_count + const videosService = new ItemsService("sexy_videos", { + schema: await getSchema(), + }); + const video = await videosService.readOne(videoId); + await videosService.updateOne(videoId, { + likes_count: (video.likes_count || 0) + 1, + }); + + res.json({ liked: true, likes_count: (video.likes_count || 0) + 1 }); + } catch (error: any) { + res.status(500).json({ error: error.message || "Failed to like video" }); + } + }); + + // DELETE /sexy/videos/:id/like - Unlike a video + router.delete("/videos/:id/like", async (req, res) => { + const accountability = req.accountability; + if (!accountability?.user) { + return res.status(401).json({ error: "Unauthorized" }); + } + + const videoId = req.params.id; + const userId = accountability.user; + + try { + const likesService = new ItemsService("sexy_video_likes", { + schema: await getSchema(), + accountability, + }); + + // Find and delete like + const existing = await likesService.readByQuery({ + filter: { video_id: videoId, user_id: userId }, + limit: 1, + }); + + if (existing.length === 0) { + return res.status(400).json({ error: "Not liked" }); + } + + await likesService.deleteOne(existing[0].id); + + // Decrement likes_count + const videosService = new ItemsService("sexy_videos", { + schema: await getSchema(), + }); + const video = await videosService.readOne(videoId); + await videosService.updateOne(videoId, { + likes_count: Math.max((video.likes_count || 0) - 1, 0), + }); + + res.json({ liked: false, likes_count: Math.max((video.likes_count || 0) - 1, 0) }); + } catch (error: any) { + res.status(500).json({ error: error.message || "Failed to unlike video" }); + } + }); + + // GET /sexy/videos/:id/like-status - Get like status for a video + router.get("/videos/:id/like-status", async (req, res) => { + const accountability = req.accountability; + if (!accountability?.user) { + return res.json({ liked: false }); + } + + const videoId = req.params.id; + const userId = accountability.user; + + try { + const likesService = new ItemsService("sexy_video_likes", { + schema: await getSchema(), + accountability, + }); + + const existing = await likesService.readByQuery({ + filter: { video_id: videoId, user_id: userId }, + limit: 1, + }); + + res.json({ liked: existing.length > 0 }); + } catch (error: any) { + res.status(500).json({ error: error.message || "Failed to get like status" }); + } + }); + + // POST /sexy/videos/:id/play - Record a video play + router.post("/videos/:id/play", async (req, res) => { + const accountability = req.accountability; + const videoId = req.params.id; + const { session_id } = req.body; + + try { + const playsService = new ItemsService("sexy_video_plays", { + schema: await getSchema(), + }); + + const videosService = new ItemsService("sexy_videos", { + schema: await getSchema(), + }); + + // Record play + const play = await playsService.createOne({ + video_id: videoId, + user_id: accountability?.user || null, + session_id: session_id || null, + }); + + // Increment plays_count + const video = await videosService.readOne(videoId); + await videosService.updateOne(videoId, { + plays_count: (video.plays_count || 0) + 1, + }); + + res.json({ success: true, play_id: play, plays_count: (video.plays_count || 0) + 1 }); + } catch (error: any) { + res.status(500).json({ error: error.message || "Failed to record play" }); + } + }); + + // PATCH /sexy/videos/:id/play/:playId - Update play progress + router.patch("/videos/:id/play/:playId", async (req, res) => { + const { playId } = req.params; + const { duration_watched, completed } = req.body; + + try { + const playsService = new ItemsService("sexy_video_plays", { + schema: await getSchema(), + }); + + await playsService.updateOne(playId, { + duration_watched, + completed, + }); + + res.json({ success: true }); + } catch (error: any) { + res.status(500).json({ error: error.message || "Failed to update play" }); + } + }); }, }; diff --git a/packages/frontend/src/lib/services.ts b/packages/frontend/src/lib/services.ts index 794c03e..748fd4b 100644 --- a/packages/frontend/src/lib/services.ts +++ b/packages/frontend/src/lib/services.ts @@ -16,7 +16,7 @@ import { readComments, aggregate, } from "@directus/sdk"; -import type { Article, Model, Recording, Stats, User, Video } from "$lib/types"; +import type { Article, Model, Recording, Stats, User, Video, VideoLikeStatus, VideoLikeResponse, VideoPlayResponse } from "$lib/types"; import { PUBLIC_URL } from "$env/static/public"; import { logger } from "$lib/logger"; @@ -630,3 +630,95 @@ export async function getRecording(id: string, fetch?: typeof globalThis.fetch) { id }, ); } + +export async function likeVideo(videoId: string) { + return loggedApiCall( + "likeVideo", + async () => { + const directus = getDirectusInstance(fetch); + return directus.request( + customEndpoint({ + method: "POST", + path: `/sexy/videos/${videoId}/like`, + }) + ); + }, + { videoId } + ); +} + +export async function unlikeVideo(videoId: string) { + return loggedApiCall( + "unlikeVideo", + async () => { + const directus = getDirectusInstance(fetch); + return directus.request( + customEndpoint({ + method: "DELETE", + path: `/sexy/videos/${videoId}/like`, + }) + ); + }, + { videoId } + ); +} + +export async function getVideoLikeStatus(videoId: string, fetch?: typeof globalThis.fetch) { + return loggedApiCall( + "getVideoLikeStatus", + async () => { + const directus = getDirectusInstance(fetch); + return directus.request( + customEndpoint({ + method: "GET", + path: `/sexy/videos/${videoId}/like-status`, + }) + ); + }, + { videoId } + ); +} + +export async function recordVideoPlay(videoId: string, sessionId?: string) { + return loggedApiCall( + "recordVideoPlay", + async () => { + const directus = getDirectusInstance(fetch); + return directus.request( + customEndpoint({ + method: "POST", + path: `/sexy/videos/${videoId}/play`, + body: JSON.stringify({ session_id: sessionId }), + headers: { "Content-Type": "application/json" }, + }) + ); + }, + { videoId } + ); +} + +export async function updateVideoPlay( + videoId: string, + playId: string, + durationWatched: number, + completed: boolean +) { + return loggedApiCall( + "updateVideoPlay", + async () => { + const directus = getDirectusInstance(fetch); + return directus.request( + customEndpoint({ + method: "PATCH", + path: `/sexy/videos/${videoId}/play/${playId}`, + body: JSON.stringify({ + duration_watched: durationWatched, + completed, + }), + headers: { "Content-Type": "application/json" }, + }) + ); + }, + { videoId, playId, durationWatched, completed } + ); +} diff --git a/packages/frontend/src/lib/types.ts b/packages/frontend/src/lib/types.ts index 8246730..46741b6 100644 --- a/packages/frontend/src/lib/types.ts +++ b/packages/frontend/src/lib/types.ts @@ -89,6 +89,9 @@ export interface Video { upload_date: Date; premium?: boolean; featured?: boolean; + likes_count?: number; + plays_count?: number; + views_count?: number; } export interface Comment { @@ -155,3 +158,25 @@ export interface Recording { featured?: boolean; public?: boolean; } + +export interface VideoLikeStatus { + liked: boolean; +} + +export interface VideoPlayRecord { + id: string; + video_id: string; + duration_watched?: number; + completed: boolean; +} + +export interface VideoLikeResponse { + liked: boolean; + likes_count: number; +} + +export interface VideoPlayResponse { + success: boolean; + play_id: string; + plays_count: number; +}