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 <noreply@anthropic.com>
This commit is contained in:
2026-03-12 18:35:04 +01:00
parent 6d1726ee97
commit 91951667e3
8 changed files with 251 additions and 85 deletions

View File

@@ -1,7 +1,7 @@
import { builder } from "../builder"; import { builder } from "../builder";
import { ArticleType, ArticleListType, AdminArticleListType } from "../types/index"; import { ArticleType, ArticleListType, AdminArticleListType } from "../types/index";
import { articles, users } from "../../db/schema/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 { requireAdmin } from "../../lib/acl";
import type { DB } from "../../db/connection"; import type { DB } from "../../db/connection";
@@ -35,6 +35,7 @@ builder.queryField("articles", (t) =>
offset: t.arg.int(), offset: t.arg.int(),
sortBy: t.arg.string(), sortBy: t.arg.string(),
tag: t.arg.string(), tag: t.arg.string(),
excludeId: t.arg.string(),
}, },
resolve: async (_root, args, ctx) => { resolve: async (_root, args, ctx) => {
const pageSize = args.limit ?? 24; 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.category) conditions.push(eq(articles.category, args.category));
if (args.tag) conditions.push(arrayContains(articles.tags, [args.tag])); if (args.tag) conditions.push(arrayContains(articles.tags, [args.tag]));
if (args.excludeId) conditions.push(ne(articles.id, args.excludeId));
if (args.search) { if (args.search) {
conditions.push( conditions.push(
or( or(

View File

@@ -30,6 +30,7 @@ import {
arrayContains, arrayContains,
isNull, isNull,
or, or,
ne,
type SQL, type SQL,
} from "drizzle-orm"; } from "drizzle-orm";
import { requireAdmin } from "../../lib/acl"; import { requireAdmin } from "../../lib/acl";
@@ -97,6 +98,7 @@ builder.queryField("videos", (t) =>
sortBy: t.arg.string(), sortBy: t.arg.string(),
duration: t.arg.string(), duration: t.arg.string(),
tag: t.arg.string(), tag: t.arg.string(),
excludeId: t.arg.string(),
}, },
resolve: async (_root, args, ctx) => { resolve: async (_root, args, ctx) => {
const pageSize = args.limit ?? 24; const pageSize = args.limit ?? 24;
@@ -113,6 +115,9 @@ builder.queryField("videos", (t) =>
if (args.tag) { if (args.tag) {
conditions.push(arrayContains(videos.tags, [args.tag])); conditions.push(arrayContains(videos.tags, [args.tag]));
} }
if (args.excludeId) {
conditions.push(ne(videos.id, args.excludeId));
}
if (args.modelId) { if (args.modelId) {
const videoIds = await ctx.db const videoIds = await ctx.db
.select({ video_id: video_models.video_id }) .select({ video_id: video_models.video_id })

View File

@@ -331,6 +331,7 @@ export default {
commenting: "Commenting...", commenting: "Commenting...",
error: "Heads Up!", error: "Heads Up!",
back: "Back to Videos", back: "Back to Videos",
related: "Related Videos",
}, },
magazine: { magazine: {
title: "Sexy Magazine", title: "Sexy Magazine",
@@ -358,6 +359,7 @@ export default {
no_results: "No articles found matching your criteria.", no_results: "No articles found matching your criteria.",
clear_filters: "Clear Filters", clear_filters: "Clear Filters",
back: "Back to Magazine", back: "Back to Magazine",
related: "More in this Category",
}, },
tags: { tags: {
title: "{tag}", title: "{tag}",

View File

@@ -245,6 +245,7 @@ const ARTICLES_QUERY = gql`
$limit: Int $limit: Int
$featured: Boolean $featured: Boolean
$tag: String $tag: String
$excludeId: String
) { ) {
articles( articles(
search: $search search: $search
@@ -254,6 +255,7 @@ const ARTICLES_QUERY = gql`
limit: $limit limit: $limit
featured: $featured featured: $featured
tag: $tag tag: $tag
excludeId: $excludeId
) { ) {
items { items {
id id
@@ -287,6 +289,7 @@ export async function getArticles(
limit?: number; limit?: number;
featured?: boolean; featured?: boolean;
tag?: string; tag?: string;
excludeId?: string;
} = {}, } = {},
fetchFn?: typeof globalThis.fetch, fetchFn?: typeof globalThis.fetch,
): Promise<{ items: Article[]; total: number }> { ): Promise<{ items: Article[]; total: number }> {
@@ -366,6 +369,7 @@ const VIDEOS_QUERY = gql`
$sortBy: String $sortBy: String
$duration: String $duration: String
$tag: String $tag: String
$excludeId: String
) { ) {
videos( videos(
modelId: $modelId modelId: $modelId
@@ -376,6 +380,7 @@ const VIDEOS_QUERY = gql`
sortBy: $sortBy sortBy: $sortBy
duration: $duration duration: $duration
tag: $tag tag: $tag
excludeId: $excludeId
) { ) {
items { items {
id id
@@ -416,6 +421,8 @@ export async function getVideos(
offset?: number; offset?: number;
limit?: number; limit?: number;
tag?: string; tag?: string;
featured?: boolean;
excludeId?: string;
} = {}, } = {},
fetchFn?: typeof globalThis.fetch, fetchFn?: typeof globalThis.fetch,
): Promise<{ items: Video[]; total: number }> { ): Promise<{ items: Video[]; total: number }> {

View File

@@ -1,10 +1,24 @@
import { error } from "@sveltejs/kit"; 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 }) { 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 { try {
return { return {
article: await getArticleBySlug(params.slug, fetch), article,
authStatus: locals.authStatus, authStatus: locals.authStatus,
relatedArticles: relatedArticles.items,
featuredVideo: featuredVideos[0] ?? null,
}; };
} catch { } catch {
error(404, "Article not found"); error(404, "Article not found");

View File

@@ -3,7 +3,7 @@
import { page } from "$app/state"; import { page } from "$app/state";
import { Button } from "$lib/components/ui/button"; import { Button } from "$lib/components/ui/button";
import { Card, CardContent } from "$lib/components/ui/card"; 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 TimeAgo from "javascript-time-ago";
import { getAssetUrl } from "$lib/api"; import { getAssetUrl } from "$lib/api";
import Meta from "$lib/components/meta/meta.svelte"; import Meta from "$lib/components/meta/meta.svelte";
@@ -176,43 +176,114 @@
<!-- Sidebar --> <!-- Sidebar -->
<aside class="space-y-6"> <aside class="space-y-6">
<!-- Related Articles --> <!-- Featured Video -->
<!-- {#if data.featuredVideo}
<Card class="bg-card/50"> {@const video = data.featuredVideo}
<CardContent class="p-6"> <Card class="p-0 bg-card/50 overflow-hidden">
<h3 class="font-semibold mb-4 flex items-center gap-2"> <a href="/videos/{video.slug}" class="block group">
<MessageCircleIcon class="w-5 h-5 text-primary" /> <div class="relative">
Related Articles {#if video.image}
</h3>
<div class="space-y-4">
{#each relatedArticles as related}
<button
onclick={() => onNavigate("article")}
class="flex gap-3 w-full text-left hover:bg-primary/5 p-3 rounded-lg transition-colors"
>
<img <img
src={related.image} src={getAssetUrl(video.image, "preview")}
alt={related.title} alt={video.title}
class="w-20 h-16 object-cover rounded" class="w-full h-36 object-cover group-hover:scale-105 transition-transform duration-300"
/> />
{:else}
<div class="w-full h-36 bg-muted/50 flex items-center justify-center">
<span class="icon-[ri--film-line] w-8 h-8 text-muted-foreground"></span>
</div>
{/if}
<div
class="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
>
<div class="w-12 h-12 bg-primary/90 rounded-full flex items-center justify-center">
<span class="icon-[ri--play-large-fill] w-6 h-6 text-white ml-0.5"></span>
</div>
</div>
{#if video.movie_file?.duration}
<div
class="absolute bottom-2 right-2 bg-black/80 text-white text-xs px-1.5 py-0.5 rounded font-medium"
>
{formatVideoDuration(video.movie_file.duration)}
</div>
{/if}
<div
class="absolute top-2 left-2 bg-gradient-to-r from-primary to-accent text-white text-xs px-2 py-0.5 rounded-full font-medium"
>
{$_("magazine.featured")}
</div>
</div>
<CardContent class="p-4">
<h3
class="font-semibold text-sm line-clamp-2 group-hover:text-primary transition-colors"
>
{video.title}
</h3>
{#if video.plays_count}
<p class="text-xs text-muted-foreground mt-1 flex items-center gap-1">
<span class="icon-[ri--play-fill] w-3 h-3"></span>
{video.plays_count}
{video.plays_count === 1 ? "play" : "plays"}
</p>
{/if}
</CardContent>
</a>
</Card>
{/if}
<!-- Related Articles -->
{#if data.relatedArticles.length > 0}
<Card class="p-0 bg-card/50">
<CardContent class="p-4">
<h3 class="font-semibold mb-4 flex items-center gap-2">
<span class="icon-[ri--article-line] w-4 h-4 text-primary"></span>
{$_("magazine.related")}
</h3>
<div class="space-y-3">
{#each data.relatedArticles as related (related.id)}
<a
href="/magazine/{related.slug}"
class="flex gap-3 hover:bg-primary/5 p-2 -mx-2 rounded-lg transition-colors group"
>
<div class="shrink-0">
{#if related.image}
<img
src={getAssetUrl(related.image, "mini")}
alt={related.title}
class="w-16 h-12 object-cover rounded"
/>
{:else}
<div
class="w-16 h-12 rounded bg-muted/50 flex items-center justify-center"
>
<span
class="icon-[ri--article-line] w-4 h-4 text-muted-foreground"
></span>
</div>
{/if}
</div>
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<h4 class="font-medium text-sm line-clamp-2 mb-2"> <h4
class="font-medium text-sm line-clamp-2 mb-1 group-hover:text-primary transition-colors"
>
{related.title} {related.title}
</h4> </h4>
<div <div class="flex items-center gap-2 text-xs text-muted-foreground">
class="flex items-center gap-2 text-xs text-muted-foreground" <span class="capitalize">{related.category}</span>
> <span>·</span>
<span>{related.author}</span> <span>
<span>•</span> {$_("magazine.read_time", {
<span>{related.readTime}</span> values: { time: calcReadingTime(related.content) },
})}
</span>
</div> </div>
</div> </div>
</button> </a>
{/each} {/each}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
--> {/if}
<!-- Back to Magazine --> <!-- Back to Magazine -->
<Button <Button

View File

@@ -1,18 +1,25 @@
import { error } from "@sveltejs/kit"; import { error } from "@sveltejs/kit";
import { getCommentsForVideo, getVideoBySlug, getVideoLikeStatus } from "$lib/services.js"; import {
getCommentsForVideo,
getVideoBySlug,
getVideoLikeStatus,
getVideos,
getArticles,
} from "$lib/services.js";
export async function load({ fetch, params, locals }) { export async function load({ fetch, params, locals }) {
const video = await getVideoBySlug(params.slug, fetch); const video = await getVideoBySlug(params.slug, fetch);
const comments = await getCommentsForVideo(video.id, fetch);
let likeStatus = { liked: false }; const [comments, likeStatus, relatedVideos, featuredArticle] = await Promise.all([
if (locals.authStatus.authenticated) { getCommentsForVideo(video.id, fetch),
try { locals.authStatus.authenticated
likeStatus = await getVideoLikeStatus(video.id, fetch); ? getVideoLikeStatus(video.id, fetch).catch(() => ({ liked: false }))
} catch (error) { : Promise.resolve({ liked: false }),
console.error("Failed to get like status:", error); video.tags?.length
} ? getVideos({ tag: video.tags[0], excludeId: video.id, limit: 5 }, fetch)
} : Promise.resolve({ items: [], total: 0 }),
getArticles({ featured: true, limit: 1 }, fetch),
]);
try { try {
return { return {
@@ -20,6 +27,8 @@ export async function load({ fetch, params, locals }) {
comments, comments,
authStatus: locals.authStatus, authStatus: locals.authStatus,
likeStatus, likeStatus,
relatedVideos: relatedVideos.items,
featuredArticle: featuredArticle.items[0] ?? null,
}; };
} catch { } catch {
error(404, "Video not found"); error(404, "Video not found");

View File

@@ -13,7 +13,7 @@
import Textarea from "$lib/components/ui/textarea/textarea.svelte"; import Textarea from "$lib/components/ui/textarea/textarea.svelte";
import Avatar from "$lib/components/ui/avatar/avatar.svelte"; import Avatar from "$lib/components/ui/avatar/avatar.svelte";
import { AvatarFallback, AvatarImage } from "$lib/components/ui/avatar"; import { AvatarFallback, AvatarImage } from "$lib/components/ui/avatar";
import { formatVideoDuration, getUserInitials } from "$lib/utils"; import { formatVideoDuration, getUserInitials, calcReadingTime } from "$lib/utils";
import { invalidateAll } from "$app/navigation"; import { invalidateAll } from "$app/navigation";
import { toast } from "svelte-sonner"; import { toast } from "svelte-sonner";
import { import {
@@ -439,43 +439,99 @@
<!-- Sidebar --> <!-- Sidebar -->
<div class="space-y-6"> <div class="space-y-6">
<!-- Related Videos --> <!-- Related Videos -->
<!-- <Card class="bg-card/50"> {#if data.relatedVideos.length > 0}
<Card class="p-0 bg-card/50">
<CardContent class="p-4"> <CardContent class="p-4">
<h3 class="font-semibold mb-4">Related Videos</h3> <h3 class="font-semibold mb-4 flex items-center gap-2">
<div class="space-y-4"> <span class="icon-[ri--film-line] w-4 h-4 text-primary"></span>
{#each relatedVideos as relatedVideo} {$_("videos.related")}
<button </h3>
onclick={() => onNavigate('video')} <div class="space-y-3">
class="flex gap-3 w-full text-left hover:bg-primary/5 p-2 rounded-lg transition-colors" {#each data.relatedVideos as related (related.id)}
<a
href="/videos/{related.slug}"
class="flex gap-3 hover:bg-primary/5 p-2 -mx-2 rounded-lg transition-colors group"
> >
<div class="relative"> <div class="relative shrink-0">
{#if related.image}
<img <img
src={relatedData.video.thumbnail} src={getAssetUrl(related.image, "mini")}
alt={relatedData.video.title} alt={related.title}
class="w-24 h-16 object-cover rounded" class="w-24 h-16 object-cover rounded"
/> />
{:else}
<div <div
class="absolute bottom-1 right-1 bg-black/70 text-white text-xs px-1 rounded" class="w-24 h-16 rounded bg-muted/50 flex items-center justify-center"
> >
{relatedData.video.duration} <span class="icon-[ri--film-line] w-5 h-5 text-muted-foreground"></span>
</div> </div>
{/if}
{#if related.movie_file?.duration}
<div
class="absolute bottom-1 right-1 bg-black/80 text-white text-xs px-1 py-0.5 rounded font-medium"
>
{formatVideoDuration(related.movie_file.duration)}
</div>
{/if}
</div> </div>
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<h4 class="font-medium text-sm line-clamp-2 mb-1"> <h4
{relatedData.video.title} class="font-medium text-sm line-clamp-2 mb-1 group-hover:text-primary transition-colors"
>
{related.title}
</h4> </h4>
<p class="text-xs text-muted-foreground"> <p class="text-xs text-muted-foreground">
{relatedData.video.model} {timeAgo.format(new Date(related.upload_date))}
</p> </p>
<p class="text-xs text-muted-foreground"> {#if related.plays_count}
{relatedData.video.views} views <p class="text-xs text-muted-foreground flex items-center gap-1 mt-0.5">
<span class="icon-[ri--play-fill] w-3 h-3"></span>
{related.plays_count}
</p> </p>
{/if}
</div> </div>
</button> </a>
{/each} {/each}
</div> </div>
</CardContent> </CardContent>
</Card> --> </Card>
{/if}
<!-- Featured Article -->
{#if data.featuredArticle}
{@const article = data.featuredArticle}
<Card class="p-0 bg-card/50 overflow-hidden">
<a href="/magazine/{article.slug}" class="block group">
{#if article.image}
<img
src={getAssetUrl(article.image, "preview")}
alt={article.title}
class="w-full h-36 object-cover group-hover:scale-105 transition-transform duration-300"
/>
{/if}
<CardContent class="p-4">
<div class="flex items-center gap-2 mb-2">
<span
class="text-xs bg-primary/10 text-primary px-2 py-0.5 rounded-full capitalize font-medium"
>
{article.category ?? "article"}
</span>
<span class="text-xs text-muted-foreground">
{$_("magazine.read_time", {
values: { time: calcReadingTime(article.content) },
})}
</span>
</div>
<h3
class="font-semibold text-sm line-clamp-2 mb-1 group-hover:text-primary transition-colors"
>
{article.title}
</h3>
<p class="text-xs text-muted-foreground line-clamp-2">{article.excerpt}</p>
</CardContent>
</a>
</Card>
{/if}
<!-- Back to Videos --> <!-- Back to Videos -->
<Button <Button