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 { 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(
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
|||||||
@@ -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}",
|
||||||
|
|||||||
@@ -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 }> {
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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"
|
||||||
/>
|
/>
|
||||||
<div class="flex-1 min-w-0">
|
{:else}
|
||||||
<h4 class="font-medium text-sm line-clamp-2 mb-2">
|
<div class="w-full h-36 bg-muted/50 flex items-center justify-center">
|
||||||
{related.title}
|
<span class="icon-[ri--film-line] w-8 h-8 text-muted-foreground"></span>
|
||||||
</h4>
|
|
||||||
<div
|
|
||||||
class="flex items-center gap-2 text-xs text-muted-foreground"
|
|
||||||
>
|
|
||||||
<span>{related.author}</span>
|
|
||||||
<span>•</span>
|
|
||||||
<span>{related.readTime}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</button>
|
{/if}
|
||||||
{/each}
|
<div
|
||||||
</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"
|
||||||
</CardContent>
|
>
|
||||||
</Card>
|
<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-1 group-hover:text-primary transition-colors"
|
||||||
|
>
|
||||||
|
{related.title}
|
||||||
|
</h4>
|
||||||
|
<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>
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Back to Magazine -->
|
<!-- Back to Magazine -->
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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}
|
||||||
<CardContent class="p-4">
|
<Card class="p-0 bg-card/50">
|
||||||
<h3 class="font-semibold mb-4">Related Videos</h3>
|
<CardContent class="p-4">
|
||||||
<div class="space-y-4">
|
<h3 class="font-semibold mb-4 flex items-center gap-2">
|
||||||
{#each relatedVideos as relatedVideo}
|
<span class="icon-[ri--film-line] w-4 h-4 text-primary"></span>
|
||||||
<button
|
{$_("videos.related")}
|
||||||
onclick={() => onNavigate('video')}
|
</h3>
|
||||||
class="flex gap-3 w-full text-left hover:bg-primary/5 p-2 rounded-lg transition-colors"
|
<div class="space-y-3">
|
||||||
>
|
{#each data.relatedVideos as related (related.id)}
|
||||||
<div class="relative">
|
<a
|
||||||
<img
|
href="/videos/{related.slug}"
|
||||||
src={relatedData.video.thumbnail}
|
class="flex gap-3 hover:bg-primary/5 p-2 -mx-2 rounded-lg transition-colors group"
|
||||||
alt={relatedData.video.title}
|
>
|
||||||
class="w-24 h-16 object-cover rounded"
|
<div class="relative shrink-0">
|
||||||
/>
|
{#if related.image}
|
||||||
<div
|
<img
|
||||||
class="absolute bottom-1 right-1 bg-black/70 text-white text-xs px-1 rounded"
|
src={getAssetUrl(related.image, "mini")}
|
||||||
>
|
alt={related.title}
|
||||||
{relatedData.video.duration}
|
class="w-24 h-16 object-cover rounded"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
class="w-24 h-16 rounded bg-muted/50 flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
</div>
|
<div class="flex-1 min-w-0">
|
||||||
<div class="flex-1 min-w-0">
|
<h4
|
||||||
<h4 class="font-medium text-sm line-clamp-2 mb-1">
|
class="font-medium text-sm line-clamp-2 mb-1 group-hover:text-primary transition-colors"
|
||||||
{relatedData.video.title}
|
>
|
||||||
</h4>
|
{related.title}
|
||||||
<p class="text-xs text-muted-foreground">
|
</h4>
|
||||||
{relatedData.video.model}
|
<p class="text-xs text-muted-foreground">
|
||||||
</p>
|
{timeAgo.format(new Date(related.upload_date))}
|
||||||
<p class="text-xs text-muted-foreground">
|
</p>
|
||||||
{relatedData.video.views} views
|
{#if related.plays_count}
|
||||||
</p>
|
<p class="text-xs text-muted-foreground flex items-center gap-1 mt-0.5">
|
||||||
</div>
|
<span class="icon-[ri--play-fill] w-3 h-3"></span>
|
||||||
</button>
|
{related.plays_count}
|
||||||
{/each}
|
</p>
|
||||||
</div>
|
{/if}
|
||||||
</CardContent>
|
</div>
|
||||||
</Card> -->
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</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
|
||||||
|
|||||||
Reference in New Issue
Block a user