76 | ) => {
77 | const file = event.target.files?.[0];
78 | if (file) {
79 | setIsAvatarUploading(true);
80 | const url = URL.createObjectURL(file);
81 | setAvatarPreview(url);
82 | setIsAvatarUploading(false);
83 | }
84 | };
85 |
86 | return (
87 |
172 | );
173 | }
174 |
--------------------------------------------------------------------------------
/cosmic/blocks/user-management/VerifyClient.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { verifyEmail } from "@/cosmic/blocks/user-management/actions";
4 | import { useSearchParams, useRouter } from "next/navigation";
5 | import { useEffect } from "react";
6 | import { Loader2 } from "lucide-react";
7 |
8 | export default function VerifyClient() {
9 | const searchParams = useSearchParams();
10 | const router = useRouter();
11 |
12 | useEffect(() => {
13 | const verifyUserEmail = async () => {
14 | const code = searchParams.get("code");
15 |
16 | if (!code) {
17 | router.push("/login?error=Invalid verification link");
18 | return;
19 | }
20 |
21 | try {
22 | await verifyEmail(code);
23 | router.push(
24 | "/login?success=Email verified successfully. You may now log in."
25 | );
26 | } catch (error) {
27 | const errorMessage =
28 | error instanceof Error ? error.message : "Verification failed";
29 | router.push(`/login?error=${encodeURIComponent(errorMessage)}`);
30 | }
31 | };
32 |
33 | verifyUserEmail();
34 | }, [searchParams, router]);
35 |
36 | return (
37 |
38 |
39 |
40 |
41 | Verifying your email...
42 |
43 |
44 |
45 | );
46 | }
47 |
--------------------------------------------------------------------------------
/cosmic/blocks/user-management/actions.ts:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import { cosmic } from "@/cosmic/client";
4 | import bcrypt from "bcryptjs";
5 | import { cookies } from "next/headers";
6 | import { Resend } from "resend";
7 | import crypto from "crypto";
8 |
9 | const resend = new Resend(process.env.RESEND_API_KEY);
10 |
11 | function isValidPassword(password: string): boolean {
12 | return (
13 | password.length >= 8 && /[A-Za-z]/.test(password) && /[0-9]/.test(password)
14 | );
15 | }
16 |
17 | export async function signUp(formData: FormData) {
18 | try {
19 | const email = (formData.get("email") as string).toLowerCase();
20 | const password = formData.get("password") as string;
21 | const firstName = formData.get("firstName") as string;
22 | const lastName = formData.get("lastName") as string;
23 |
24 | // Add password validation
25 | if (!isValidPassword(password)) {
26 | return {
27 | success: false,
28 | error:
29 | "Password must be at least 8 characters long and contain both letters and numbers",
30 | };
31 | }
32 |
33 | // Check if user already exists
34 | let existingUser;
35 | try {
36 | existingUser = await cosmic.objects
37 | .findOne({
38 | type: "users",
39 | "metadata.email": email,
40 | })
41 | .props(["metadata"])
42 | .depth(0);
43 | } catch (err) {
44 | // User does not exist
45 | }
46 |
47 | if (existingUser) {
48 | return {
49 | success: false,
50 | error: "An account with this email already exists",
51 | };
52 | }
53 |
54 | // Generate verification code
55 | const verificationCode = crypto.randomBytes(32).toString("hex");
56 | const verificationExpiry = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours
57 |
58 | // Hash password
59 | const hashedPassword = await bcrypt.hash(password, 10);
60 |
61 | // Create new user
62 | await cosmic.objects.insertOne({
63 | title: `${firstName} ${lastName}`,
64 | type: "users",
65 | metadata: {
66 | first_name: firstName,
67 | last_name: lastName,
68 | email: email,
69 | password: hashedPassword,
70 | active_status: true,
71 | email_verified: false,
72 | verification_code: verificationCode,
73 | verification_expiry: verificationExpiry,
74 | },
75 | });
76 |
77 | // Send verification email
78 | const verificationUrl = `${process.env.NEXT_PUBLIC_APP_URL}/verify?code=${verificationCode}`;
79 |
80 | try {
81 | await resend.emails.send({
82 | from: `${process.env.NEXT_PUBLIC_APP_NAME} Support <${process.env.SUPPORT_EMAIL}>`,
83 | to: email,
84 | subject: "Verify your email address",
85 | html: `
86 | Welcome to ${process.env.NEXT_PUBLIC_APP_NAME}!
87 | Please click the link below to verify your email address:
88 | Verify Email
89 | This link will expire in 24 hours.
90 | `,
91 | });
92 | console.log(`Verification email sent to ${email}`);
93 | } catch (error) {
94 | console.error("Error sending verification email:", error);
95 | return {
96 | success: false,
97 | error: "Failed to send verification email. Please try again.",
98 | };
99 | }
100 |
101 | return { success: true };
102 | } catch (error) {
103 | console.error("Signup error:", error);
104 | return {
105 | success: false,
106 | error: "Failed to create account. Please try again.",
107 | };
108 | }
109 | }
110 |
111 | export async function login(formData: FormData) {
112 | const email = (formData.get("email") as string).toLowerCase();
113 | const password = formData.get("password") as string;
114 |
115 | try {
116 | const result = await cosmic.objects
117 | .findOne({
118 | type: "users",
119 | "metadata.email": email,
120 | "metadata.email_verified": true,
121 | "metadata.active_status": true,
122 | })
123 | .props(["id", "title", "metadata"])
124 | .depth(0);
125 |
126 | if (!result.object) {
127 | return { error: "Invalid email or password" };
128 | }
129 |
130 | const isValid = await bcrypt.compare(
131 | password,
132 | result.object.metadata.password
133 | );
134 |
135 | if (!isValid) {
136 | return { error: "Invalid email or password" };
137 | }
138 |
139 | const user = {
140 | id: result.object.id,
141 | name: result.object.title,
142 | email: result.object.metadata.email,
143 | image: result.object.metadata.avatar?.imgix_url,
144 | };
145 |
146 | // Set the user_id cookie
147 | (await cookies()).set("user_id", result.object.id, {
148 | httpOnly: true,
149 | secure: process.env.NODE_ENV === "production",
150 | sameSite: "lax",
151 | path: "/",
152 | });
153 |
154 | return { user };
155 | } catch (error) {
156 | console.error("Login error:", error);
157 | return { error: "Invalid email or password" };
158 | }
159 | }
160 |
161 | export async function getUserData(userId: string) {
162 | try {
163 | const { object } = await cosmic.objects
164 | .findOne({
165 | id: userId,
166 | type: "users",
167 | })
168 | .props("id,title,metadata")
169 | .depth(0);
170 |
171 | if (!object) {
172 | return { data: null, error: "User not found" };
173 | }
174 |
175 | // Check active status after finding the user
176 | if (!object.metadata.active_status) {
177 | return { data: null, error: "Account is not active" };
178 | }
179 |
180 | return { data: object, error: null };
181 | } catch (error) {
182 | console.error("Error fetching user data:", error);
183 | return { data: null, error: "Failed to fetch user data" };
184 | }
185 | }
186 |
187 | export async function getUserFromCookie() {
188 | const cookieStore = await cookies();
189 | const userId = cookieStore.get("user_id");
190 | if (!userId) {
191 | return null;
192 | }
193 |
194 | try {
195 | const result = await cosmic.objects
196 | .findOne({
197 | type: "users",
198 | id: userId.value,
199 | "metadata.active_status": true,
200 | })
201 | .props(["id", "metadata.name", "metadata.email", "metadata.image"])
202 | .depth(0);
203 |
204 | if (!result?.object) {
205 | return null;
206 | }
207 |
208 | return {
209 | id: result.object.id,
210 | name: result.object.metadata.name,
211 | email: result.object.metadata.email,
212 | image: result.object.metadata.image,
213 | };
214 | } catch (error) {
215 | console.error("Error fetching user:", error);
216 | return null;
217 | }
218 | }
219 |
220 | async function uploadFile(file: File) {
221 | const arrayBuffer = await file.arrayBuffer();
222 | const buffer = Buffer.from(arrayBuffer);
223 | const media = { originalname: file.name, buffer };
224 | return await cosmic.media.insertOne({
225 | media,
226 | });
227 | }
228 |
229 | export async function updateUserProfile(userId: string, formData: FormData) {
230 | try {
231 | const firstName = formData.get("firstName") as string;
232 | const lastName = formData.get("lastName") as string;
233 | const email = (formData.get("email") as string).toLowerCase();
234 | const avatar = formData.get("avatar") as File;
235 |
236 | // Get current user data to check if email has changed
237 | const { object: currentUser } = await cosmic.objects
238 | .findOne({ id: userId })
239 | .props(["metadata"])
240 | .depth(0);
241 |
242 | const metadata: any = {
243 | first_name: firstName,
244 | last_name: lastName,
245 | email: email,
246 | };
247 |
248 | // If email has changed, generate new verification
249 | if (email !== currentUser.metadata.email) {
250 | // Check if new email already exists
251 | const existingUser = await cosmic.objects
252 | .findOne({
253 | type: "users",
254 | "metadata.email": email,
255 | })
256 | .props(["id"])
257 | .depth(0);
258 |
259 | if (existingUser.object) {
260 | return {
261 | success: false,
262 | error: "An account with this email already exists",
263 | };
264 | }
265 |
266 | const verificationCode = crypto.randomBytes(32).toString("hex");
267 | const verificationExpiry = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours
268 |
269 | metadata.email_verified = false;
270 | metadata.verification_code = verificationCode;
271 | metadata.verification_expiry = verificationExpiry;
272 |
273 | // Send new verification email
274 | const verificationUrl = `${process.env.NEXT_PUBLIC_APP_URL}/verify?code=${verificationCode}`;
275 | await resend.emails.send({
276 | from: `${process.env.NEXT_PUBLIC_APP_NAME} Support <${process.env.SUPPORT_EMAIL}>`,
277 | to: email,
278 | subject: "Verify your new email address",
279 | html: `
280 | Verify Your New Email Address
281 | Please click the link below to verify your new email address:
282 | Verify Email
283 | This link will expire in 24 hours.
284 | `,
285 | });
286 | }
287 |
288 | const updates: {
289 | title: string;
290 | metadata: any;
291 | thumbnail?: string;
292 | } = {
293 | title: `${firstName} ${lastName}`,
294 | metadata,
295 | };
296 |
297 | // Handle avatar upload if provided
298 | if (avatar && avatar.size > 0) {
299 | const { media } = await uploadFile(avatar);
300 | metadata.avatar = media.name;
301 | updates.thumbnail = media.name;
302 | }
303 |
304 | const { object } = await cosmic.objects.updateOne(userId, updates);
305 |
306 | return { success: true, data: object };
307 | } catch (error) {
308 | console.error("Error updating profile:", error);
309 | return { success: false, error: "Failed to update profile" };
310 | }
311 | }
312 |
313 | // Add new verification function
314 | export async function verifyEmail(code: string) {
315 | try {
316 | const { object } = await cosmic.objects
317 | .findOne({
318 | type: "users",
319 | "metadata.verification_code": code,
320 | })
321 | .props(["id", "metadata"])
322 | .depth(0);
323 |
324 | if (!object) {
325 | throw new Error("Invalid verification code");
326 | }
327 |
328 | const verificationExpiry = new Date(object.metadata.verification_expiry);
329 | if (verificationExpiry < new Date()) {
330 | throw new Error("Verification code has expired");
331 | }
332 |
333 | await cosmic.objects.updateOne(object.id, {
334 | metadata: {
335 | email_verified: true,
336 | verification_code: "",
337 | verification_expiry: "",
338 | },
339 | });
340 |
341 | return { success: true };
342 | } catch (error) {
343 | console.error("Error verifying email:", error);
344 | throw new Error("Email verification failed");
345 | }
346 | }
347 |
348 | export async function forgotPassword(formData: FormData) {
349 | try {
350 | const email = (formData.get("email") as string).toLowerCase();
351 |
352 | // Check if user exists
353 | const existingUser = await cosmic.objects
354 | .findOne({
355 | type: "users",
356 | "metadata.email": email,
357 | })
358 | .props(["id", "metadata"])
359 | .depth(0);
360 |
361 | if (!existingUser.object) {
362 | return {
363 | success: false,
364 | error: "No account found with this email address",
365 | };
366 | }
367 |
368 | // Generate reset token and expiry
369 | const resetToken = crypto.randomBytes(32).toString("hex");
370 | const resetExpiry = new Date(Date.now() + 1 * 60 * 60 * 1000); // 1 hour
371 |
372 | // Update user with reset token
373 | await cosmic.objects.updateOne(existingUser.object.id, {
374 | metadata: {
375 | reset_password_token: resetToken,
376 | reset_password_expiry: resetExpiry,
377 | },
378 | });
379 |
380 | // Send reset email
381 | const resetUrl = `${process.env.NEXT_PUBLIC_APP_URL}/reset-password?token=${resetToken}`;
382 |
383 | await resend.emails.send({
384 | from: `${process.env.NEXT_PUBLIC_APP_NAME} Support <${process.env.SUPPORT_EMAIL}>`,
385 | to: email,
386 | subject: "Reset your password",
387 | html: `
388 | Reset Your Password
389 | Click the link below to reset your password:
390 | Reset Password
391 | This link will expire in 1 hour.
392 | `,
393 | });
394 |
395 | return { success: true };
396 | } catch (error) {
397 | console.error("Forgot password error:", error);
398 | return {
399 | success: false,
400 | error: "Failed to process request. Please try again.",
401 | };
402 | }
403 | }
404 |
405 | export async function resetPassword(token: string, formData: FormData) {
406 | try {
407 | const password = formData.get("password") as string;
408 |
409 | // Add password validation
410 | if (!isValidPassword(password)) {
411 | return {
412 | success: false,
413 | error:
414 | "Password must be at least 8 characters long and contain both letters and numbers",
415 | };
416 | }
417 |
418 | // Find user with reset token
419 | const existingUser = await cosmic.objects
420 | .findOne({
421 | type: "users",
422 | "metadata.reset_password_token": token,
423 | })
424 | .props(["id", "metadata"])
425 | .depth(0);
426 |
427 | if (!existingUser.object) {
428 | return {
429 | success: false,
430 | error: "Invalid or expired reset token",
431 | };
432 | }
433 |
434 | const resetExpiry = new Date(
435 | existingUser.object.metadata.reset_password_expiry
436 | );
437 | if (resetExpiry < new Date()) {
438 | return {
439 | success: false,
440 | error: "Reset token has expired",
441 | };
442 | }
443 |
444 | // Hash new password
445 | const hashedPassword = await bcrypt.hash(password, 10);
446 |
447 | // Update user password and clear reset token
448 | await cosmic.objects.updateOne(existingUser.object.id, {
449 | metadata: {
450 | password: hashedPassword,
451 | reset_password_token: "",
452 | reset_password_expiry: "",
453 | },
454 | });
455 |
456 | return { success: true };
457 | } catch (error) {
458 | console.error("Reset password error:", error);
459 | return {
460 | success: false,
461 | error: "Failed to reset password. Please try again.",
462 | };
463 | }
464 | }
465 |
466 | export async function getAuthUser() {
467 | "use server";
468 | return await getUserFromCookie();
469 | }
470 |
471 | export async function logoutUser() {
472 | "use server";
473 | (await cookies()).delete("user_id");
474 | return { success: true };
475 | }
476 |
--------------------------------------------------------------------------------
/cosmic/blocks/videos/CategoriesList.tsx:
--------------------------------------------------------------------------------
1 | import { CategoryPill, CategoryType } from "./CategoryPill";
2 | import { cosmic } from "@/cosmic/client";
3 |
4 | function Categories({
5 | categories,
6 | activeSlug,
7 | }: {
8 | categories: CategoryType[];
9 | activeSlug?: string;
10 | }) {
11 | return (
12 |
13 | {categories.map((category: CategoryType) => {
14 | return (
15 |
20 | );
21 | })}
22 |
23 | );
24 | }
25 |
26 | export async function CategoriesList({
27 | query,
28 | sort,
29 | limit,
30 | skip,
31 | className,
32 | status,
33 | noWrap = false,
34 | activeSlug,
35 | }: {
36 | query: any;
37 | sort?: string;
38 | limit?: number;
39 | skip?: number;
40 | className?: string;
41 | status?: "draft" | "published" | "any";
42 | noWrap?: boolean;
43 | activeSlug?: string;
44 | }) {
45 | const { objects: categories } = await cosmic.objects
46 | .find(query)
47 | .props(
48 | `{
49 | id
50 | slug
51 | title
52 | metadata {
53 | emoji
54 | }
55 | }`
56 | )
57 | .depth(1)
58 | .sort(sort ? sort : "-order")
59 | .limit(limit ? limit : 100)
60 | .skip(skip ? skip : 0)
61 | .status(status ? status : "published");
62 | if (noWrap) return ;
63 | return (
64 |
65 |
66 |
67 | );
68 | }
69 |
--------------------------------------------------------------------------------
/cosmic/blocks/videos/CategoryPill.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable @next/next/no-img-element */
2 | import Link from "next/link";
3 | import { cn } from "@/cosmic/utils";
4 | export type CategoryType = {
5 | id: string;
6 | title: string;
7 | slug: string;
8 | created_at: string;
9 | metadata: {
10 | emoji: string;
11 | };
12 | };
13 |
14 | export function CategoryPill({
15 | category,
16 | className,
17 | active,
18 | }: {
19 | category: CategoryType;
20 | className?: string;
21 | active?: boolean;
22 | }) {
23 | return (
24 |
25 |
34 | {category.metadata?.emoji}
35 | {category.title}
36 |
37 |
38 | );
39 | }
40 |
--------------------------------------------------------------------------------
/cosmic/blocks/videos/ChannelPill.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable @next/next/no-img-element */
2 | import Link from "next/link";
3 | export type ChannelType = {
4 | id: string;
5 | slug: string;
6 | title: string;
7 | metadata: {
8 | thumbnail: {
9 | imgix_url: string;
10 | alt_text: string;
11 | };
12 | };
13 | };
14 |
15 | export function ChannelPill({ channel }: { channel: ChannelType }) {
16 | return (
17 |
22 |
26 |

31 |
32 |
33 | {channel.title}
34 |
35 |
36 | );
37 | }
38 |
--------------------------------------------------------------------------------
/cosmic/blocks/videos/ChannelsList.tsx:
--------------------------------------------------------------------------------
1 | import { cosmic } from "@/cosmic/client";
2 | import { ChannelPill, ChannelType } from "@/cosmic/blocks/videos/ChannelPill";
3 |
4 | export async function ChannelsList() {
5 | try {
6 | const { objects: channels } = await cosmic.objects
7 | .find({
8 | type: "channels",
9 | })
10 | .props(
11 | `{
12 | id
13 | slug
14 | title
15 | metadata {
16 | thumbnail {
17 | imgix_url
18 | }
19 | }
20 | }`
21 | )
22 | .depth(1)
23 | .options({
24 | media: {
25 | props: "alt_text",
26 | },
27 | });
28 | return channels?.map((channel: ChannelType) => {
29 | return ;
30 | });
31 | } catch (e: any) {
32 | if (e.status === 404) return <>No results found.>;
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/cosmic/blocks/videos/FollowButton.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Button } from "@/cosmic/elements/Button";
4 | import { useAuth } from "@/cosmic/blocks/user-management/AuthContext";
5 | import { followChannel, checkIsFollowing } from "./followActions";
6 | import { useState, useEffect } from "react";
7 | import { useRouter } from "next/navigation";
8 | import { Loader2 } from "lucide-react";
9 |
10 | export function FollowButton({ channelId }: { channelId: string }) {
11 | const { user } = useAuth();
12 | const router = useRouter();
13 | const [isFollowing, setIsFollowing] = useState(false);
14 | const [isLoading, setIsLoading] = useState(true);
15 |
16 | useEffect(() => {
17 | async function checkFollowStatus() {
18 | if (user) {
19 | const isFollowing = await checkIsFollowing(user.id, channelId);
20 | setIsFollowing(isFollowing);
21 | }
22 | setIsLoading(false);
23 | }
24 |
25 | checkFollowStatus();
26 | }, [user, channelId]);
27 |
28 | const handleFollow = async () => {
29 | if (!user) {
30 | router.push("/login");
31 | return;
32 | }
33 |
34 | setIsLoading(true);
35 | try {
36 | const result = await followChannel(user.id, channelId);
37 | if (result.success) {
38 | setIsFollowing(result.isFollowing ?? false);
39 | }
40 | } catch (error) {
41 | console.error("Error following channel:", error);
42 | }
43 | setIsLoading(false);
44 | };
45 |
46 | return (
47 |
61 | );
62 | }
63 |
--------------------------------------------------------------------------------
/cosmic/blocks/videos/PlayArea.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable @next/next/no-img-element */
2 | "use client";
3 |
4 | import { VideoType } from "./VideoCard";
5 | import { useState } from "react";
6 | export function PlayArea({ video }: { video: VideoType }) {
7 | const [isClicked, setIsClicked] = useState(false);
8 | return (
9 | <>
10 | {!isClicked ? (
11 |
12 |
35 |
36 | ) : (
37 |
43 | )}
44 | >
45 | );
46 | }
47 |
--------------------------------------------------------------------------------
/cosmic/blocks/videos/SingleChannel.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable @next/next/no-img-element */
2 | import { cosmic } from "@/cosmic/client";
3 | import { notFound } from "next/navigation";
4 | import { VideoList } from "@/cosmic/blocks/videos/VideoList";
5 | import { FollowButton } from "@/cosmic/blocks/videos/FollowButton";
6 | import { useAuth } from "@/cosmic/blocks/user-management/AuthContext";
7 | export async function SingleChannel({
8 | query,
9 | className,
10 | status,
11 | }: {
12 | query: any;
13 | className?: string;
14 | status?: "draft" | "published" | "any";
15 | }) {
16 | try {
17 | const { object: channel } = await cosmic.objects
18 | .findOne(query)
19 | .props(
20 | `{
21 | id
22 | slug
23 | title
24 | metadata
25 | }`
26 | )
27 | .depth(1)
28 | .status(status ? status : "published")
29 | .options({
30 | media: {
31 | props: "alt_text",
32 | },
33 | });
34 | return (
35 |
36 |
37 |

42 |
43 |
44 |
45 |

50 |
54 | {channel.title}
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 | About
65 |
66 |
70 |
71 |
72 |
73 | Videos
74 |
75 |
82 |
83 |
84 | );
85 | } catch (e: any) {
86 | if (e.status === 404) return notFound();
87 | }
88 | }
89 |
--------------------------------------------------------------------------------
/cosmic/blocks/videos/SingleVideo.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable @next/next/no-img-element */
2 | import { cosmic } from "@/cosmic/client";
3 | import { notFound } from "next/navigation";
4 | import { TimeAgo } from "@/components/TimeAgo";
5 | import { VideoType } from "./VideoCard";
6 | import { PlayArea } from "./PlayArea";
7 | import Link from "next/link";
8 | import { CategoryPill, CategoryType } from "./CategoryPill";
9 | import { Comments } from "@/cosmic/blocks/comments/Comments";
10 | import { FollowButton } from "./FollowButton";
11 | export async function SingleVideo({
12 | query,
13 | className,
14 | status,
15 | }: {
16 | query: any;
17 | className?: string;
18 | status?: "draft" | "published" | "any";
19 | }) {
20 | try {
21 | const { object: video }: { object: VideoType } = await cosmic.objects
22 | .findOne(query)
23 | .props(
24 | `{
25 | id
26 | slug
27 | title
28 | created_at
29 | metadata
30 | }`
31 | )
32 | .depth(1)
33 | .status(status ? status : "published")
34 | .options({
35 | media: {
36 | props: "alt_text",
37 | },
38 | });
39 |
40 | return (
41 |
42 |
45 |
46 |
47 |
48 |
52 | {video.title}
53 |
54 |
55 |
56 |
57 |
61 |

66 |
67 |
68 | {video.metadata.channel.title}
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 | {video.metadata.categories.map((category: CategoryType) => {
77 | return ;
78 | })}
79 |
80 |
81 |
85 |
86 |
87 |
94 |
95 | );
96 | } catch (e: any) {
97 | if (e.status === 404) return notFound();
98 | }
99 | }
100 |
--------------------------------------------------------------------------------
/cosmic/blocks/videos/VideoCard.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable @next/next/no-img-element */
2 | import Link from "next/link";
3 | import { TimeAgo } from "@/components/TimeAgo";
4 | import { CategoryType } from "./CategoryPill";
5 |
6 | export type VideoType = {
7 | id: string;
8 | title: string;
9 | slug: string;
10 | created_at: string;
11 | metadata: {
12 | thumbnail: {
13 | imgix_url: string;
14 | alt_text: string;
15 | };
16 | video: {
17 | url: string;
18 | };
19 | description: string;
20 | categories: CategoryType[];
21 | channel: {
22 | id: string;
23 | slug: string;
24 | title: string;
25 | metadata: {
26 | thumbnail: {
27 | alt_text: string | undefined;
28 | imgix_url: string;
29 | };
30 | };
31 | };
32 | };
33 | };
34 |
35 | export function VideoCard({
36 | video,
37 | className,
38 | }: {
39 | video: VideoType;
40 | className?: string;
41 | }) {
42 | return (
43 |
44 |
45 |

50 |
51 |
52 | {video.title}
53 |
54 |
55 |
56 |
57 |
61 |
62 |
66 |
70 |

75 |
76 |
77 |
78 | {video.metadata.channel.title}
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 |
88 | );
89 | }
90 |
--------------------------------------------------------------------------------
/cosmic/blocks/videos/VideoList.tsx:
--------------------------------------------------------------------------------
1 | import { VideoCard, VideoType } from "./VideoCard";
2 | import { cosmic } from "@/cosmic/client";
3 |
4 | function Videos({ videos }: { videos: VideoType[] }) {
5 | return (
6 | <>
7 | {videos.map((video: VideoType) => {
8 | return ;
9 | })}
10 | >
11 | );
12 | }
13 |
14 | export async function VideoList({
15 | query,
16 | sort,
17 | limit,
18 | skip,
19 | className,
20 | status,
21 | noWrap = false,
22 | }: {
23 | query: any;
24 | sort?: string;
25 | limit?: number;
26 | skip?: number;
27 | className?: string;
28 | status?: "draft" | "published" | "any";
29 | noWrap?: boolean;
30 | }) {
31 | try {
32 | const { objects: videos } = await cosmic.objects
33 | .find(query)
34 | .props(
35 | `{
36 | id
37 | slug
38 | title
39 | created_at
40 | metadata {
41 | thumbnail {
42 | imgix_url
43 | }
44 | video {
45 | url
46 | }
47 | channel {
48 | slug
49 | title
50 | metadata {
51 | thumbnail {
52 | imgix_url
53 | }
54 | }
55 | }
56 | }
57 | }`
58 | )
59 | .depth(1)
60 | .sort(sort ? sort : "-order")
61 | .limit(limit ? limit : 100)
62 | .skip(skip ? skip : 0)
63 | .status(status ? status : "published")
64 | .options({
65 | media: {
66 | props: "alt_text",
67 | },
68 | });
69 | if (noWrap) return ;
70 | return (
71 |
72 |
73 |
74 | );
75 | } catch (e: any) {
76 | if (e.status === 404) return <>No results found.>;
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/cosmic/blocks/videos/followActions.ts:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import { cosmic } from "@/cosmic/client";
4 |
5 | export async function followChannel(userId: string, channelId: string) {
6 | try {
7 | // Get current user data
8 | const { object: user } = await cosmic.objects
9 | .findOne({
10 | id: userId,
11 | type: "users",
12 | })
13 | .props("metadata")
14 | .depth(0);
15 |
16 | // Get current followed channels or initialize empty array
17 | const followedChannels = user.metadata.channels || [];
18 |
19 | // Check if already following
20 | const isFollowing = followedChannels.includes(channelId);
21 |
22 | // Update the followed channels list
23 | const updatedFollowedChannels = isFollowing
24 | ? followedChannels.filter((id: string) => id !== channelId)
25 | : [...followedChannels, channelId];
26 |
27 | // Update user metadata
28 | await cosmic.objects.updateOne(userId, {
29 | metadata: {
30 | channels: updatedFollowedChannels,
31 | },
32 | });
33 |
34 | return {
35 | success: true,
36 | isFollowing: !isFollowing,
37 | };
38 | } catch (error) {
39 | console.error("Error following channel:", error);
40 | return {
41 | success: false,
42 | error: "Failed to update follow status",
43 | };
44 | }
45 | }
46 |
47 | export async function checkIsFollowing(userId: string, channelId: string) {
48 | if (!userId) return false;
49 |
50 | const { object: user } = await cosmic.objects
51 | .findOne({
52 | type: "users",
53 | id: userId,
54 | })
55 | .props("metadata");
56 |
57 | return user?.metadata?.channels?.includes(channelId) || false;
58 | }
59 |
--------------------------------------------------------------------------------
/cosmic/client.ts:
--------------------------------------------------------------------------------
1 | import { createBucketClient } from "@cosmicjs/sdk"
2 |
3 | if (!process.env.COSMIC_BUCKET_SLUG)
4 | console.error(
5 | "Error: Environment variables missing. You need to create an environment variable file and include COSMIC_BUCKET_SLUG, COSMIC_READ_KEY, and COSMIC_WRITE_KEY environment variables."
6 | )
7 | // Make sure to add/update your ENV variables
8 | export const cosmic = createBucketClient({
9 | bucketSlug:
10 | process.env.COSMIC_BUCKET_SLUG ||
11 | "You need to add your COSMIC_BUCKET_SLUG environment variable.",
12 | readKey:
13 | process.env.COSMIC_READ_KEY ||
14 | "You need to add your COSMIC_READ_KEY environment variabl.",
15 | writeKey:
16 | process.env.COSMIC_WRITE_KEY ||
17 | "You need to add your COSMIC_WRITE_KEY environment variable.",
18 | })
19 |
--------------------------------------------------------------------------------
/cosmic/elements/Button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { Slot } from "@radix-ui/react-slot";
3 | import { cva, type VariantProps } from "class-variance-authority";
4 | import { cn } from "@/cosmic/utils";
5 |
6 | const buttonVariants = cva(
7 | "inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none ring-offset-background",
8 | {
9 | variants: {
10 | variant: {
11 | default: "bg-teal-500 text-white hover:bg-teal-500/90",
12 | destructive:
13 | "bg-red-500 text-red-50 hover:bg-red-500/90 dark:bg-dark-red-500 dark:hover:bg-dark-red-500/90",
14 | outline:
15 | "border text-gray-700 dark:text-gray-100 border-input hover:bg-accent hover:text-accent-foreground",
16 | secondary:
17 | "bg-gray-50 dark:bg-dark-gray-100 text-gray-600 dark:text-dark-gray-600 hover:bg-gray-100 dark:hover:bg-dark-gray-200",
18 | ghost:
19 | "hover:bg-gray-50 dark:hover:bg-dark-gray-50 hover:text-gray-800 dark:hover:text-dark-gray-800",
20 | link: "underline-offset-4 hover:underline text-blue-500-link dark:blue-500",
21 | },
22 | size: {
23 | default: "h-10 py-2 px-4",
24 | sm: "h-9 px-3 rounded-md",
25 | lg: "h-11 px-8 rounded-md",
26 | icon: "h-10 w-10",
27 | },
28 | },
29 | defaultVariants: {
30 | variant: "default",
31 | size: "default",
32 | },
33 | }
34 | );
35 |
36 | export interface ButtonProps
37 | extends React.ButtonHTMLAttributes,
38 | VariantProps {
39 | asChild?: boolean;
40 | renderAs?: string;
41 | iconRight?: React.ReactElement;
42 | href?: string;
43 | target?: string;
44 | }
45 |
46 | const Button = React.forwardRef(
47 | (
48 | {
49 | className,
50 | variant,
51 | size,
52 | asChild = false,
53 | renderAs = "button",
54 | href,
55 | target,
56 | iconRight,
57 | ...props
58 | },
59 | ref
60 | ) => {
61 | const Comp = asChild ? Slot : href ? "a" : renderAs;
62 |
63 | return (
64 |
70 | {props.children}
71 | {iconRight && (
72 | {iconRight}
73 | )}
74 |
75 | );
76 | }
77 | );
78 |
79 | Button.displayName = "Button";
80 |
81 | export { Button, buttonVariants };
82 |
--------------------------------------------------------------------------------
/cosmic/elements/Input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | import { cn } from "@/cosmic/utils";
4 |
5 | export interface InputProps
6 | extends React.InputHTMLAttributes {}
7 |
8 | const Input = React.forwardRef(
9 | ({ className, type, ...props }, ref) => {
10 | return (
11 |
20 | );
21 | }
22 | );
23 | Input.displayName = "Input";
24 |
25 | export { Input };
26 |
--------------------------------------------------------------------------------
/cosmic/elements/Label.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as LabelPrimitive from "@radix-ui/react-label"
5 | import { cva, type VariantProps } from "class-variance-authority"
6 |
7 | import { cn } from "@/cosmic/utils"
8 |
9 | const labelVariants = cva(
10 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
11 | )
12 |
13 | const Label = React.forwardRef<
14 | React.ElementRef,
15 | React.ComponentPropsWithoutRef &
16 | VariantProps
17 | >(({ className, ...props }, ref) => (
18 |
23 | ))
24 | Label.displayName = LabelPrimitive.Root.displayName
25 |
26 | export { Label }
27 |
--------------------------------------------------------------------------------
/cosmic/elements/TextArea.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | import { cn } from "@/cosmic/utils";
4 |
5 | export interface TextareaProps
6 | extends React.TextareaHTMLAttributes {}
7 |
8 | const Textarea = React.forwardRef(
9 | ({ className, ...props }, ref) => {
10 | return (
11 |
19 | );
20 | }
21 | );
22 | Textarea.displayName = "Textarea";
23 |
24 | export { Textarea };
25 |
--------------------------------------------------------------------------------
/cosmic/utils.ts:
--------------------------------------------------------------------------------
1 | import { clsx, type ClassValue } from "clsx";
2 | import { twMerge } from "tailwind-merge";
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs));
6 | }
7 |
--------------------------------------------------------------------------------
/helpers/timeAgo.ts:
--------------------------------------------------------------------------------
1 | function getFormattedDate(
2 | date: Date,
3 | prefomattedDate?: "Today" | "Yesterday" | false,
4 | hideYear?: boolean
5 | ) {
6 | const date_number = date.getDate();
7 | const days = ["Sun", "Mon", "Tues", "Wed", "Thurs", "Fri", "Sat"];
8 | const day = days[date.getDay()];
9 | const MONTH_NAMES = [
10 | "Jan",
11 | "Feb",
12 | "Mar",
13 | "Apr",
14 | "May",
15 | "Jun",
16 | "Jul",
17 | "Aug",
18 | "Sep",
19 | "Oct",
20 | "Nov",
21 | "Dec",
22 | ];
23 | const month = MONTH_NAMES[date.getMonth()];
24 | const year = date.getFullYear();
25 | let hours = date.getHours();
26 | let am_pm = "am";
27 | if (hours > 11) {
28 | am_pm = "pm";
29 | if (hours > 12) hours = hours - 12;
30 | }
31 | if (hours === 0) {
32 | hours = 12;
33 | }
34 | let minutes: number | string = date.getMinutes();
35 | if (minutes < 10) {
36 | // Adding leading zero to minutes
37 | minutes = "0" + minutes;
38 | }
39 | if (prefomattedDate)
40 | return prefomattedDate + " at " + hours + ":" + minutes + am_pm;
41 | if (hideYear) {
42 | // 10. January at 10:20
43 | return (
44 | day +
45 | ", " +
46 | month +
47 | " " +
48 | date_number +
49 | " at " +
50 | hours +
51 | ":" +
52 | minutes +
53 | am_pm
54 | );
55 | }
56 | // 10. January 2017. at 10:20
57 | return (
58 | day +
59 | ", " +
60 | month +
61 | " " +
62 | date_number +
63 | ", " +
64 | year +
65 | " at " +
66 | hours +
67 | ":" +
68 | minutes +
69 | am_pm
70 | );
71 | }
72 |
73 | export function timeAgo(dateParam?: number | string | Date) {
74 | if (!dateParam) {
75 | return null;
76 | }
77 | const date = typeof dateParam === "object" ? dateParam : new Date(dateParam);
78 | const DAY_IN_MS = 86400000; // 24 * 60 * 60 * 1000
79 | const today = new Date();
80 | const yesterday = new Date(today.getTime() - DAY_IN_MS);
81 | const seconds = Math.round((today.getTime() - date.getTime()) / 1000);
82 | const minutes = Math.round(seconds / 60);
83 | const isToday = today.toDateString() === date.toDateString();
84 | const isYesterday = yesterday.toDateString() === date.toDateString();
85 | const isThisYear = today.getFullYear() === date.getFullYear();
86 | if (seconds < 5) {
87 | return "Just now";
88 | } else if (seconds < 60) {
89 | return seconds + " seconds ago";
90 | } else if (seconds < 90) {
91 | return "about a minute ago";
92 | } else if (minutes < 60) {
93 | return minutes + " minutes ago";
94 | } else if (isToday) {
95 | return getFormattedDate(date, "Today"); // Today at 10:20
96 | } else if (isYesterday) {
97 | return getFormattedDate(date, "Yesterday"); // Yesterday at 10:20
98 | } else if (isThisYear) {
99 | return getFormattedDate(date, false, true); // 10. January at 10:20
100 | }
101 | return getFormattedDate(date); // 10. January 2017. at 10:20
102 | }
103 |
--------------------------------------------------------------------------------
/next.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 |
3 | const nextConfig = {};
4 |
5 | export default nextConfig;
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "cosmic-app",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint",
10 | "type-check": "tsc --noEmit"
11 | },
12 | "dependencies": {
13 | "@cosmicjs/sdk": "1.2.0",
14 | "@radix-ui/react-label": "^2.1.0",
15 | "@radix-ui/react-slot": "^1.1.0",
16 | "bcryptjs": "^2.4.3",
17 | "class-variance-authority": "^0.7.0",
18 | "clsx": "^2.1.1",
19 | "lucide-react": "^0.456.0",
20 | "next": "15.3.1",
21 | "next-themes": "^0.3.0",
22 | "react": "beta",
23 | "react-dom": "beta",
24 | "react-markdown": "^9.0.1",
25 | "resend": "^4.0.1-alpha.0",
26 | "tailwind-merge": "^2.5.4"
27 | },
28 | "devDependencies": {
29 | "typescript": "^5",
30 | "@types/node": "^20",
31 | "@types/react": "^18",
32 | "@types/bcryptjs": "^2.4.6",
33 | "@types/react-dom": "^18",
34 | "postcss": "^8",
35 | "tailwindcss": "^3.4.1",
36 | "eslint": "^8",
37 | "eslint-config-next": "14.2.5"
38 | }
39 | }
--------------------------------------------------------------------------------
/postcss.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('postcss-load-config').Config} */
2 | const config = {
3 | plugins: {
4 | tailwindcss: {},
5 | },
6 | };
7 |
8 | export default config;
9 |
--------------------------------------------------------------------------------
/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from "tailwindcss";
2 |
3 | const config: Config = {
4 | darkMode: ["class"],
5 | content: [
6 | "./pages/**/*.{js,ts,jsx,tsx,mdx}",
7 | "./components/**/*.{js,ts,jsx,tsx,mdx}",
8 | "./app/**/*.{js,ts,jsx,tsx,mdx}",
9 | "./cosmic/**/*.{ts,tsx,js,jsx}",
10 | ],
11 | theme: {
12 | extend: {
13 | backgroundImage: {
14 | "gradient-radial": "radial-gradient(var(--tw-gradient-stops))",
15 | "gradient-conic":
16 | "conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))",
17 | },
18 | },
19 | },
20 | plugins: [],
21 | };
22 | export default config;
23 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "lib": [
4 | "dom",
5 | "dom.iterable",
6 | "esnext"
7 | ],
8 | "allowJs": true,
9 | "skipLibCheck": true,
10 | "strict": true,
11 | "noEmit": true,
12 | "esModuleInterop": true,
13 | "module": "esnext",
14 | "moduleResolution": "bundler",
15 | "resolveJsonModule": true,
16 | "isolatedModules": true,
17 | "jsx": "preserve",
18 | "incremental": true,
19 | "plugins": [
20 | {
21 | "name": "next"
22 | }
23 | ],
24 | "paths": {
25 | "@/*": [
26 | "./*"
27 | ]
28 | },
29 | "target": "ES2017"
30 | },
31 | "include": [
32 | "next-env.d.ts",
33 | "**/*.ts",
34 | "**/*.tsx",
35 | ".next/types/**/*.ts"
36 | ],
37 | "exclude": [
38 | "node_modules"
39 | ]
40 | }
41 |
--------------------------------------------------------------------------------