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" });
|
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,
|
readComments,
|
||||||
aggregate,
|
aggregate,
|
||||||
} from "@directus/sdk";
|
} 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 { PUBLIC_URL } from "$env/static/public";
|
||||||
import { logger } from "$lib/logger";
|
import { logger } from "$lib/logger";
|
||||||
|
|
||||||
@@ -722,3 +722,19 @@ export async function updateVideoPlay(
|
|||||||
{ videoId, playId, durationWatched, completed }
|
{ 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;
|
play_id: string;
|
||||||
plays_count: number;
|
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 }) {
|
export async function load({ locals, fetch }) {
|
||||||
const recordings = locals.authStatus.authenticated
|
const recordings = locals.authStatus.authenticated
|
||||||
? await getRecordings(fetch).catch(() => [])
|
? await getRecordings(fetch).catch(() => [])
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
|
const analytics =
|
||||||
|
locals.authStatus.authenticated && isModel(locals.authStatus.user)
|
||||||
|
? await getAnalytics(fetch).catch(() => null)
|
||||||
|
: null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
authStatus: locals.authStatus,
|
authStatus: locals.authStatus,
|
||||||
folders: await getFolders(fetch),
|
folders: await getFolders(fetch),
|
||||||
recordings,
|
recordings,
|
||||||
|
analytics,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -234,7 +234,7 @@ onMount(() => {
|
|||||||
|
|
||||||
<!-- Dashboard Tabs -->
|
<!-- Dashboard Tabs -->
|
||||||
<Tabs bind:value={activeTab} class="w-full">
|
<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">
|
<TabsTrigger value="settings" class="flex items-center gap-2">
|
||||||
<span class="icon-[ri--settings-4-line] w-4 h-4"></span>
|
<span class="icon-[ri--settings-4-line] w-4 h-4"></span>
|
||||||
{$_("me.settings.title")}
|
{$_("me.settings.title")}
|
||||||
@@ -243,6 +243,12 @@ onMount(() => {
|
|||||||
<span class="icon-[ri--play-list-2-line] w-4 h-4"></span>
|
<span class="icon-[ri--play-list-2-line] w-4 h-4"></span>
|
||||||
{$_("me.recordings.title")}
|
{$_("me.recordings.title")}
|
||||||
</TabsTrigger>
|
</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>
|
</TabsList>
|
||||||
|
|
||||||
<!-- Settings Tab -->
|
<!-- Settings Tab -->
|
||||||
@@ -550,6 +556,105 @@ onMount(() => {
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</TabsContent>
|
</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>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user