From 91951667e351de3c41df34402c14d5bb903b6240 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Kr=C3=BCger?= Date: Thu, 12 Mar 2026 18:35:04 +0100 Subject: [PATCH] feat: related content and featured cross-content sidebar widgets - Add excludeId arg to videos and articles GraphQL resolvers - Add excludeId + featured params to getVideos/getArticles services - Video page: fetch related videos by tag + featured article in parallel - Article page: fetch related articles by category + featured video in parallel - Implement sidebar widgets with thumbnails, metadata, hover interactions - Add videos.related and magazine.related i18n keys - Seed dummy articles (spotlight, interview, psychology) and videos with overlapping tags for testing related content Co-Authored-By: Claude Sonnet 4.6 --- .../backend/src/graphql/resolvers/articles.ts | 4 +- .../backend/src/graphql/resolvers/videos.ts | 5 + packages/frontend/src/lib/i18n/locales/en.ts | 2 + packages/frontend/src/lib/services.ts | 7 + .../routes/magazine/[slug]/+page.server.ts | 18 ++- .../src/routes/magazine/[slug]/+page.svelte | 141 +++++++++++++----- .../src/routes/videos/[slug]/+page.server.ts | 29 ++-- .../src/routes/videos/[slug]/+page.svelte | 130 +++++++++++----- 8 files changed, 251 insertions(+), 85 deletions(-) diff --git a/packages/backend/src/graphql/resolvers/articles.ts b/packages/backend/src/graphql/resolvers/articles.ts index f5d79b3..2c5dcbf 100644 --- a/packages/backend/src/graphql/resolvers/articles.ts +++ b/packages/backend/src/graphql/resolvers/articles.ts @@ -1,7 +1,7 @@ import { builder } from "../builder"; import { ArticleType, ArticleListType, AdminArticleListType } from "../types/index"; import { articles, users } from "../../db/schema/index"; -import { eq, and, lte, desc, asc, ilike, or, count, arrayContains, isNotNull, type SQL } from "drizzle-orm"; +import { eq, and, lte, desc, asc, ilike, or, count, arrayContains, isNotNull, ne, type SQL } from "drizzle-orm"; import { requireAdmin } from "../../lib/acl"; import type { DB } from "../../db/connection"; @@ -35,6 +35,7 @@ builder.queryField("articles", (t) => offset: t.arg.int(), sortBy: t.arg.string(), tag: t.arg.string(), + excludeId: t.arg.string(), }, resolve: async (_root, args, ctx) => { const pageSize = args.limit ?? 24; @@ -46,6 +47,7 @@ builder.queryField("articles", (t) => } if (args.category) conditions.push(eq(articles.category, args.category)); if (args.tag) conditions.push(arrayContains(articles.tags, [args.tag])); + if (args.excludeId) conditions.push(ne(articles.id, args.excludeId)); if (args.search) { conditions.push( or( diff --git a/packages/backend/src/graphql/resolvers/videos.ts b/packages/backend/src/graphql/resolvers/videos.ts index ec160ff..93e262a 100644 --- a/packages/backend/src/graphql/resolvers/videos.ts +++ b/packages/backend/src/graphql/resolvers/videos.ts @@ -30,6 +30,7 @@ import { arrayContains, isNull, or, + ne, type SQL, } from "drizzle-orm"; import { requireAdmin } from "../../lib/acl"; @@ -97,6 +98,7 @@ builder.queryField("videos", (t) => sortBy: t.arg.string(), duration: t.arg.string(), tag: t.arg.string(), + excludeId: t.arg.string(), }, resolve: async (_root, args, ctx) => { const pageSize = args.limit ?? 24; @@ -113,6 +115,9 @@ builder.queryField("videos", (t) => if (args.tag) { conditions.push(arrayContains(videos.tags, [args.tag])); } + if (args.excludeId) { + conditions.push(ne(videos.id, args.excludeId)); + } if (args.modelId) { const videoIds = await ctx.db .select({ video_id: video_models.video_id }) diff --git a/packages/frontend/src/lib/i18n/locales/en.ts b/packages/frontend/src/lib/i18n/locales/en.ts index c9806f6..83260bb 100644 --- a/packages/frontend/src/lib/i18n/locales/en.ts +++ b/packages/frontend/src/lib/i18n/locales/en.ts @@ -331,6 +331,7 @@ export default { commenting: "Commenting...", error: "Heads Up!", back: "Back to Videos", + related: "Related Videos", }, magazine: { title: "Sexy Magazine", @@ -358,6 +359,7 @@ export default { no_results: "No articles found matching your criteria.", clear_filters: "Clear Filters", back: "Back to Magazine", + related: "More in this Category", }, tags: { title: "{tag}", diff --git a/packages/frontend/src/lib/services.ts b/packages/frontend/src/lib/services.ts index 407db69..81fd704 100644 --- a/packages/frontend/src/lib/services.ts +++ b/packages/frontend/src/lib/services.ts @@ -245,6 +245,7 @@ const ARTICLES_QUERY = gql` $limit: Int $featured: Boolean $tag: String + $excludeId: String ) { articles( search: $search @@ -254,6 +255,7 @@ const ARTICLES_QUERY = gql` limit: $limit featured: $featured tag: $tag + excludeId: $excludeId ) { items { id @@ -287,6 +289,7 @@ export async function getArticles( limit?: number; featured?: boolean; tag?: string; + excludeId?: string; } = {}, fetchFn?: typeof globalThis.fetch, ): Promise<{ items: Article[]; total: number }> { @@ -366,6 +369,7 @@ const VIDEOS_QUERY = gql` $sortBy: String $duration: String $tag: String + $excludeId: String ) { videos( modelId: $modelId @@ -376,6 +380,7 @@ const VIDEOS_QUERY = gql` sortBy: $sortBy duration: $duration tag: $tag + excludeId: $excludeId ) { items { id @@ -416,6 +421,8 @@ export async function getVideos( offset?: number; limit?: number; tag?: string; + featured?: boolean; + excludeId?: string; } = {}, fetchFn?: typeof globalThis.fetch, ): Promise<{ items: Video[]; total: number }> { diff --git a/packages/frontend/src/routes/magazine/[slug]/+page.server.ts b/packages/frontend/src/routes/magazine/[slug]/+page.server.ts index a2165a3..7cf985c 100644 --- a/packages/frontend/src/routes/magazine/[slug]/+page.server.ts +++ b/packages/frontend/src/routes/magazine/[slug]/+page.server.ts @@ -1,10 +1,24 @@ import { error } from "@sveltejs/kit"; -import { getArticleBySlug } from "$lib/services.js"; +import { getArticleBySlug, getArticles, getFeaturedVideos } from "$lib/services.js"; + export async function load({ fetch, params, locals }) { + const article = await getArticleBySlug(params.slug, fetch); + + const [relatedArticles, featuredVideos] = await Promise.all([ + article.category + ? getArticles({ category: article.category, excludeId: article.id, limit: 5 }, fetch) + : article.tags?.length + ? getArticles({ tag: article.tags[0], excludeId: article.id, limit: 5 }, fetch) + : Promise.resolve({ items: [], total: 0 }), + getFeaturedVideos(1, fetch), + ]); + try { return { - article: await getArticleBySlug(params.slug, fetch), + article, authStatus: locals.authStatus, + relatedArticles: relatedArticles.items, + featuredVideo: featuredVideos[0] ?? null, }; } catch { error(404, "Article not found"); diff --git a/packages/frontend/src/routes/magazine/[slug]/+page.svelte b/packages/frontend/src/routes/magazine/[slug]/+page.svelte index b0a4d71..2979054 100644 --- a/packages/frontend/src/routes/magazine/[slug]/+page.svelte +++ b/packages/frontend/src/routes/magazine/[slug]/+page.svelte @@ -3,7 +3,7 @@ import { page } from "$app/state"; import { Button } from "$lib/components/ui/button"; import { Card, CardContent } from "$lib/components/ui/card"; - import { calcReadingTime } from "$lib/utils"; + import { calcReadingTime, formatVideoDuration } from "$lib/utils"; import TimeAgo from "javascript-time-ago"; import { getAssetUrl } from "$lib/api"; import Meta from "$lib/components/meta/meta.svelte"; @@ -176,43 +176,114 @@