feat: add comprehensive analytics dashboard for content creators
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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" });
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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<Analytics>(
|
||||
customEndpoint({
|
||||
method: "GET",
|
||||
path: "/sexy/analytics",
|
||||
})
|
||||
);
|
||||
},
|
||||
{}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<string, number>;
|
||||
likes_by_date: Record<string, number>;
|
||||
videos: VideoAnalytics[];
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -234,7 +234,7 @@ onMount(() => {
|
||||
|
||||
<!-- Dashboard Tabs -->
|
||||
<Tabs bind:value={activeTab} class="w-full">
|
||||
<TabsList class="grid w-full grid-cols-2 max-w-2xl mb-8">
|
||||
<TabsList class="grid w-full {data.analytics ? 'grid-cols-3' : 'grid-cols-2'} max-w-2xl mb-8">
|
||||
<TabsTrigger value="settings" class="flex items-center gap-2">
|
||||
<span class="icon-[ri--settings-4-line] w-4 h-4"></span>
|
||||
{$_("me.settings.title")}
|
||||
@@ -243,6 +243,12 @@ onMount(() => {
|
||||
<span class="icon-[ri--play-list-2-line] w-4 h-4"></span>
|
||||
{$_("me.recordings.title")}
|
||||
</TabsTrigger>
|
||||
{#if data.analytics}
|
||||
<TabsTrigger value="analytics" class="flex items-center gap-2">
|
||||
<span class="icon-[ri--line-chart-line] w-4 h-4"></span>
|
||||
Analytics
|
||||
</TabsTrigger>
|
||||
{/if}
|
||||
</TabsList>
|
||||
|
||||
<!-- Settings Tab -->
|
||||
@@ -550,6 +556,105 @@ onMount(() => {
|
||||
</div>
|
||||
{/if}
|
||||
</TabsContent>
|
||||
|
||||
<!-- Analytics Tab -->
|
||||
{#if data.analytics}
|
||||
<TabsContent value="analytics" class="space-y-6">
|
||||
<div class="mb-6">
|
||||
<h2 class="text-2xl font-bold text-card-foreground">
|
||||
Analytics Dashboard
|
||||
</h2>
|
||||
<p class="text-muted-foreground">
|
||||
Track your content performance and audience engagement
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Overview Stats -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<Card class="bg-card/50 border-primary/20">
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2">
|
||||
<span class="icon-[ri--video-line] w-5 h-5 text-primary"></span>
|
||||
Total Videos
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p class="text-3xl font-bold">{data.analytics.total_videos}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card class="bg-card/50 border-primary/20">
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2">
|
||||
<span class="icon-[ri--heart-fill] w-5 h-5 text-primary"></span>
|
||||
Total Likes
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p class="text-3xl font-bold">{data.analytics.total_likes.toLocaleString()}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card class="bg-card/50 border-primary/20">
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2">
|
||||
<span class="icon-[ri--play-fill] w-5 h-5 text-primary"></span>
|
||||
Total Plays
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p class="text-3xl font-bold">{data.analytics.total_plays.toLocaleString()}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Video Performance Table -->
|
||||
<Card class="bg-card/50 border-primary/20">
|
||||
<CardHeader>
|
||||
<CardTitle>Video Performance</CardTitle>
|
||||
<CardDescription>Detailed metrics for each video</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
<tr class="border-b border-border">
|
||||
<th class="text-left p-3">Title</th>
|
||||
<th class="text-right p-3">Likes</th>
|
||||
<th class="text-right p-3">Plays</th>
|
||||
<th class="text-right p-3">Completion Rate</th>
|
||||
<th class="text-right p-3">Avg Watch Time</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each data.analytics.videos as video}
|
||||
<tr class="border-b border-border/50 hover:bg-primary/5 transition-colors">
|
||||
<td class="p-3">
|
||||
<a href="/videos/{video.slug}" class="hover:text-primary transition-colors">
|
||||
{video.title}
|
||||
</a>
|
||||
</td>
|
||||
<td class="text-right p-3 font-medium">
|
||||
{video.likes}
|
||||
</td>
|
||||
<td class="text-right p-3 font-medium">
|
||||
{video.plays}
|
||||
</td>
|
||||
<td class="text-right p-3">
|
||||
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs {video.completion_rate >= 70 ? 'bg-green-500/20 text-green-500' : video.completion_rate >= 40 ? 'bg-yellow-500/20 text-yellow-500' : 'bg-red-500/20 text-red-500'}">
|
||||
{video.completion_rate.toFixed(1)}%
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-right p-3 text-muted-foreground">
|
||||
{Math.floor(video.avg_watch_time / 60)}:{(video.avg_watch_time % 60).toString().padStart(2, '0')}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
{/if}
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user