Skip to content

Commit c2b250a

Browse files
authored
Merge pull request #838 from NanaKhadija1980j/feature/826-explore-page
feat: add viral clip explore page (#826)
2 parents 181ec61 + 7a39a8e commit c2b250a

11 files changed

Lines changed: 588 additions & 0 deletions

File tree

app/(dashboard)/settings/page.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import Link from "next/link";
99
import { useToast } from "@/hooks/useToast";
1010
import { useAuth } from "@/components/auth/AuthProvider";
1111
import LocaleSwitcher from "@/components/LocaleSwitcher";
12+
import PrivacySettings from "@/components/settings/PrivacySettings";
1213
import {
1314
getStoredPermission,
1415
requestNotificationPermission,
@@ -439,6 +440,8 @@ export default function SettingsPage() {
439440
</div>
440441
</div>
441442

443+
<PrivacySettings />
444+
442445
{/* Language / Locale Settings */}
443446
<div className="space-y-4">
444447
<h2 className="text-lg font-extrabold text-white">Language</h2>

app/api/explore/exploreStore.ts

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
export interface ExploreClip {
2+
id: string;
3+
userId: string;
4+
creatorUsername: string;
5+
title: string;
6+
thumbnail: string;
7+
score: number;
8+
style: string;
9+
duration: string;
10+
videoUrl: string;
11+
isPublic: boolean;
12+
shareId: string;
13+
createdAt: string;
14+
}
15+
16+
export interface UserPrivacySettings {
17+
userId: string;
18+
exploreOptIn: boolean;
19+
showUsername: boolean;
20+
}
21+
22+
class PrivacyStore {
23+
private settings = new Map<string, UserPrivacySettings>();
24+
25+
get(userId: string): UserPrivacySettings {
26+
return (
27+
this.settings.get(userId) ?? {
28+
userId,
29+
exploreOptIn: false,
30+
showUsername: true,
31+
}
32+
);
33+
}
34+
35+
update(userId: string, update: Partial<Pick<UserPrivacySettings, "exploreOptIn" | "showUsername">>): UserPrivacySettings {
36+
const current = this.get(userId);
37+
const next = { ...current, ...update, userId };
38+
this.settings.set(userId, next);
39+
return next;
40+
}
41+
}
42+
43+
export const privacyStore = new PrivacyStore();
44+
45+
class ExploreStore {
46+
private clips: ExploreClip[] = [];
47+
48+
constructor() {
49+
this.seed();
50+
}
51+
52+
private seed() {
53+
const creators = ["viralVibes", "clipMaster", "trendSetter", "contentKing", "shortFormPro"];
54+
const styles = ["Bold & Dynamic", "Minimalist", "Emoji-Rich", "Subtitles Only"];
55+
const thumbs = ["/projects/thumb1.png", "/projects/thumb2.png", "/projects/thumb3.png"];
56+
57+
this.clips = Array.from({ length: 40 }, (_, i) => ({
58+
id: `explore-clip-${i + 1}`,
59+
userId: `creator-${(i % 5) + 1}`,
60+
creatorUsername: creators[i % creators.length],
61+
title: `Trending Clip #${String(i + 1).padStart(2, "0")}`,
62+
thumbnail: thumbs[i % thumbs.length],
63+
score: 95 - (i % 30),
64+
style: styles[i % styles.length],
65+
duration: `00:${String(30 + (i % 30)).padStart(2, "0")}`,
66+
videoUrl: "https://storage.googleapis.com/gtv-videos-bucket/sample/ForBiggerBlazes.mp4",
67+
isPublic: true,
68+
shareId: `explore-clip-${i + 1}-share`,
69+
createdAt: new Date(Date.now() - i * 3600000).toISOString(),
70+
}));
71+
}
72+
73+
getTrending(options: {
74+
cursor?: string;
75+
limit?: number;
76+
privacyFilter?: (clip: ExploreClip) => ExploreClip | null;
77+
}): { clips: ExploreClip[]; nextCursor: string | null } {
78+
const limit = options.limit ?? 20;
79+
let filtered = this.clips.filter((c) => c.isPublic);
80+
81+
if (options.privacyFilter) {
82+
filtered = filtered
83+
.map(options.privacyFilter)
84+
.filter((c): c is ExploreClip => c !== null);
85+
}
86+
87+
filtered.sort((a, b) => b.score - a.score);
88+
89+
let startIndex = 0;
90+
if (options.cursor) {
91+
const cursorIndex = filtered.findIndex((c) => c.id === options.cursor);
92+
startIndex = cursorIndex >= 0 ? cursorIndex + 1 : 0;
93+
}
94+
95+
const page = filtered.slice(startIndex, startIndex + limit);
96+
const nextCursor =
97+
startIndex + limit < filtered.length ? page[page.length - 1]?.id ?? null : null;
98+
99+
return { clips: page, nextCursor };
100+
}
101+
102+
getByShareId(shareId: string): ExploreClip | undefined {
103+
return this.clips.find((c) => c.shareId === shareId && c.isPublic);
104+
}
105+
106+
getById(id: string): ExploreClip | undefined {
107+
return this.clips.find((c) => c.id === id && c.isPublic);
108+
}
109+
}
110+
111+
export const exploreStore = new ExploreStore();

app/api/explore/trending/route.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
import { applyRateLimit } from "@/app/lib/serverRateLimit";
3+
import { exploreStore, privacyStore } from "./exploreStore";
4+
import type { ApiResponse } from "../types";
5+
6+
/**
7+
* GET /api/explore/trending
8+
*
9+
* Public endpoint — returns isPublic clips sorted by virality score.
10+
* Cursor pagination, 20 results per page.
11+
*/
12+
export async function GET(request: NextRequest) {
13+
const rateLimited = await applyRateLimit(request, { limit: 60, windowMs: 60_000 });
14+
if (rateLimited) return rateLimited;
15+
16+
const { searchParams } = new URL(request.url);
17+
const cursor = searchParams.get("cursor") ?? undefined;
18+
const limit = Math.min(20, Math.max(1, parseInt(searchParams.get("limit") ?? "20", 10)));
19+
20+
const { clips, nextCursor } = exploreStore.getTrending({
21+
cursor,
22+
limit,
23+
privacyFilter: (clip) => {
24+
const isDemoCreator = clip.userId.startsWith("creator-");
25+
const privacy = privacyStore.get(clip.userId);
26+
27+
if (!isDemoCreator && !privacy.exploreOptIn) {
28+
return null;
29+
}
30+
31+
return {
32+
...clip,
33+
creatorUsername: privacy.showUsername || isDemoCreator
34+
? clip.creatorUsername
35+
: "Anonymous Creator",
36+
};
37+
},
38+
});
39+
40+
const body: ApiResponse<{
41+
clips: Array<{
42+
id: string;
43+
title: string;
44+
thumbnail: string;
45+
score: number;
46+
style: string;
47+
duration: string;
48+
creatorUsername: string;
49+
shareId: string;
50+
}>;
51+
nextCursor: string | null;
52+
}> = {
53+
data: {
54+
clips: clips.map((c) => ({
55+
id: c.id,
56+
title: c.title,
57+
thumbnail: c.thumbnail,
58+
score: c.score,
59+
style: c.style,
60+
duration: c.duration,
61+
creatorUsername: c.creatorUsername,
62+
shareId: c.shareId,
63+
})),
64+
nextCursor,
65+
},
66+
error: null,
67+
};
68+
69+
return NextResponse.json(body);
70+
}

app/api/schemas/privacy.schema.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { z } from "zod";
2+
3+
export const privacySettingsSchema = z.object({
4+
exploreOptIn: z.boolean().optional(),
5+
showUsername: z.boolean().optional(),
6+
});
7+
8+
export type PrivacySettingsBody = z.infer<typeof privacySettingsSchema>;

app/api/user/privacy/route.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
import { checkCsrf } from "@/app/lib/csrf";
3+
import { requireAuth } from "@/app/api/jobs/shared/authGuard";
4+
import { parseRequestJson } from "@/app/lib/parseRequestJson";
5+
import { privacyStore } from "@/app/api/explore/exploreStore";
6+
import { privacySettingsSchema } from "@/app/api/schemas/privacy.schema";
7+
import type { ApiResponse } from "../types";
8+
9+
/**
10+
* GET /api/user/privacy — get explore privacy preferences.
11+
* PATCH — update explore opt-in and username visibility.
12+
*/
13+
export async function GET() {
14+
const authResult = await requireAuth();
15+
if (authResult instanceof NextResponse) return authResult;
16+
const { userId } = authResult;
17+
18+
const settings = privacyStore.get(userId);
19+
20+
const body: ApiResponse<{
21+
exploreOptIn: boolean;
22+
showUsername: boolean;
23+
}> = {
24+
data: {
25+
exploreOptIn: settings.exploreOptIn,
26+
showUsername: settings.showUsername,
27+
},
28+
error: null,
29+
};
30+
31+
return NextResponse.json(body);
32+
}
33+
34+
export async function PATCH(request: NextRequest) {
35+
const csrfError = checkCsrf(request);
36+
if (csrfError) return csrfError;
37+
38+
const authResult = await requireAuth();
39+
if (authResult instanceof NextResponse) return authResult;
40+
const { userId } = authResult;
41+
42+
const parsedBody = await parseRequestJson(request);
43+
if (!parsedBody.ok) return parsedBody.response;
44+
45+
const validation = privacySettingsSchema.safeParse(parsedBody.body);
46+
if (!validation.success) {
47+
return NextResponse.json(
48+
{ error: "Validation failed", issues: validation.error.issues },
49+
{ status: 400 },
50+
);
51+
}
52+
53+
const updated = privacyStore.update(userId, validation.data);
54+
55+
const body: ApiResponse<{
56+
exploreOptIn: boolean;
57+
showUsername: boolean;
58+
}> = {
59+
data: {
60+
exploreOptIn: updated.exploreOptIn,
61+
showUsername: updated.showUsername,
62+
},
63+
error: null,
64+
};
65+
66+
return NextResponse.json(body);
67+
}

app/explore/page.tsx

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import type { Metadata } from "next";
2+
import Link from "next/link";
3+
import BackgroundOrbs from "@/components/layout/BackgroundOrbs";
4+
import ExploreFeed from "@/components/explore/ExploreFeed";
5+
6+
export const metadata: Metadata = {
7+
title: "Explore Trending Clips — ClipCash",
8+
description:
9+
"Discover viral creator clips trending on ClipCash. Browse top-performing short-form content and create your own.",
10+
alternates: {
11+
canonical: "/explore",
12+
},
13+
openGraph: {
14+
title: "Explore Trending Clips — ClipCash",
15+
description:
16+
"Discover viral creator clips trending on ClipCash. Browse top-performing short-form content.",
17+
type: "website",
18+
url: "/explore",
19+
images: [
20+
{
21+
url: "/api/og?title=Explore%20Trending%20Clips&score=95",
22+
width: 1200,
23+
height: 630,
24+
alt: "Explore Trending Clips on ClipCash",
25+
},
26+
],
27+
},
28+
twitter: {
29+
card: "summary_large_image",
30+
title: "Explore Trending Clips — ClipCash",
31+
description: "Discover viral creator clips trending on ClipCash.",
32+
images: ["/api/og?title=Explore%20Trending%20Clips&score=95"],
33+
},
34+
};
35+
36+
export default function ExplorePage() {
37+
return (
38+
<div className="min-h-screen bg-[#0a0a0a] text-white relative overflow-hidden">
39+
<BackgroundOrbs />
40+
41+
<header className="relative z-10 border-b border-white/10 bg-black/40 backdrop-blur-md">
42+
<div className="max-w-6xl mx-auto px-6 py-5 flex items-center justify-between">
43+
<Link href="/" className="text-xl font-extrabold text-brand">
44+
ClipCash
45+
</Link>
46+
<Link
47+
href="/login"
48+
className="px-5 py-2 bg-brand text-black rounded-xl text-sm font-bold hover:bg-brand-hover transition-colors"
49+
data-analytics-event="explore_cta_click"
50+
>
51+
Create my own clips
52+
</Link>
53+
</div>
54+
</header>
55+
56+
<main className="relative z-10 max-w-6xl mx-auto px-6 py-10">
57+
<div className="mb-10 text-center">
58+
<h1 className="text-4xl font-extrabold tracking-tight mb-3">
59+
Explore Trending Clips
60+
</h1>
61+
<p className="text-muted-foreground max-w-xl mx-auto">
62+
Discover what&apos;s going viral. Browse top creator clips ranked by virality score.
63+
</p>
64+
</div>
65+
66+
<ExploreFeed />
67+
68+
<div className="mt-16 text-center">
69+
<p className="text-muted-foreground mb-4">Ready to create your own viral clips?</p>
70+
<Link
71+
href="/login"
72+
className="inline-flex px-8 py-3 bg-brand text-black rounded-2xl text-base font-bold hover:bg-brand-hover transition-colors shadow-[0_0_30px_rgba(0,229,143,0.3)]"
73+
data-analytics-event="explore_cta_click"
74+
>
75+
Create my own clips
76+
</Link>
77+
</div>
78+
</main>
79+
</div>
80+
);
81+
}

app/robots.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ export default function robots(): MetadataRoute.Robots {
4343
userAgent: "*",
4444
allow: [
4545
"/",
46+
"/explore",
4647
// Share links are the one authenticated-adjacent surface meant to be
4748
// public — they are how a clip reaches social media.
4849
"/share/",

app/share/[shareId]/page.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import React from "react";
22
import type { Metadata } from "next";
33
import Link from "next/link";
44
import BackgroundOrbs from "@/components/layout/BackgroundOrbs";
5+
import { exploreStore } from "@/app/api/explore/exploreStore";
56

67
/**
78
* Shape of a shared clip. Sourced from a mock today; the fetch below is the
@@ -22,6 +23,16 @@ interface SharedClip {
2223
async function getSharedClip(shareId: string): Promise<SharedClip | null> {
2324
if (!shareId) return null;
2425

26+
const exploreClip = exploreStore.getByShareId(shareId);
27+
if (exploreClip) {
28+
return {
29+
title: exploreClip.title,
30+
score: exploreClip.score,
31+
thumbnail: exploreClip.thumbnail,
32+
style: exploreClip.style,
33+
};
34+
}
35+
2536
return {
2637
title: "Shared Clip",
2738
score: 87,

0 commit comments

Comments
 (0)