From 493ddd7e78de75af34f90a27c0441d8bbb13bd35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Kr=C3=BCger?= Date: Wed, 4 Mar 2026 18:11:22 +0100 Subject: [PATCH] chore: remove packages/bundle (Directus extension) All custom logic (endpoints, hooks, gamification) has been ported to packages/backend. The Directus bundle is no longer needed. Co-Authored-By: Claude Sonnet 4.6 --- packages/bundle/package.json | 54 - packages/bundle/src/endpoint/gamification.ts | 336 ------ packages/bundle/src/endpoint/index.ts | 1125 ------------------ packages/bundle/src/hook/index.ts | 145 --- packages/bundle/src/theme/index.ts | 130 -- packages/bundle/src/theme/style.css | 0 packages/bundle/tsconfig.json | 29 - 7 files changed, 1819 deletions(-) delete mode 100644 packages/bundle/package.json delete mode 100644 packages/bundle/src/endpoint/gamification.ts delete mode 100644 packages/bundle/src/endpoint/index.ts delete mode 100644 packages/bundle/src/hook/index.ts delete mode 100644 packages/bundle/src/theme/index.ts delete mode 100644 packages/bundle/src/theme/style.css delete mode 100644 packages/bundle/tsconfig.json diff --git a/packages/bundle/package.json b/packages/bundle/package.json deleted file mode 100644 index 1defa93..0000000 --- a/packages/bundle/package.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "name": "@sexy.pivoine.art/bundle", - "description": "Please enter a description for your extension", - "icon": "extension", - "version": "1.0.0", - "keywords": [ - "directus", - "directus-extension", - "directus-extension-bundle" - ], - "type": "module", - "files": [ - "dist" - ], - "directus:extension": { - "type": "bundle", - "path": { - "app": "dist/app.js", - "api": "dist/api.js" - }, - "entries": [ - { - "name": "endpoint", - "type": "endpoint", - "source": "src/endpoint" - }, - { - "name": "hook", - "type": "hook", - "source": "src/hook" - }, - { - "name": "theme", - "type": "theme", - "source": "src/theme" - } - ], - "host": "^11.11.0" - }, - "scripts": { - "build": "directus-extension build", - "dev": "directus-extension build -w --no-minify", - "link": "directus-extension link", - "validate": "directus-extension validate", - "add": "directus-extension add" - }, - "devDependencies": { - "@directus/extensions-sdk": "17.0.9" - }, - "dependencies": { - "@sindresorhus/slugify": "^3.0.0", - "fluent-ffmpeg": "^2.1.3" - } -} diff --git a/packages/bundle/src/endpoint/gamification.ts b/packages/bundle/src/endpoint/gamification.ts deleted file mode 100644 index 56a5f2a..0000000 --- a/packages/bundle/src/endpoint/gamification.ts +++ /dev/null @@ -1,336 +0,0 @@ -/** - * Gamification Helper Functions - * Handles points, achievements, and user stats for recording-focused gamification system - */ - -import type { Knex } from "knex"; - -/** - * Point values for different actions - */ -export const POINT_VALUES = { - RECORDING_CREATE: 50, - RECORDING_PLAY: 10, - RECORDING_COMPLETE: 5, - COMMENT_CREATE: 5, - RECORDING_FEATURED: 100, -} as const; - -/** - * Time decay constant for weighted scoring - * λ = 0.005 means ~14% decay per month - */ -const DECAY_LAMBDA = 0.005; - -/** - * Award points to a user for a specific action - */ -export async function awardPoints( - database: Knex, - userId: string, - action: keyof typeof POINT_VALUES, - recordingId?: string, -): Promise { - const points = POINT_VALUES[action]; - - await database("sexy_user_points").insert({ - user_id: userId, - action, - points, - recording_id: recordingId || null, - date_created: new Date(), - }); - - // Update cached stats - await updateUserStats(database, userId); -} - -/** - * Calculate time-weighted score using exponential decay - * Score = Σ (points × e^(-λ × age_in_days)) - */ -export async function calculateWeightedScore( - database: Knex, - userId: string, -): Promise { - const now = new Date(); - - const result = await database("sexy_user_points") - .where({ user_id: userId }) - .select( - database.raw(` - SUM( - points * EXP(-${DECAY_LAMBDA} * EXTRACT(EPOCH FROM (? - date_created)) / 86400) - ) as weighted_score - `, [now]), - ); - - return result[0]?.weighted_score || 0; -} - -/** - * Update or create user stats cache - */ -export async function updateUserStats(database: Knex, userId: string): Promise { - const now = new Date(); - - // Calculate raw points - const rawPointsResult = await database("sexy_user_points") - .where({ user_id: userId }) - .sum("points as total"); - const totalRawPoints = rawPointsResult[0]?.total || 0; - - // Calculate weighted points - const totalWeightedPoints = await calculateWeightedScore(database, userId); - - // Get recordings count - const recordingsResult = await database("sexy_recordings") - .where({ user_created: userId, status: "published" }) - .count("* as count"); - const recordingsCount = recordingsResult[0]?.count || 0; - - // Get playbacks count (excluding own recordings) - const playbacksResult = await database("sexy_recording_plays") - .where({ user_id: userId }) - .whereNotIn("recording_id", function () { - this.select("id").from("sexy_recordings").where("user_created", userId); - }) - .count("* as count"); - const playbacksCount = playbacksResult[0]?.count || 0; - - // Get comments count (on recordings only) - const commentsResult = await database("comments") - .where({ user_created: userId, collection: "sexy_recordings" }) - .count("* as count"); - const commentsCount = commentsResult[0]?.count || 0; - - // Get achievements count - const achievementsResult = await database("sexy_user_achievements") - .where({ user_id: userId }) - .whereNotNull("date_unlocked") - .count("* as count"); - const achievementsCount = achievementsResult[0]?.count || 0; - - // Upsert stats - const existing = await database("sexy_user_stats") - .where({ user_id: userId }) - .first(); - - if (existing) { - await database("sexy_user_stats") - .where({ user_id: userId }) - .update({ - total_raw_points: totalRawPoints, - total_weighted_points: totalWeightedPoints, - recordings_count: recordingsCount, - playbacks_count: playbacksCount, - comments_count: commentsCount, - achievements_count: achievementsCount, - last_updated: now, - }); - } else { - await database("sexy_user_stats").insert({ - user_id: userId, - total_raw_points: totalRawPoints, - total_weighted_points: totalWeightedPoints, - recordings_count: recordingsCount, - playbacks_count: playbacksCount, - comments_count: commentsCount, - achievements_count: achievementsCount, - last_updated: now, - }); - } -} - -/** - * Check and update achievement progress for a user - */ -export async function checkAchievements( - database: Knex, - userId: string, - category?: string, -): Promise { - // Get all achievements (optionally filtered by category) - let achievementsQuery = database("sexy_achievements") - .where({ status: "published" }); - - if (category) { - achievementsQuery = achievementsQuery.where({ category }); - } - - const achievements = await achievementsQuery; - - for (const achievement of achievements) { - const progress = await getAchievementProgress(database, userId, achievement); - - // Check if already unlocked - const existing = await database("sexy_user_achievements") - .where({ user_id: userId, achievement_id: achievement.id }) - .first(); - - const isUnlocked = progress >= achievement.required_count; - const wasUnlocked = existing?.date_unlocked !== null; - - if (existing) { - // Update progress - await database("sexy_user_achievements") - .where({ user_id: userId, achievement_id: achievement.id }) - .update({ - progress, - date_unlocked: isUnlocked ? (existing.date_unlocked || new Date()) : null, - }); - } else { - // Insert new progress - await database("sexy_user_achievements").insert({ - user_id: userId, - achievement_id: achievement.id, - progress, - date_unlocked: isUnlocked ? new Date() : null, - }); - } - - // Award bonus points if newly unlocked - if (isUnlocked && !wasUnlocked && achievement.points_reward > 0) { - await database("sexy_user_points").insert({ - user_id: userId, - action: `ACHIEVEMENT_${achievement.code}`, - points: achievement.points_reward, - recording_id: null, - date_created: new Date(), - }); - - // Refresh stats after awarding bonus - await updateUserStats(database, userId); - } - } -} - -/** - * Get progress for a specific achievement - */ -async function getAchievementProgress( - database: Knex, - userId: string, - achievement: any, -): Promise { - const { code } = achievement; - - // Recordings achievements - if (code === "first_recording" || code === "recording_10" || code === "recording_50" || code === "recording_100") { - const result = await database("sexy_recordings") - .where({ user_created: userId, status: "published" }) - .count("* as count"); - return result[0]?.count || 0; - } - - // Featured recording - if (code === "featured_recording") { - const result = await database("sexy_recordings") - .where({ user_created: userId, status: "published", featured: true }) - .count("* as count"); - return result[0]?.count || 0; - } - - // Playback achievements (excluding own recordings) - if (code === "first_play" || code === "play_100" || code === "play_500") { - const result = await database("sexy_recording_plays as rp") - .leftJoin("sexy_recordings as r", "rp.recording_id", "r.id") - .where({ "rp.user_id": userId }) - .where("r.user_created", "!=", userId) - .count("* as count"); - return result[0]?.count || 0; - } - - // Completionist achievements - if (code === "completionist_10" || code === "completionist_100") { - const result = await database("sexy_recording_plays") - .where({ user_id: userId, completed: true }) - .count("* as count"); - return result[0]?.count || 0; - } - - // Social achievements - if (code === "first_comment" || code === "comment_50" || code === "comment_250") { - const result = await database("comments") - .where({ user_created: userId, collection: "sexy_recordings" }) - .count("* as count"); - return result[0]?.count || 0; - } - - // Special: Early adopter (joined in first month) - if (code === "early_adopter") { - const user = await database("directus_users") - .where({ id: userId }) - .first(); - - if (user) { - const joinDate = new Date(user.date_created); - const platformLaunch = new Date("2025-01-01"); // Adjust to actual launch date - const oneMonthAfterLaunch = new Date(platformLaunch); - oneMonthAfterLaunch.setMonth(oneMonthAfterLaunch.getMonth() + 1); - - return joinDate <= oneMonthAfterLaunch ? 1 : 0; - } - } - - // Special: One year anniversary - if (code === "one_year") { - const user = await database("directus_users") - .where({ id: userId }) - .first(); - - if (user) { - const joinDate = new Date(user.date_created); - const oneYearAgo = new Date(); - oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1); - - return joinDate <= oneYearAgo ? 1 : 0; - } - } - - // Special: Balanced creator (50 recordings + 100 plays) - if (code === "balanced_creator") { - const recordings = await database("sexy_recordings") - .where({ user_created: userId, status: "published" }) - .count("* as count"); - const plays = await database("sexy_recording_plays as rp") - .leftJoin("sexy_recordings as r", "rp.recording_id", "r.id") - .where({ "rp.user_id": userId }) - .where("r.user_created", "!=", userId) - .count("* as count"); - - const recordingsCount = recordings[0]?.count || 0; - const playsCount = plays[0]?.count || 0; - - return (recordingsCount >= 50 && playsCount >= 100) ? 1 : 0; - } - - // Special: Top 10 rank - if (code === "top_10_rank") { - const userStats = await database("sexy_user_stats") - .where({ user_id: userId }) - .first(); - - if (!userStats) return 0; - - const rank = await database("sexy_user_stats") - .where("total_weighted_points", ">", userStats.total_weighted_points) - .count("* as count"); - - const userRank = (rank[0]?.count || 0) + 1; - return userRank <= 10 ? 1 : 0; - } - - return 0; -} - -/** - * Recalculate all weighted scores (for cron job) - */ -export async function recalculateAllWeightedScores(database: Knex): Promise { - const users = await database("sexy_user_stats").select("user_id"); - - for (const user of users) { - await updateUserStats(database, user.user_id); - } -} diff --git a/packages/bundle/src/endpoint/index.ts b/packages/bundle/src/endpoint/index.ts deleted file mode 100644 index a5b59d9..0000000 --- a/packages/bundle/src/endpoint/index.ts +++ /dev/null @@ -1,1125 +0,0 @@ -import { checkAchievements } from "./gamification"; - -const createPolicyFilter = (policy) => ({ - _or: [ - { - policies: { - policy: { - name: { - _eq: policy, - }, - }, - }, - }, - { - role: { - name: { - _eq: policy, - }, - }, - }, - ], -}); - -export default { - id: "sexy", - handler: (router, context) => { - const { services, getSchema, database } = context; - const { ItemsService } = services; - - router.get("/stats", async (_req, res) => { - const usersService = new ItemsService("directus_users", { - schema: await getSchema(), - }); - const modelsCount = await usersService.readByQuery({ - aggregate: { - count: ["*"], - }, - filter: createPolicyFilter("Model"), - }); - const viewersCount = await usersService.readByQuery({ - aggregate: { - count: ["*"], - }, - filter: createPolicyFilter("Viewer"), - }); - - const videosService = new ItemsService("sexy_videos", { - schema: await getSchema(), - }); - const videosCount = await videosService.readByQuery({ - aggregate: { - count: ["*"], - }, - }); - - res.json({ - models_count: modelsCount[0].count, - viewers_count: viewersCount[0].count, - videos_count: videosCount[0].count, - }); - }); - - // GET /sexy/models - Public endpoint to fetch models (bypasses permissions) - router.get("/models", async (req, res) => { - try { - const { featured, limit } = req.query; - - // Build query using Knex to bypass permissions - let query = database - .select("u.*") - .from("directus_users as u") - .leftJoin("directus_roles as r", "u.role", "r.id") - .where("r.name", "Model") - .orderBy("u.id", "desc"); - - - if (limit) { - query = query.limit(parseInt(limit as string)); - } - - const models = await query; - - // Fetch related photos and banner for each model - for (const model of models) { - // Fetch photos - const photos = await database - .select("df.*") - .from("junction_directus_users_files as juf") - .leftJoin("directus_files as df", "juf.directus_files_id", "df.id") - .where("juf.directus_users_id", model.id); - - model.photos = photos.map((p) => ({ directus_files_id: p })); - - // Fetch banner - if (model.banner) { - const banner = await database - .select("*") - .from("directus_files") - .where("id", model.banner) - .first(); - model.banner = banner; - } - } - - res.json(models); - } catch (error: any) { - console.error("Models endpoint error:", error); - res.status(500).json({ error: error.message || "Failed to fetch models" }); - } - }); - - // GET /sexy/models/:slug - Get single model by slug - router.get("/models/:slug", async (req, res) => { - try { - const { slug } = req.params; - - const model = await database - .select("u.*") - .from("directus_users as u") - .leftJoin("directus_roles as r", "u.role", "r.id") - .where("r.name", "Model") - .where("u.slug", slug) - .first(); - - if (!model) { - return res.status(404).json({ error: "Model not found" }); - } - - // Fetch photos - const photos = await database - .select("df.*") - .from("junction_directus_users_files as juf") - .leftJoin("directus_files as df", "juf.directus_files_id", "df.id") - .where("juf.directus_users_id", model.id); - - model.photos = photos.map((p) => ({ directus_files_id: p })); - - // Fetch banner - if (model.banner) { - const banner = await database - .select("*") - .from("directus_files") - .where("id", model.banner) - .first(); - model.banner = banner; - } - - res.json(model); - } catch (error: any) { - console.error("Model by slug error:", error); - res.status(500).json({ error: error.message || "Failed to fetch model" }); - } - }); - - // GET /sexy/videos - List videos - router.get("/videos", async (req, res) => { - try { - const { model_id, limit } = req.query; - - let query = database - .select("v.*") - .from("sexy_videos as v") - .where("v.upload_date", "<=", new Date().toISOString()) - .orderBy("v.upload_date", "desc"); - - if (model_id) { - query = query - .leftJoin("sexy_videos_models as vm", "v.id", "vm.sexy_videos_id") - .where("vm.directus_users_id", model_id); - } - - if (limit) { - query = query.limit(parseInt(limit as string)); - } - - const videos = await query; - - // Fetch models and movie for each video - for (const video of videos) { - // Fetch models - const models = await database - .select("u.*") - .from("sexy_videos_models as vm") - .leftJoin("directus_users as u", "vm.directus_users_id", "u.id") - .where("vm.sexy_videos_id", video.id); - - video.models = models; - - // Fetch movie file - if (video.movie) { - const movie = await database - .select("*") - .from("directus_files") - .where("id", video.movie) - .first(); - video.movie = movie; - } - - // Count actual likes from database - const likesCount = await database - .count("* as count") - .from("sexy_video_likes") - .where("video_id", video.id) - .first(); - - video.likes_count = parseInt(likesCount?.count || 0); - - // Count actual plays from database - const playsCount = await database - .count("* as count") - .from("sexy_video_plays") - .where("video_id", video.id) - .first(); - - video.plays_count = parseInt(playsCount?.count || 0); - } - - res.json(videos); - } catch (error: any) { - console.error("Videos endpoint error:", error); - res.status(500).json({ error: error.message || "Failed to fetch videos" }); - } - }); - - // GET /sexy/videos/:slug - Get single video by slug - router.get("/videos/:slug", async (req, res) => { - try { - const { slug } = req.params; - - const video = await database - .select("v.*") - .from("sexy_videos as v") - .where("v.slug", slug) - .where("v.upload_date", "<=", new Date().toISOString()) - .first(); - - if (!video) { - return res.status(404).json({ error: "Video not found" }); - } - - // Fetch models - const models = await database - .select("u.*") - .from("sexy_videos_models as vm") - .leftJoin("directus_users as u", "vm.directus_users_id", "u.id") - .where("vm.sexy_videos_id", video.id); - - video.models = models; - - // Fetch movie file - if (video.movie) { - const movie = await database - .select("*") - .from("directus_files") - .where("id", video.movie) - .first(); - video.movie = movie; - } - - // Count actual likes from database - const likesCount = await database - .count("* as count") - .from("sexy_video_likes") - .where("video_id", video.id) - .first(); - - video.likes_count = parseInt(likesCount?.count || 0); - - // Count actual plays from database - const playsCount = await database - .count("* as count") - .from("sexy_video_plays") - .where("video_id", video.id) - .first(); - - video.plays_count = parseInt(playsCount?.count || 0); - - res.json(video); - } catch (error: any) { - console.error("Video by slug error:", error); - res.status(500).json({ error: error.message || "Failed to fetch video" }); - } - }); - - // GET /sexy/articles - List articles - router.get("/articles", async (req, res) => { - try { - const { featured, limit } = req.query; - - let query = database - .select("a.*") - .from("sexy_articles as a") - .where("a.publish_date", "<=", new Date().toISOString()) - .orderBy("a.publish_date", "desc"); - - query = query.where("a.featured", true); - - if (limit) { - query = query.limit(parseInt(limit as string)); - } - - const articles = await query; - - // Fetch author for each article - for (const article of articles) { - if (article.author) { - const author = await database - .select("*") - .from("directus_users") - .where("id", article.author) - .first(); - article.author = author; - } - } - - res.json(articles); - } catch (error: any) { - console.error("Articles endpoint error:", error); - res.status(500).json({ error: error.message || "Failed to fetch articles" }); - } - }); - - // GET /sexy/recordings - List user's recordings - router.get("/recordings", async (req, res) => { - const accountability = req.accountability; - if (!accountability?.user) { - return res.status(401).json({ error: "Unauthorized" }); - } - - const recordingsService = new ItemsService("sexy_recordings", { - schema: await getSchema(), - accountability, - }); - - const { status, tags, linked_video, limit, page } = req.query; - const filter: any = { - user_created: { - _eq: accountability.user, - }, - }; - - if (status) filter.status = { _eq: status }; - if (tags) filter.tags = { _contains: tags }; - if (linked_video) filter.linked_video = { _eq: linked_video }; - - const recordings = await recordingsService.readByQuery({ - filter, - limit: limit ? parseInt(limit as string) : 50, - page: page ? parseInt(page as string) : 1, - sort: ["-date_created"], - }); - - res.json(recordings); - }); - - // GET /sexy/recordings/:id - Get single recording - router.get("/recordings/:id", async (req, res) => { - const accountability = req.accountability; - if (!accountability?.user) { - return res.status(401).json({ error: "Unauthorized" }); - } - - const recordingsService = new ItemsService("sexy_recordings", { - schema: await getSchema(), - accountability, - }); - - try { - const recording = await recordingsService.readOne(req.params.id); - - // Check if user owns the recording or if it's public - if ( - recording.user_created !== accountability.user && - !recording.public - ) { - return res.status(403).json({ error: "Forbidden" }); - } - - res.json(recording); - } catch (error) { - res.status(404).json({ error: "Recording not found" }); - } - }); - - // POST /sexy/recordings - Create new recording - router.post("/recordings", async (req, res) => { - const accountability = req.accountability; - if (!accountability?.user) { - return res.status(401).json({ error: "Unauthorized" }); - } - - const recordingsService = new ItemsService("sexy_recordings", { - schema: await getSchema(), - accountability, - }); - - const { title, description, duration, events, device_info, tags, linked_video, status } = req.body; - - // Validate required fields - if (!title || !duration || !events || !device_info) { - return res.status(400).json({ - error: "Missing required fields: title, duration, events, device_info", - }); - } - - // Validate events structure - if (!Array.isArray(events) || events.length === 0) { - return res.status(400).json({ error: "Events must be a non-empty array" }); - } - - // Generate slug from title - const slug = title - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-|-$/g, ""); - - try { - const recording = await recordingsService.createOne({ - title, - description, - slug, - duration, - events, - device_info, - tags: tags || [], - linked_video: linked_video || null, - status: status || "draft", - public: false, - }); - - res.status(201).json(recording); - } catch (error: any) { - console.error("Failed to create recording:", error); - res.status(500).json({ - error: error.message || "Failed to create recording", - details: error.toString() - }); - } - }); - - // PATCH /sexy/recordings/:id - Update recording - router.patch("/recordings/:id", async (req, res) => { - const accountability = req.accountability; - if (!accountability?.user) { - return res.status(401).json({ error: "Unauthorized" }); - } - - const recordingsService = new ItemsService("sexy_recordings", { - schema: await getSchema(), - accountability, - }); - - try { - const existing = await recordingsService.readOne(req.params.id); - - // Only allow owner to update - if (existing.user_created !== accountability.user) { - return res.status(403).json({ error: "Forbidden" }); - } - - const { title, description, tags, status, public: isPublic, linked_video } = req.body; - const updates: any = {}; - - if (title !== undefined) { - updates.title = title; - updates.slug = title - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-|-$/g, ""); - } - if (description !== undefined) updates.description = description; - if (tags !== undefined) updates.tags = tags; - if (status !== undefined) updates.status = status; - if (isPublic !== undefined) updates.public = isPublic; - if (linked_video !== undefined) updates.linked_video = linked_video; - - const recording = await recordingsService.updateOne(req.params.id, updates); - res.json(recording); - } catch (error: any) { - res.status(500).json({ error: error.message || "Failed to update recording" }); - } - }); - - // DELETE /sexy/recordings/:id - Delete (archive) recording - router.delete("/recordings/:id", async (req, res) => { - const accountability = req.accountability; - if (!accountability?.user) { - return res.status(401).json({ error: "Unauthorized" }); - } - - const recordingsService = new ItemsService("sexy_recordings", { - schema: await getSchema(), - accountability, - }); - - try { - const existing = await recordingsService.readOne(req.params.id); - - // Only allow owner to delete - if (existing.user_created !== accountability.user) { - return res.status(403).json({ error: "Forbidden" }); - } - - // Soft delete by setting status to archived - await recordingsService.updateOne(req.params.id, { - status: "archived", - }); - - res.json({ success: true }); - } catch (error: any) { - 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" }); - } - }); - - // 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" }); - } - }); - - // ========================================= - // GAMIFICATION ENDPOINTS - // ========================================= - - // GET /sexy/gamification/leaderboard - Get top users by weighted score - router.get("/gamification/leaderboard", async (req, res) => { - try { - const limit = Math.min(parseInt(req.query.limit as string) || 100, 500); - const offset = parseInt(req.query.offset as string) || 0; - - const leaderboard = await database("sexy_user_stats as s") - .leftJoin("directus_users as u", "s.user_id", "u.id") - .select( - "u.id as user_id", - "u.artist_name as display_name", - "u.avatar", - "s.total_weighted_points", - "s.total_raw_points", - "s.recordings_count", - "s.playbacks_count", - "s.achievements_count", - ) - .orderBy("s.total_weighted_points", "desc") - .limit(limit) - .offset(offset); - - // Add rank to each entry - const leaderboardWithRank = leaderboard.map((entry, index) => ({ - ...entry, - rank: offset + index + 1, - })); - - res.json({ data: leaderboardWithRank }); - } catch (error: any) { - console.error("Leaderboard error:", error); - res.status(500).json({ error: error.message || "Failed to get leaderboard" }); - } - }); - - // GET /sexy/gamification/user/:id - Get gamification stats for a user - router.get("/gamification/user/:id", async (req, res) => { - try { - const { id } = req.params; - - // Get user stats - const stats = await database("sexy_user_stats") - .where({ user_id: id }) - .first(); - - // Calculate rank - let rank = 1; - if (stats) { - const rankResult = await database("sexy_user_stats") - .where("total_weighted_points", ">", stats.total_weighted_points) - .count("* as count"); - rank = (rankResult[0]?.count || 0) + 1; - } - - // Get unlocked achievements - const achievements = await database("sexy_user_achievements as ua") - .leftJoin("sexy_achievements as a", "ua.achievement_id", "a.id") - .where({ "ua.user_id": id }) - .whereNotNull("ua.date_unlocked") - .select( - "a.id", - "a.code", - "a.name", - "a.description", - "a.icon", - "a.category", - "ua.date_unlocked", - "ua.progress", - "a.required_count", - ) - .orderBy("ua.date_unlocked", "desc"); - - // Get recent points - const recentPoints = await database("sexy_user_points") - .where({ user_id: id }) - .select("action", "points", "date_created", "recording_id") - .orderBy("date_created", "desc") - .limit(10); - - res.json({ - stats: stats ? { ...stats, rank } : null, - achievements, - recent_points: recentPoints, - }); - } catch (error: any) { - console.error("User gamification error:", error); - res.status(500).json({ error: error.message || "Failed to get user gamification data" }); - } - }); - - // GET /sexy/gamification/achievements - Get all achievements - router.get("/gamification/achievements", async (req, res) => { - try { - const achievements = await database("sexy_achievements") - .where({ status: "published" }) - .select( - "id", - "code", - "name", - "description", - "icon", - "category", - "required_count", - "points_reward", - ) - .orderBy("sort", "asc"); - - res.json({ data: achievements }); - } catch (error: any) { - console.error("Achievements error:", error); - res.status(500).json({ error: error.message || "Failed to get achievements" }); - } - }); - - // POST /sexy/recordings/:id/play - Record a recording play (with gamification) - router.post("/recordings/:id/play", async (req, res) => { - const accountability = req.accountability; - const recordingId = req.params.id; - - try { - // Get recording to check ownership - const recording = await database("sexy_recordings") - .where({ id: recordingId }) - .first(); - - if (!recording) { - return res.status(404).json({ error: "Recording not found" }); - } - - // Record play - const play = await database("sexy_recording_plays").insert({ - user_id: accountability?.user || null, - recording_id: recordingId, - duration_played: 0, - completed: false, - date_created: new Date(), - }).returning("id"); - - const playId = play[0]?.id || play[0]; - - // Award points if user is authenticated and not playing own recording - if (accountability?.user && recording.user_created !== accountability.user) { - const { awardPoints, POINT_VALUES } = await import("./gamification"); - await awardPoints(database, accountability.user, "RECORDING_PLAY", recordingId); - await checkAchievements(database, accountability.user, "playback"); - } - - res.json({ success: true, play_id: playId }); - } catch (error: any) { - console.error("Recording play error:", error); - res.status(500).json({ error: error.message || "Failed to record play" }); - } - }); - - // PATCH /sexy/recordings/:id/play/:playId - Update play progress (with gamification) - router.patch("/recordings/:id/play/:playId", async (req, res) => { - const { playId } = req.params; - const { duration_played, completed } = req.body; - const accountability = req.accountability; - - try { - // Get existing play record - const existingPlay = await database("sexy_recording_plays") - .where({ id: playId }) - .first(); - - if (!existingPlay) { - return res.status(404).json({ error: "Play record not found" }); - } - - const wasCompleted = existingPlay.completed; - - // Update play record - await database("sexy_recording_plays") - .where({ id: playId }) - .update({ - duration_played, - completed, - date_updated: new Date(), - }); - - // Award completion points if newly completed - if (completed && !wasCompleted && accountability?.user) { - const { awardPoints } = await import("./gamification"); - await awardPoints(database, accountability.user, "RECORDING_COMPLETE", existingPlay.recording_id); - await checkAchievements(database, accountability.user, "playback"); - } - - res.json({ success: true }); - } catch (error: any) { - console.error("Update play error:", error); - res.status(500).json({ error: error.message || "Failed to update play" }); - } - }); - - // GET /sexy/community-recordings - List community shared recordings - router.get("/community-recordings", async (req, res) => { - try { - const limit = parseInt(req.query.limit as string) || 50; - const offset = parseInt(req.query.offset as string) || 0; - - const recordingsService = new ItemsService("sexy_recordings", { - schema: await getSchema(), - accountability: null, // Public endpoint, no auth required - knex: database, - }); - - const recordings = await recordingsService.readByQuery({ - filter: { - status: { _eq: "published" }, - public: { _eq: true }, - }, - fields: [ - "id", - "title", - "description", - "slug", - "duration", - "tags", - "date_created", - "user_created.id", - "user_created.first_name", - "user_created.last_name", - "user_created.avatar", - ], - limit, - offset, - sort: ["-date_created"], - }); - - res.json({ data: recordings }); - } catch (error: any) { - console.error("List community recordings error:", error); - res.status(500).json({ error: error.message || "Failed to list community recordings" }); - } - }); - - // POST /sexy/recordings/:id/duplicate - Duplicate a community recording to current user - router.post("/recordings/:id/duplicate", async (req, res) => { - try { - const accountability = req.accountability; - if (!accountability?.user) { - return res.status(401).json({ error: "Authentication required" }); - } - - const recordingId = req.params.id; - - // Fetch the original recording - const schema = await getSchema(); - const recordingsService = new ItemsService("sexy_recordings", { - schema, - accountability: null, // Need to read any public recording - knex: database, - }); - - const originalRecording = await recordingsService.readOne(recordingId, { - fields: [ - "id", - "title", - "description", - "duration", - "events", - "device_info", - "tags", - "status", - "public", - ], - }); - - // Verify it's a published, public recording - if (originalRecording.status !== "published" || !originalRecording.public) { - return res.status(403).json({ error: "Recording is not publicly shared" }); - } - - // Create duplicate with current user's accountability - const userRecordingsService = new ItemsService("sexy_recordings", { - schema, - accountability, - knex: database, - }); - - // Generate unique slug - const baseSlug = originalRecording.title - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-|-$/g, ""); - const timestamp = Date.now(); - const slug = `${baseSlug}-copy-${timestamp}`; - - const duplicatedRecording = await userRecordingsService.createOne({ - title: `${originalRecording.title} (Copy)`, - description: originalRecording.description, - slug, - duration: originalRecording.duration, - events: originalRecording.events, - device_info: originalRecording.device_info, - tags: originalRecording.tags || [], - status: "draft", - public: false, - original_recording_id: recordingId, - }); - - res.status(201).json({ data: duplicatedRecording }); - } catch (error: any) { - console.error("Duplicate recording error:", error); - res.status(500).json({ error: error.message || "Failed to duplicate recording" }); - } - }); - }, -}; diff --git a/packages/bundle/src/hook/index.ts b/packages/bundle/src/hook/index.ts deleted file mode 100644 index 0c12b4b..0000000 --- a/packages/bundle/src/hook/index.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { createRequire } from "module"; -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, - { schema, accountability }, - services, - logger, -) { - const { FilesService } = services; - const itemId = meta.key; - const videoPath = `/directus/uploads/${meta.payload.filename_disk}`; // Adjust path as needed - const videoService = new FilesService({ schema, accountability }); // Replace with your collection name - - try { - const durationInSeconds = await new Promise((resolve, reject) => { - ffmpeg.ffprobe(videoPath, function (err, metadata) { - if (err) { - reject(err); - } - resolve(parseInt(metadata.format.duration)); - }); - }); - // Update the item with the duration - await videoService.updateOne(itemId, { duration: durationInSeconds }); - logger.info(`Video ${itemId} duration updated to ${durationInSeconds}`); - } catch (error) { - logger.error(`Error processing video ${itemId}:`, error); - } -} - -export default defineHook(async ({ filter, action }, { services, logger, database, getSchema }) => { - action("files.upload", async (meta, context) => { - await processVideo(meta, context, services, logger); - }); - - filter( - "users.create", - (payload: { - first_name: string; - last_name: string; - artist_name: string; - slug: string; - }) => { - const artist_name = `${payload.first_name}-${new Date().getTime()}`; - const slug = slugify(artist_name); - const join_date = new Date(); - return { ...payload, artist_name, slug, join_date }; - }, - ); - - filter( - "users.update", - (payload: { - first_name: string; - last_name: string; - artist_name: string; - slug: string; - }) => { - if (payload.artist_name) { - const slug = slugify(payload.artist_name); - return { ...payload, slug }; - } - 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/bundle/src/theme/index.ts b/packages/bundle/src/theme/index.ts deleted file mode 100644 index ed9367e..0000000 --- a/packages/bundle/src/theme/index.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { defineTheme } from "@directus/extensions-sdk"; -import "./style.css"; - -export default defineTheme({ - id: "@sexy.pivoine.art/theme", - name: "Sexy.Art Dark", - appearance: "dark", - rules: { - borderRadius: "6px", - borderWidth: "2px", - foreground: "#c9d1d9", - foregroundSubdued: "#666672", - foregroundAccent: "#f0f6fc", - background: "#0D1117", - backgroundNormal: "#21262E", - backgroundAccent: "#30363D", - backgroundSubdued: "#161B22", - borderColor: "#21262E", - borderColorAccent: "#30363D", - borderColorSubdued: "#161B22", - primary: "#ce47eb", - secondary: "#613dff", - success: "#87ff66", - warning: "#ffbf66", - danger: "#ff6467", - navigation: { - background: "#21262E", - backgroundAccent: "#30363D", - borderWidth: "0px", - borderColor: "transparent", - project: { - background: "#30363D", - borderWidth: "0px", - borderColor: "transparent", - }, - modules: { - borderWidth: "0px", - borderColor: "transparent", - button: { - foregroundHover: "#fff", - background: "transparent", - backgroundHover: "transparent", - backgroundActive: "#21262E", - }, - }, - list: { - background: "transparent", - backgroundHover: "#30363D", - backgroundActive: "#30363D", - divider: { - borderColor: "#30363D", - }, - }, - }, - header: { - borderWidth: "0px", - borderColor: "transparent", - boxShadow: "0 4px 7px -4px black", - }, - form: { - columnGap: "32px", - rowGap: "40px", - field: { - label: { - fontWeight: "600", - }, - input: { - borderColor: "#21262E", - borderColorHover: "#30363D", - boxShadow: "none", - boxShadowHover: "none", - height: "60px", - padding: "16px", - }, - }, - }, - sidebar: { - background: "#21262E", - borderWidth: "0px", - borderColor: "transparent", - section: { - toggle: { - background: "#30363D", - borderWidth: "0px", - borderColor: "transparent", - }, - form: { - field: { - input: { - height: "52px", - padding: "12px", - }, - }, - }, - }, - }, - public: { - art: { - background: "#21262E", - speed: "1", - }, - }, - popover: { - menu: { - background: "#30363D", - boxShadow: "0px 0px 6px 0px black", - }, - }, - banner: { - background: "#161B22", - padding: "40px", - avatar: { - background: "#fff", - borderRadius: "50%", - }, - headline: { - foreground: "#fff", - }, - title: { - foreground: "#fff", - }, - subtitle: { - foreground: "#969696", - }, - art: { - foreground: "#21262E", - }, - }, - }, -}); diff --git a/packages/bundle/src/theme/style.css b/packages/bundle/src/theme/style.css deleted file mode 100644 index e69de29..0000000 diff --git a/packages/bundle/tsconfig.json b/packages/bundle/tsconfig.json deleted file mode 100644 index 6485252..0000000 --- a/packages/bundle/tsconfig.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "lib": ["ES2022", "DOM"], - "module": "ES2022", - "moduleResolution": "node", - "strict": false, - "noFallthroughCasesInSwitch": true, - "esModuleInterop": true, - "noImplicitAny": false, - "noImplicitThis": true, - "noImplicitReturns": true, - "noUnusedLocals": true, - "noUncheckedIndexedAccess": true, - "noUnusedParameters": true, - "alwaysStrict": true, - "strictNullChecks": true, - "strictFunctionTypes": true, - "strictBindCallApply": true, - "strictPropertyInitialization": true, - "resolveJsonModule": false, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "allowSyntheticDefaultImports": true, - "isolatedModules": true, - "allowJs": true - }, - "include": ["./src/**/*.ts"] -}