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:
@@ -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(
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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 }> {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 @@
|
||||
|
||||
<!-- Sidebar -->
|
||||
<aside class="space-y-6">
|
||||
<!-- Related Articles -->
|
||||
<!--
|
||||
<Card class="bg-card/50">
|
||||
<CardContent class="p-6">
|
||||
<h3 class="font-semibold mb-4 flex items-center gap-2">
|
||||
<MessageCircleIcon class="w-5 h-5 text-primary" />
|
||||
Related Articles
|
||||
</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"
|
||||
>
|
||||
<!-- Featured Video -->
|
||||
{#if data.featuredVideo}
|
||||
{@const video = data.featuredVideo}
|
||||
<Card class="p-0 bg-card/50 overflow-hidden">
|
||||
<a href="/videos/{video.slug}" class="block group">
|
||||
<div class="relative">
|
||||
{#if video.image}
|
||||
<img
|
||||
src={related.image}
|
||||
alt={related.title}
|
||||
class="w-20 h-16 object-cover rounded"
|
||||
src={getAssetUrl(video.image, "preview")}
|
||||
alt={video.title}
|
||||
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">
|
||||
<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}
|
||||
</h4>
|
||||
<div
|
||||
class="flex items-center gap-2 text-xs text-muted-foreground"
|
||||
>
|
||||
<span>{related.author}</span>
|
||||
<span>•</span>
|
||||
<span>{related.readTime}</span>
|
||||
<div class="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span class="capitalize">{related.category}</span>
|
||||
<span>·</span>
|
||||
<span>
|
||||
{$_("magazine.read_time", {
|
||||
values: { time: calcReadingTime(related.content) },
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
-->
|
||||
{/if}
|
||||
|
||||
<!-- Back to Magazine -->
|
||||
<Button
|
||||
|
||||
@@ -1,18 +1,25 @@
|
||||
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 }) {
|
||||
const video = await getVideoBySlug(params.slug, fetch);
|
||||
const comments = await getCommentsForVideo(video.id, fetch);
|
||||
|
||||
let likeStatus = { liked: false };
|
||||
if (locals.authStatus.authenticated) {
|
||||
try {
|
||||
likeStatus = await getVideoLikeStatus(video.id, fetch);
|
||||
} catch (error) {
|
||||
console.error("Failed to get like status:", error);
|
||||
}
|
||||
}
|
||||
const [comments, likeStatus, relatedVideos, featuredArticle] = await Promise.all([
|
||||
getCommentsForVideo(video.id, fetch),
|
||||
locals.authStatus.authenticated
|
||||
? getVideoLikeStatus(video.id, fetch).catch(() => ({ liked: false }))
|
||||
: Promise.resolve({ liked: false }),
|
||||
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 {
|
||||
return {
|
||||
@@ -20,6 +27,8 @@ export async function load({ fetch, params, locals }) {
|
||||
comments,
|
||||
authStatus: locals.authStatus,
|
||||
likeStatus,
|
||||
relatedVideos: relatedVideos.items,
|
||||
featuredArticle: featuredArticle.items[0] ?? null,
|
||||
};
|
||||
} catch {
|
||||
error(404, "Video not found");
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
import Textarea from "$lib/components/ui/textarea/textarea.svelte";
|
||||
import Avatar from "$lib/components/ui/avatar/avatar.svelte";
|
||||
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 { toast } from "svelte-sonner";
|
||||
import {
|
||||
@@ -439,43 +439,99 @@
|
||||
<!-- Sidebar -->
|
||||
<div class="space-y-6">
|
||||
<!-- Related Videos -->
|
||||
<!-- <Card class="bg-card/50">
|
||||
{#if data.relatedVideos.length > 0}
|
||||
<Card class="p-0 bg-card/50">
|
||||
<CardContent class="p-4">
|
||||
<h3 class="font-semibold mb-4">Related Videos</h3>
|
||||
<div class="space-y-4">
|
||||
{#each relatedVideos as relatedVideo}
|
||||
<button
|
||||
onclick={() => onNavigate('video')}
|
||||
class="flex gap-3 w-full text-left hover:bg-primary/5 p-2 rounded-lg transition-colors"
|
||||
<h3 class="font-semibold mb-4 flex items-center gap-2">
|
||||
<span class="icon-[ri--film-line] w-4 h-4 text-primary"></span>
|
||||
{$_("videos.related")}
|
||||
</h3>
|
||||
<div class="space-y-3">
|
||||
{#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
|
||||
src={relatedData.video.thumbnail}
|
||||
alt={relatedData.video.title}
|
||||
src={getAssetUrl(related.image, "mini")}
|
||||
alt={related.title}
|
||||
class="w-24 h-16 object-cover rounded"
|
||||
/>
|
||||
{:else}
|
||||
<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>
|
||||
{/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 class="flex-1 min-w-0">
|
||||
<h4 class="font-medium text-sm line-clamp-2 mb-1">
|
||||
{relatedData.video.title}
|
||||
<h4
|
||||
class="font-medium text-sm line-clamp-2 mb-1 group-hover:text-primary transition-colors"
|
||||
>
|
||||
{related.title}
|
||||
</h4>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{relatedData.video.model}
|
||||
{timeAgo.format(new Date(related.upload_date))}
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{relatedData.video.views} views
|
||||
{#if related.plays_count}
|
||||
<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>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</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 -->
|
||||
<Button
|
||||
|
||||
Reference in New Issue
Block a user