From 3f38633863edf93e10fd9e81c52b409a9ac9068b Mon Sep 17 00:00:00 2001 From: Valknar XXX Date: Tue, 28 Oct 2025 10:42:06 +0100 Subject: [PATCH] feat: add comprehensive analytics dashboard for content creators MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend changes: - Added /sexy/analytics endpoint to fetch detailed creator analytics - Calculates total likes, plays, completion rates, and avg watch times - Groups analytics by date for timeline visualization - Provides video-specific performance metrics Frontend changes: - Added Analytics TypeScript types and service function - Created Analytics tab in /me dashboard (visible only for Models) - Displays overview stats: total videos, likes, and plays - Added detailed video performance table with: - Individual video metrics - Color-coded completion rates (green >70%, yellow >40%, red <40%) - Average watch time per video - Links to video pages 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- packages/bundle/src/endpoint/index.ts | 125 ++++++++++++++++++ packages/frontend/src/lib/services.ts | 18 ++- packages/frontend/src/lib/types.ts | 21 +++ .../frontend/src/routes/me/+page.server.ts | 9 +- packages/frontend/src/routes/me/+page.svelte | 107 ++++++++++++++- 5 files changed, 277 insertions(+), 3 deletions(-) diff --git a/packages/bundle/src/endpoint/index.ts b/packages/bundle/src/endpoint/index.ts index 4656a4b..d5fcff0 100644 --- a/packages/bundle/src/endpoint/index.ts +++ b/packages/bundle/src/endpoint/index.ts @@ -421,5 +421,130 @@ export default { res.status(500).json({ error: error.message || "Failed to update play" }); } }); + + // GET /sexy/analytics - Get analytics for the authenticated user's content + router.get("/analytics", async (req, res) => { + const accountability = req.accountability; + if (!accountability?.user) { + return res.status(401).json({ error: "Unauthorized" }); + } + + try { + const userId = accountability.user; + + // Get all videos by this user + const videosService = new ItemsService("sexy_videos", { + schema: await getSchema(), + }); + + const videos = await videosService.readByQuery({ + filter: { + models: { + directus_users_id: { + _eq: userId, + }, + }, + }, + fields: ["id", "title", "slug", "likes_count", "plays_count", "upload_date"], + limit: -1, + }); + + if (videos.length === 0) { + return res.json({ + total_videos: 0, + total_likes: 0, + total_plays: 0, + videos: [], + }); + } + + const videoIds = videos.map((v) => v.id); + + // Get play analytics + const playsService = new ItemsService("sexy_video_plays", { + schema: await getSchema(), + }); + + const plays = await playsService.readByQuery({ + filter: { + video_id: { + _in: videoIds, + }, + }, + fields: ["video_id", "date_created", "duration_watched", "completed"], + limit: -1, + }); + + // Get like analytics + const likesService = new ItemsService("sexy_video_likes", { + schema: await getSchema(), + }); + + const likes = await likesService.readByQuery({ + filter: { + video_id: { + _in: videoIds, + }, + }, + fields: ["video_id", "date_created"], + limit: -1, + }); + + // Calculate totals + const totalLikes = videos.reduce((sum, v) => sum + (v.likes_count || 0), 0); + const totalPlays = videos.reduce((sum, v) => sum + (v.plays_count || 0), 0); + + // Group plays by date for timeline + const playsByDate = plays.reduce((acc, play) => { + const date = new Date(play.date_created).toISOString().split("T")[0]; + if (!acc[date]) acc[date] = 0; + acc[date]++; + return acc; + }, {}); + + // Group likes by date for timeline + const likesByDate = likes.reduce((acc, like) => { + const date = new Date(like.date_created).toISOString().split("T")[0]; + if (!acc[date]) acc[date] = 0; + acc[date]++; + return acc; + }, {}); + + // Video-specific analytics + const videoAnalytics = videos.map((video) => { + const videoPlays = plays.filter((p) => p.video_id === video.id); + const completedPlays = videoPlays.filter((p) => p.completed).length; + const avgWatchTime = + videoPlays.length > 0 + ? videoPlays.reduce((sum, p) => sum + (p.duration_watched || 0), 0) / + videoPlays.length + : 0; + + return { + id: video.id, + title: video.title, + slug: video.slug, + upload_date: video.upload_date, + likes: video.likes_count || 0, + plays: video.plays_count || 0, + completed_plays: completedPlays, + completion_rate: video.plays_count ? (completedPlays / video.plays_count) * 100 : 0, + avg_watch_time: Math.round(avgWatchTime), + }; + }); + + res.json({ + total_videos: videos.length, + total_likes: totalLikes, + total_plays: totalPlays, + plays_by_date: playsByDate, + likes_by_date: likesByDate, + videos: videoAnalytics, + }); + } catch (error: any) { + console.error("Analytics error:", error); + res.status(500).json({ error: error.message || "Failed to get analytics" }); + } + }); }, }; diff --git a/packages/frontend/src/lib/services.ts b/packages/frontend/src/lib/services.ts index 748fd4b..f1a4269 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, VideoLikeStatus, VideoLikeResponse, VideoPlayResponse } from "$lib/types"; +import type { Analytics, Article, Model, Recording, Stats, User, Video, VideoLikeStatus, VideoLikeResponse, VideoPlayResponse } from "$lib/types"; import { PUBLIC_URL } from "$env/static/public"; import { logger } from "$lib/logger"; @@ -722,3 +722,19 @@ export async function updateVideoPlay( { videoId, playId, durationWatched, completed } ); } + +export async function getAnalytics(fetch?: typeof globalThis.fetch) { + return loggedApiCall( + "getAnalytics", + async () => { + const directus = getDirectusInstance(fetch); + return directus.request( + customEndpoint({ + method: "GET", + path: "/sexy/analytics", + }) + ); + }, + {} + ); +} diff --git a/packages/frontend/src/lib/types.ts b/packages/frontend/src/lib/types.ts index 46741b6..98daf93 100644 --- a/packages/frontend/src/lib/types.ts +++ b/packages/frontend/src/lib/types.ts @@ -180,3 +180,24 @@ export interface VideoPlayResponse { play_id: string; plays_count: number; } + +export interface VideoAnalytics { + id: string; + title: string; + slug: string; + upload_date: Date; + likes: number; + plays: number; + completed_plays: number; + completion_rate: number; + avg_watch_time: number; +} + +export interface Analytics { + total_videos: number; + total_likes: number; + total_plays: number; + plays_by_date: Record; + likes_by_date: Record; + videos: VideoAnalytics[]; +} diff --git a/packages/frontend/src/routes/me/+page.server.ts b/packages/frontend/src/routes/me/+page.server.ts index b9609e3..b68c5dd 100644 --- a/packages/frontend/src/routes/me/+page.server.ts +++ b/packages/frontend/src/routes/me/+page.server.ts @@ -1,13 +1,20 @@ -import { getFolders, getRecordings } from "$lib/services"; +import { getAnalytics, getFolders, getRecordings } from "$lib/services"; +import { isModel } from "$lib/directus"; export async function load({ locals, fetch }) { const recordings = locals.authStatus.authenticated ? await getRecordings(fetch).catch(() => []) : []; + const analytics = + locals.authStatus.authenticated && isModel(locals.authStatus.user) + ? await getAnalytics(fetch).catch(() => null) + : null; + return { authStatus: locals.authStatus, folders: await getFolders(fetch), recordings, + analytics, }; } diff --git a/packages/frontend/src/routes/me/+page.svelte b/packages/frontend/src/routes/me/+page.svelte index f06832c..1f3c6b2 100644 --- a/packages/frontend/src/routes/me/+page.svelte +++ b/packages/frontend/src/routes/me/+page.svelte @@ -234,7 +234,7 @@ onMount(() => { - + {$_("me.settings.title")} @@ -243,6 +243,12 @@ onMount(() => { {$_("me.recordings.title")} + {#if data.analytics} + + + Analytics + + {/if} @@ -550,6 +556,105 @@ onMount(() => { {/if} + + + {#if data.analytics} + +
+

+ Analytics Dashboard +

+

+ Track your content performance and audience engagement +

+
+ + +
+ + + + + Total Videos + + + +

{data.analytics.total_videos}

+
+
+ + + + + Total Likes + + + +

{data.analytics.total_likes.toLocaleString()}

+
+
+ + + + + Total Plays + + + +

{data.analytics.total_plays.toLocaleString()}

+
+
+
+ + + + + Video Performance + Detailed metrics for each video + + +
+ + + + + + + + + + + + {#each data.analytics.videos as video} + + + + + + + + {/each} + +
TitleLikesPlaysCompletion RateAvg Watch Time
+ + {video.title} + + + {video.likes} + + {video.plays} + + + {video.completion_rate.toFixed(1)}% + + + {Math.floor(video.avg_watch_time / 60)}:{(video.avg_watch_time % 60).toString().padStart(2, '0')} +
+
+
+
+
+ {/if}