dvijaykrishnan commited on
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 { ProductGrid } from '@/features/vault/components/product-grid';
 
 
 
 
 
 
 
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
- <div className="min-h-screen bg-background">
25
- {/* Sticky header */}
26
- <div className="sticky top-0 z-10 bg-background/80 backdrop-blur-md border-b border-border/40 px-4 py-4 md:px-6">
27
- <div className="mx-auto max-w-7xl flex items-center gap-3">
28
- <Link href="/" className="text-primary font-bold text-lg tracking-tight shrink-0">Vault</Link>
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
- <div className="mx-auto max-w-7xl px-4 py-8 md:px-6 space-y-8">
38
- {/* Video header */}
39
- <div className="flex flex-col sm:flex-row gap-6 items-start">
40
- {video.thumbnailUrl && (
41
- <div className="relative aspect-video w-full sm:w-auto sm:max-w-md overflow-hidden rounded-lg bg-card/40 border border-border/40 shrink-0">
42
- <Image
43
- src={video.thumbnailUrl}
44
- alt={video.title}
45
- fill
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
- {/* Product grid */}
60
- <div>
61
- <h2 className="text-xl font-semibold mb-4 text-foreground">Products in this video</h2>
62
- <ProductGrid products={products} />
 
 
 
 
63
  </div>
64
  </div>
65
- </div>
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({ channel }: VaultHeaderProps) {
 
 
 
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): Promise<{
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