Spaces:
Runtime error
Runtime error
Commit ·
9196ebb
1
Parent(s): ce58dc7
feat: Add ownership check to display dashboard link in vault header and refactor video page.
Browse files
src/app/vault/[creatorSlug]/page.tsx
CHANGED
|
@@ -10,6 +10,8 @@ import { VaultGridSkeleton } from '@/features/vault/components/vault-grid-skelet
|
|
| 10 |
import { VaultFilters } from '@/features/vault/components/vault-filters';
|
| 11 |
import { CategoryFilter } from '@/features/vault/components/category-filter';
|
| 12 |
import { VaultHeader } from '@/features/vault/components/vault-header';
|
|
|
|
|
|
|
| 13 |
|
| 14 |
// ISR: Revalidate every 5 minutes
|
| 15 |
export const revalidate = 300;
|
|
@@ -82,6 +84,14 @@ export default async function VaultPage({ params, searchParams }: PageProps) {
|
|
| 82 |
sortBy: resolvedSearchParams.sortBy as any,
|
| 83 |
});
|
| 84 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
// Render vault grid with loading skeleton
|
| 86 |
// Story 4.2: Category filtering happens via URL params
|
| 87 |
// Story 4.3: Products are passed to VaultGrid for display
|
|
@@ -90,7 +100,7 @@ export default async function VaultPage({ params, searchParams }: PageProps) {
|
|
| 90 |
<div className="min-h-screen bg-background pb-20 overflow-x-hidden">
|
| 91 |
{/* Centered Header Section */}
|
| 92 |
<div className="animate-in fade-in duration-700">
|
| 93 |
-
<VaultHeader channel={vault.channel} />
|
| 94 |
</div>
|
| 95 |
|
| 96 |
<div className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 -mt-6 relative z-20 space-y-8 animate-in slide-in-from-bottom-4 duration-700 delay-150">
|
|
|
|
| 10 |
import { VaultFilters } from '@/features/vault/components/vault-filters';
|
| 11 |
import { CategoryFilter } from '@/features/vault/components/category-filter';
|
| 12 |
import { VaultHeader } from '@/features/vault/components/vault-header';
|
| 13 |
+
import { auth } from '@/lib/auth';
|
| 14 |
+
import { headers } from 'next/headers';
|
| 15 |
|
| 16 |
// ISR: Revalidate every 5 minutes
|
| 17 |
export const revalidate = 300;
|
|
|
|
| 84 |
sortBy: resolvedSearchParams.sortBy as any,
|
| 85 |
});
|
| 86 |
|
| 87 |
+
// Check if current user is owner
|
| 88 |
+
const session = await auth.api.getSession({
|
| 89 |
+
headers: await headers(),
|
| 90 |
+
});
|
| 91 |
+
|
| 92 |
+
const isOwner = session?.user?.id === vault.channel.creatorId;
|
| 93 |
+
const dashboardUrl = isOwner ? '/dashboard' : undefined;
|
| 94 |
+
|
| 95 |
// Render vault grid with loading skeleton
|
| 96 |
// Story 4.2: Category filtering happens via URL params
|
| 97 |
// Story 4.3: Products are passed to VaultGrid for display
|
|
|
|
| 100 |
<div className="min-h-screen bg-background pb-20 overflow-x-hidden">
|
| 101 |
{/* Centered Header Section */}
|
| 102 |
<div className="animate-in fade-in duration-700">
|
| 103 |
+
<VaultHeader channel={vault.channel} dashboardUrl={dashboardUrl} />
|
| 104 |
</div>
|
| 105 |
|
| 106 |
<div className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 -mt-6 relative z-20 space-y-8 animate-in slide-in-from-bottom-4 duration-700 delay-150">
|
src/app/vault/[creatorSlug]/video/[videoId]/page.tsx
CHANGED
|
@@ -1,9 +1,13 @@
|
|
| 1 |
import { notFound } from 'next/navigation';
|
| 2 |
-
import Image from 'next/image';
|
| 3 |
-
import Link from 'next/link';
|
| 4 |
-
import { ArrowLeft } from 'lucide-react';
|
| 5 |
import { VaultService } from '@/features/vault/services/vault.service';
|
| 6 |
-
import {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
|
| 8 |
interface VideoPageProps {
|
| 9 |
params: Promise<{
|
|
@@ -20,48 +24,69 @@ export default async function VideoPage({ params }: VideoPageProps) {
|
|
| 20 |
notFound();
|
| 21 |
}
|
| 22 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
return (
|
| 24 |
-
<
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
<div className="
|
| 28 |
-
<
|
| 29 |
-
<span className="text-border/40">|</span>
|
| 30 |
-
<Link href={`/vault/${creatorSlug}`} className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors">
|
| 31 |
-
<ArrowLeft className="h-4 w-4" />
|
| 32 |
-
Back to Vault
|
| 33 |
-
</Link>
|
| 34 |
</div>
|
| 35 |
-
</div>
|
| 36 |
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
{video.
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
className="object-cover"
|
| 47 |
-
priority
|
| 48 |
-
/>
|
| 49 |
</div>
|
| 50 |
)}
|
| 51 |
-
<div className="space-y-2">
|
| 52 |
-
<h1 className="text-2xl font-bold text-foreground">{video.title}</h1>
|
| 53 |
-
<p className="text-muted-foreground">
|
| 54 |
-
{products.length} {products.length === 1 ? 'product' : 'products'} detected
|
| 55 |
-
</p>
|
| 56 |
-
</div>
|
| 57 |
-
</div>
|
| 58 |
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
</div>
|
| 64 |
</div>
|
| 65 |
-
</
|
| 66 |
);
|
| 67 |
}
|
|
|
|
| 1 |
import { notFound } from 'next/navigation';
|
|
|
|
|
|
|
|
|
|
| 2 |
import { VaultService } from '@/features/vault/services/vault.service';
|
| 3 |
+
import { VaultHeader } from '@/features/vault/components/vault-header';
|
| 4 |
+
import { VideoEmbed } from '@/features/vault/components/video-embed';
|
| 5 |
+
import { VaultGrid } from '@/features/vault/components/vault-grid';
|
| 6 |
+
import { CategoryFilter } from '@/features/vault/components/category-filter';
|
| 7 |
+
import { Suspense } from 'react';
|
| 8 |
+
import { VaultGridSkeleton } from '@/features/vault/components/vault-grid-skeleton';
|
| 9 |
+
import { auth } from '@/lib/auth';
|
| 10 |
+
import { headers } from 'next/headers';
|
| 11 |
|
| 12 |
interface VideoPageProps {
|
| 13 |
params: Promise<{
|
|
|
|
| 24 |
notFound();
|
| 25 |
}
|
| 26 |
|
| 27 |
+
// Derive categories from products for the filter
|
| 28 |
+
const categories = Array.from(
|
| 29 |
+
new Set(products.map(p => p.category))
|
| 30 |
+
).map(category => ({
|
| 31 |
+
category,
|
| 32 |
+
count: products.filter(p => p.category === category).length
|
| 33 |
+
}));
|
| 34 |
+
|
| 35 |
+
// Construct vault object for VaultGrid
|
| 36 |
+
const vaultVideo = {
|
| 37 |
+
id: video.videoId, // Use YouTube ID for routing
|
| 38 |
+
title: video.title,
|
| 39 |
+
thumbnailUrl: video.thumbnailUrl || '',
|
| 40 |
+
viewCount: video.viewCount || 0,
|
| 41 |
+
publishedAt: video.publishedAt || new Date(),
|
| 42 |
+
approvedProductCount: products.length,
|
| 43 |
+
categories: categories.map(c => c.category)
|
| 44 |
+
};
|
| 45 |
+
|
| 46 |
+
const vault = {
|
| 47 |
+
channel: video.channel,
|
| 48 |
+
videos: [vaultVideo],
|
| 49 |
+
categories
|
| 50 |
+
};
|
| 51 |
+
|
| 52 |
+
// Check if current user is owner
|
| 53 |
+
const session = await auth.api.getSession({
|
| 54 |
+
headers: await headers(),
|
| 55 |
+
});
|
| 56 |
+
|
| 57 |
+
const isOwner = session?.user?.id === video.channel.creatorId;
|
| 58 |
+
const dashboardUrl = isOwner ? '/dashboard' : undefined;
|
| 59 |
+
|
| 60 |
return (
|
| 61 |
+
<Suspense fallback={<VaultGridSkeleton />}>
|
| 62 |
+
<div className="min-h-screen bg-background pb-20 overflow-x-hidden">
|
| 63 |
+
{/* Header */}
|
| 64 |
+
<div className="animate-in fade-in duration-700">
|
| 65 |
+
<VaultHeader channel={video.channel} dashboardUrl={dashboardUrl} />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
</div>
|
|
|
|
| 67 |
|
| 68 |
+
<div className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 -mt-6 relative z-20 space-y-8 animate-in slide-in-from-bottom-4 duration-700 delay-150">
|
| 69 |
+
|
| 70 |
+
{/* Video Embed */}
|
| 71 |
+
<VideoEmbed videoId={video.videoId} title={video.title} />
|
| 72 |
+
|
| 73 |
+
{/* Category Filter */}
|
| 74 |
+
{categories.length > 0 && (
|
| 75 |
+
<div className="flex justify-center">
|
| 76 |
+
<CategoryFilter categories={categories} />
|
|
|
|
|
|
|
|
|
|
| 77 |
</div>
|
| 78 |
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
|
| 80 |
+
{/* Content Grid (Products only) */}
|
| 81 |
+
<div className="mt-8">
|
| 82 |
+
<VaultGrid
|
| 83 |
+
vault={vault}
|
| 84 |
+
products={products}
|
| 85 |
+
hideVideos={true}
|
| 86 |
+
/>
|
| 87 |
+
</div>
|
| 88 |
</div>
|
| 89 |
</div>
|
| 90 |
+
</Suspense>
|
| 91 |
);
|
| 92 |
}
|
src/features/vault/components/vault-header.tsx
CHANGED
|
@@ -4,7 +4,7 @@
|
|
| 4 |
// Story 4.5: Added search bar integration
|
| 5 |
import Image from 'next/image';
|
| 6 |
import Link from 'next/link';
|
| 7 |
-
import { Share2 } from 'lucide-react';
|
| 8 |
import { Button } from '@/components/ui/button';
|
| 9 |
import { toast } from 'sonner';
|
| 10 |
import { SearchBar } from './search-bar';
|
|
@@ -22,7 +22,10 @@ function formatSubscriberCount(count: number | null): string {
|
|
| 22 |
return `${count} subscribers`;
|
| 23 |
}
|
| 24 |
|
| 25 |
-
export function VaultHeader({
|
|
|
|
|
|
|
|
|
|
| 26 |
const handleShare = async () => {
|
| 27 |
const url = window.location.href;
|
| 28 |
|
|
@@ -56,6 +59,19 @@ export function VaultHeader({ channel }: VaultHeaderProps) {
|
|
| 56 |
return (
|
| 57 |
<div className="bg-background/80 backdrop-blur-md border-b border-border/40 px-4 py-8 md:px-6">
|
| 58 |
<div className="mx-auto max-w-7xl flex flex-col items-center text-center space-y-6">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
{/* Creator Info Section */}
|
| 60 |
<div className="flex flex-col items-center gap-4">
|
| 61 |
<div className="flex items-center gap-4">
|
|
|
|
| 4 |
// Story 4.5: Added search bar integration
|
| 5 |
import Image from 'next/image';
|
| 6 |
import Link from 'next/link';
|
| 7 |
+
import { Share2, LayoutDashboard } from 'lucide-react';
|
| 8 |
import { Button } from '@/components/ui/button';
|
| 9 |
import { toast } from 'sonner';
|
| 10 |
import { SearchBar } from './search-bar';
|
|
|
|
| 22 |
return `${count} subscribers`;
|
| 23 |
}
|
| 24 |
|
| 25 |
+
export function VaultHeader({
|
| 26 |
+
channel,
|
| 27 |
+
dashboardUrl
|
| 28 |
+
}: VaultHeaderProps & { dashboardUrl?: string }) {
|
| 29 |
const handleShare = async () => {
|
| 30 |
const url = window.location.href;
|
| 31 |
|
|
|
|
| 59 |
return (
|
| 60 |
<div className="bg-background/80 backdrop-blur-md border-b border-border/40 px-4 py-8 md:px-6">
|
| 61 |
<div className="mx-auto max-w-7xl flex flex-col items-center text-center space-y-6">
|
| 62 |
+
|
| 63 |
+
{/* Ownership/Action Banner */}
|
| 64 |
+
{dashboardUrl && (
|
| 65 |
+
<div className="w-full flex justify-end -mb-4">
|
| 66 |
+
<Button variant="ghost" size="sm" asChild className="text-muted-foreground hover:text-primary gap-2">
|
| 67 |
+
<Link href={dashboardUrl}>
|
| 68 |
+
<LayoutDashboard className="h-4 w-4" />
|
| 69 |
+
Back to Dashboard
|
| 70 |
+
</Link>
|
| 71 |
+
</Button>
|
| 72 |
+
</div>
|
| 73 |
+
)}
|
| 74 |
+
|
| 75 |
{/* Creator Info Section */}
|
| 76 |
<div className="flex flex-col items-center gap-4">
|
| 77 |
<div className="flex items-center gap-4">
|
src/features/vault/services/vault.service.ts
CHANGED
|
@@ -49,6 +49,7 @@ export class VaultService {
|
|
| 49 |
subscriberCount: sampleCreator.subscriberCount,
|
| 50 |
thumbnailUrl: sampleCreator.avatar,
|
| 51 |
creatorSlug: sampleCreator.slug,
|
|
|
|
| 52 |
},
|
| 53 |
videos,
|
| 54 |
categories: [{ category: 'Tech', count: videos.reduce((acc, v) => acc + v.approvedProductCount, 0) }],
|
|
@@ -63,6 +64,7 @@ export class VaultService {
|
|
| 63 |
subscriberCount: youtubeChannels.subscriberCount,
|
| 64 |
thumbnailUrl: youtubeChannels.thumbnailUrl,
|
| 65 |
creatorSlug: youtubeChannels.creatorSlug,
|
|
|
|
| 66 |
videoId: youtubeVideos.id,
|
| 67 |
videoTitle: youtubeVideos.title,
|
| 68 |
videoThumbnailUrl: youtubeVideos.thumbnailUrl,
|
|
@@ -112,6 +114,7 @@ export class VaultService {
|
|
| 112 |
subscriberCount: youtubeChannels.subscriberCount,
|
| 113 |
thumbnailUrl: youtubeChannels.thumbnailUrl,
|
| 114 |
creatorSlug: youtubeChannels.creatorSlug,
|
|
|
|
| 115 |
})
|
| 116 |
.from(youtubeChannels)
|
| 117 |
.where(eq(youtubeChannels.creatorSlug, creatorSlug))
|
|
@@ -170,6 +173,7 @@ export class VaultService {
|
|
| 170 |
subscriberCount: firstRow.subscriberCount,
|
| 171 |
thumbnailUrl: firstRow.thumbnailUrl,
|
| 172 |
creatorSlug: firstRow.creatorSlug,
|
|
|
|
| 173 |
},
|
| 174 |
videos,
|
| 175 |
categories, // Story 4.2: Available categories with counts
|
|
@@ -618,20 +622,28 @@ export class VaultService {
|
|
| 618 |
* Get a single video's details and its approved products.
|
| 619 |
* Used by the /vault/[creatorSlug]/video/[videoId] page.
|
| 620 |
*/
|
| 621 |
-
static async getVideoProducts(videoId: string)
|
| 622 |
-
video: { id: string; title: string; thumbnailUrl: string | null; viewCount: number | null; publishedAt: Date | null } | null;
|
| 623 |
-
products: ProductCard[];
|
| 624 |
-
}> {
|
| 625 |
// Try UUID first, then YouTube ID
|
| 626 |
const videoResult = await db
|
| 627 |
.select({
|
| 628 |
id: youtubeVideos.id,
|
|
|
|
|
|
|
| 629 |
title: youtubeVideos.title,
|
| 630 |
thumbnailUrl: youtubeVideos.thumbnailUrl,
|
| 631 |
viewCount: youtubeVideos.viewCount,
|
| 632 |
publishedAt: youtubeVideos.publishedAt,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 633 |
})
|
| 634 |
.from(youtubeVideos)
|
|
|
|
| 635 |
.where(
|
| 636 |
sql`${youtubeVideos.id} = ${videoId} OR ${youtubeVideos.videoId} = ${videoId}`
|
| 637 |
)
|
|
|
|
| 49 |
subscriberCount: sampleCreator.subscriberCount,
|
| 50 |
thumbnailUrl: sampleCreator.avatar,
|
| 51 |
creatorSlug: sampleCreator.slug,
|
| 52 |
+
creatorId: sampleCreator.id,
|
| 53 |
},
|
| 54 |
videos,
|
| 55 |
categories: [{ category: 'Tech', count: videos.reduce((acc, v) => acc + v.approvedProductCount, 0) }],
|
|
|
|
| 64 |
subscriberCount: youtubeChannels.subscriberCount,
|
| 65 |
thumbnailUrl: youtubeChannels.thumbnailUrl,
|
| 66 |
creatorSlug: youtubeChannels.creatorSlug,
|
| 67 |
+
creatorId: youtubeChannels.creatorId,
|
| 68 |
videoId: youtubeVideos.id,
|
| 69 |
videoTitle: youtubeVideos.title,
|
| 70 |
videoThumbnailUrl: youtubeVideos.thumbnailUrl,
|
|
|
|
| 114 |
subscriberCount: youtubeChannels.subscriberCount,
|
| 115 |
thumbnailUrl: youtubeChannels.thumbnailUrl,
|
| 116 |
creatorSlug: youtubeChannels.creatorSlug,
|
| 117 |
+
creatorId: youtubeChannels.creatorId,
|
| 118 |
})
|
| 119 |
.from(youtubeChannels)
|
| 120 |
.where(eq(youtubeChannels.creatorSlug, creatorSlug))
|
|
|
|
| 173 |
subscriberCount: firstRow.subscriberCount,
|
| 174 |
thumbnailUrl: firstRow.thumbnailUrl,
|
| 175 |
creatorSlug: firstRow.creatorSlug,
|
| 176 |
+
creatorId: firstRow.creatorId,
|
| 177 |
},
|
| 178 |
videos,
|
| 179 |
categories, // Story 4.2: Available categories with counts
|
|
|
|
| 622 |
* Get a single video's details and its approved products.
|
| 623 |
* Used by the /vault/[creatorSlug]/video/[videoId] page.
|
| 624 |
*/
|
| 625 |
+
static async getVideoProducts(videoId: string) {
|
|
|
|
|
|
|
|
|
|
| 626 |
// Try UUID first, then YouTube ID
|
| 627 |
const videoResult = await db
|
| 628 |
.select({
|
| 629 |
id: youtubeVideos.id,
|
| 630 |
+
|
| 631 |
+
videoId: youtubeVideos.videoId, // Added for linking
|
| 632 |
title: youtubeVideos.title,
|
| 633 |
thumbnailUrl: youtubeVideos.thumbnailUrl,
|
| 634 |
viewCount: youtubeVideos.viewCount,
|
| 635 |
publishedAt: youtubeVideos.publishedAt,
|
| 636 |
+
channel: {
|
| 637 |
+
id: youtubeChannels.id,
|
| 638 |
+
channelName: youtubeChannels.channelName,
|
| 639 |
+
subscriberCount: youtubeChannels.subscriberCount,
|
| 640 |
+
thumbnailUrl: youtubeChannels.thumbnailUrl,
|
| 641 |
+
creatorSlug: youtubeChannels.creatorSlug,
|
| 642 |
+
creatorId: youtubeChannels.creatorId
|
| 643 |
+
}
|
| 644 |
})
|
| 645 |
.from(youtubeVideos)
|
| 646 |
+
.innerJoin(youtubeChannels, eq(youtubeVideos.channelId, youtubeChannels.id))
|
| 647 |
.where(
|
| 648 |
sql`${youtubeVideos.id} = ${videoId} OR ${youtubeVideos.videoId} = ${videoId}`
|
| 649 |
)
|
src/features/vault/types/vault.types.ts
CHANGED
|
@@ -24,6 +24,7 @@ export interface CreatorVault {
|
|
| 24 |
subscriberCount: number | null;
|
| 25 |
thumbnailUrl: string | null;
|
| 26 |
creatorSlug: string;
|
|
|
|
| 27 |
};
|
| 28 |
videos: VaultVideo[];
|
| 29 |
categories: CategoryCount[]; // Story 4.2: Available categories with counts
|
|
|
|
| 24 |
subscriberCount: number | null;
|
| 25 |
thumbnailUrl: string | null;
|
| 26 |
creatorSlug: string;
|
| 27 |
+
creatorId: string;
|
| 28 |
};
|
| 29 |
videos: VaultVideo[];
|
| 30 |
categories: CategoryCount[]; // Story 4.2: Available categories with counts
|