diff --git a/packages/bundle/src/hook/index.ts b/packages/bundle/src/hook/index.ts index 7cf1974..0c12b4b 100644 --- a/packages/bundle/src/hook/index.ts +++ b/packages/bundle/src/hook/index.ts @@ -3,6 +3,7 @@ global.require = createRequire(import.meta.url); import { defineHook } from "@directus/extensions-sdk"; import slugify from "@sindresorhus/slugify"; import ffmpeg from "fluent-ffmpeg"; +import { awardPoints, checkAchievements } from "../endpoint/gamification.js"; async function processVideo( meta, @@ -32,7 +33,7 @@ async function processVideo( } } -export default defineHook(async ({ filter, action }, { services, logger }) => { +export default defineHook(async ({ filter, action }, { services, logger, database, getSchema }) => { action("files.upload", async (meta, context) => { await processVideo(meta, context, services, logger); }); @@ -67,4 +68,78 @@ export default defineHook(async ({ filter, action }, { services, logger }) => { return payload; }, ); + + // ========================================= + // GAMIFICATION HOOKS + // ========================================= + + // Hook: Award points when recording is published + action("items.create", async (meta, { collection, accountability }) => { + if (collection === "sexy_recordings") { + const { payload, key } = meta; + + // Award points if recording is published + if (payload.status === "published" && accountability?.user) { + try { + await awardPoints(database, accountability.user, "RECORDING_CREATE", key); + await checkAchievements(database, accountability.user, "recordings"); + logger.info(`Awarded RECORDING_CREATE points to user ${accountability.user}`); + } catch (error) { + logger.error("Failed to award recording creation points:", error); + } + } + } + }); + + // Hook: Award points when recording status changes to published or featured + action("items.update", async (meta, { collection, accountability, schema }) => { + if (collection === "sexy_recordings") { + const { payload, keys } = meta; + + try { + const { ItemsService } = services; + const recordingsService = new ItemsService("sexy_recordings", { + schema: await getSchema(), + }); + + for (const key of keys) { + const recording = await recordingsService.readOne(key); + + // Award points if status changed from non-published to published + if (payload.status === "published" && recording.status !== "published" && recording.user_created) { + await awardPoints(database, recording.user_created, "RECORDING_CREATE", key); + await checkAchievements(database, recording.user_created, "recordings"); + logger.info(`Awarded RECORDING_CREATE points to user ${recording.user_created}`); + } + + // Award bonus points if recording becomes featured + if (payload.featured === true && !recording.featured && recording.user_created) { + await awardPoints(database, recording.user_created, "RECORDING_FEATURED", key); + await checkAchievements(database, recording.user_created, "recordings"); + logger.info(`Awarded RECORDING_FEATURED points to user ${recording.user_created}`); + } + } + } catch (error) { + logger.error("Failed to award recording update points:", error); + } + } + }); + + // Hook: Award points when user creates a comment on a recording + action("comments.create", async (meta, { accountability }) => { + if (!accountability?.user) return; + + try { + const { payload } = meta; + + // Check if comment is on a recording + if (payload.collection === "sexy_recordings") { + await awardPoints(database, accountability.user, "COMMENT_CREATE"); + await checkAchievements(database, accountability.user, "social"); + logger.info(`Awarded COMMENT_CREATE points to user ${accountability.user}`); + } + } catch (error) { + logger.error("Failed to award comment points:", error); + } + }); }); diff --git a/packages/frontend/src/lib/i18n/locales/en.ts b/packages/frontend/src/lib/i18n/locales/en.ts index da393d4..a46fad3 100644 --- a/packages/frontend/src/lib/i18n/locales/en.ts +++ b/packages/frontend/src/lib/i18n/locales/en.ts @@ -910,4 +910,25 @@ export default { head: { title: "SexyArt | {title}", }, + gamification: { + leaderboard: "Leaderboard", + leaderboard_description: "Compete with other creators and players for the top spot", + leaderboard_subtitle: "Top creators and players ranked by activity points", + top_players: "Top Players", + no_rankings_yet: "No rankings yet. Be the first to earn points!", + points: "Points", + recordings: "Recordings", + plays: "Plays", + achievements: "Achievements", + rank: "Rank", + stats: "Stats", + how_it_works: "How It Works", + how_it_works_description: "Points are awarded for creating recordings, playing others' recordings, and engaging with the community. Rankings use time-weighted scoring to keep things dynamic.", + earn_by_creating: "Create Recordings", + earn_by_creating_desc: "Earn 50 points per published recording", + earn_by_playing: "Play & Complete", + earn_by_playing_desc: "Earn 10 points per play, 5 for completion", + stay_active: "Stay Active", + stay_active_desc: "Recent activity counts more toward your rank", + }, }; diff --git a/packages/frontend/src/routes/leaderboard/+page.server.ts b/packages/frontend/src/routes/leaderboard/+page.server.ts new file mode 100644 index 0000000..9e7adc6 --- /dev/null +++ b/packages/frontend/src/routes/leaderboard/+page.server.ts @@ -0,0 +1,43 @@ +import { redirect } from "@sveltejs/kit"; +import type { PageServerLoad } from "./$types"; + +export const load: PageServerLoad = async ({ fetch, url, locals }) => { + // Guard: Redirect to login if not authenticated + if (!locals.authStatus.authenticated) { + throw redirect(302, "/login"); + } + + try { + const limit = parseInt(url.searchParams.get("limit") || "100"); + const offset = parseInt(url.searchParams.get("offset") || "0"); + + const response = await fetch( + `/api/sexy/gamification/leaderboard?limit=${limit}&offset=${offset}`, + ); + + if (!response.ok) { + throw new Error("Failed to fetch leaderboard"); + } + + const data = await response.json(); + + return { + leaderboard: data.data || [], + pagination: { + limit, + offset, + hasMore: data.data?.length === limit, + }, + }; + } catch (error) { + console.error("Leaderboard load error:", error); + return { + leaderboard: [], + pagination: { + limit: 100, + offset: 0, + hasMore: false, + }, + }; + } +}; diff --git a/packages/frontend/src/routes/leaderboard/+page.svelte b/packages/frontend/src/routes/leaderboard/+page.svelte new file mode 100644 index 0000000..5746ede --- /dev/null +++ b/packages/frontend/src/routes/leaderboard/+page.svelte @@ -0,0 +1,192 @@ + + + + +
+ + +
+ +
+
+

{$_("gamification.leaderboard")}

+

{$_("gamification.leaderboard_subtitle")}

+
+ +
+ + + + + + + {$_("gamification.top_players")} + + + + {#if data.leaderboard.length === 0} +
+ +

{$_("gamification.no_rankings_yet")}

+
+ {:else} + + + + {#if data.pagination.hasMore} +
+ +
+ {/if} + {/if} +
+
+ + + + +

+ + {$_("gamification.how_it_works")} +

+

+ {$_("gamification.how_it_works_description")} +

+
+
+ +
+
{$_("gamification.earn_by_creating")}
+
{$_("gamification.earn_by_creating_desc")}
+
+
+
+ +
+
{$_("gamification.earn_by_playing")}
+
{$_("gamification.earn_by_playing_desc")}
+
+
+
+ +
+
{$_("gamification.stay_active")}
+
{$_("gamification.stay_active_desc")}
+
+
+
+
+
+
+