tr]:last:border-b-0",
48 | className
49 | )}
50 | {...props}
51 | />
52 | )
53 | }
54 |
55 | function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
56 | return (
57 |
65 | )
66 | }
67 |
68 | function TableHead({ className, ...props }: React.ComponentProps<"th">) {
69 | return (
70 | [role=checkbox]]:translate-y-[2px]",
74 | className
75 | )}
76 | {...props}
77 | />
78 | )
79 | }
80 |
81 | function TableCell({ className, ...props }: React.ComponentProps<"td">) {
82 | return (
83 | | [role=checkbox]]:translate-y-[2px]",
87 | className
88 | )}
89 | {...props}
90 | />
91 | )
92 | }
93 |
94 | function TableCaption({
95 | className,
96 | ...props
97 | }: React.ComponentProps<"caption">) {
98 | return (
99 |
104 | )
105 | }
106 |
107 | export {
108 | Table,
109 | TableHeader,
110 | TableBody,
111 | TableFooter,
112 | TableHead,
113 | TableRow,
114 | TableCell,
115 | TableCaption,
116 | }
117 |
--------------------------------------------------------------------------------
/src/db/schema.ts:
--------------------------------------------------------------------------------
1 | import { relations } from "drizzle-orm";
2 | import { integer, pgEnum, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core";
3 |
4 | import { createInsertSchema, createSelectSchema, createUpdateSchema } from "drizzle-zod";
5 |
6 | export const users = pgTable(
7 | "users",
8 | {
9 | id: uuid("id").primaryKey().defaultRandom(),
10 | clerkId: text("clerk_id").unique().notNull(),
11 | name: text("name").notNull(),
12 | // Todo: add banner fields
13 | imageUrl: text("image_url").notNull(),
14 | createdAt: timestamp("created_at").defaultNow().notNull(),
15 | updatedAt: timestamp("updated_at").defaultNow().notNull(),
16 | },
17 | (t) => [uniqueIndex("clerk_id_idx").on(t.clerkId)]
18 | );
19 |
20 | export const userRelations = relations(users, ({ many }) => ({
21 | videos: many(videos),
22 | }));
23 |
24 | export const categories = pgTable(
25 | "categories",
26 | {
27 | id: uuid("id").primaryKey().defaultRandom(),
28 | name: text("name").notNull().unique(),
29 | description: text("description"),
30 | createdAt: timestamp("created_at").defaultNow().notNull(),
31 | updatedAt: timestamp("updated_at").defaultNow().notNull(),
32 | },
33 | (t) => [uniqueIndex("name_idx").on(t.name)]
34 | );
35 |
36 | export const categoryRelations = relations(categories, ({ many }) => ({
37 | videos: many(videos),
38 | }));
39 |
40 | export const videoVisibility = pgEnum("video_visibility", ["public", "private"]);
41 |
42 | export const videos = pgTable("videos", {
43 | id: uuid("id").primaryKey().defaultRandom(),
44 | title: text("title").notNull(),
45 | description: text("description"),
46 | muxStatus: text("mux_status"),
47 | muxAssetId: text("mux_asset_id").unique(),
48 | muxUploadId: text("mux_upload_id").unique(),
49 | muxPlaybackId: text("mux_playback_id").unique(),
50 | muxTrackId: text("mux_track_id").unique(),
51 | muxTrackStatus: text("mux_track_status"),
52 | thumbnailUrl: text("thumbnail_url"),
53 | thumbnailKey: text("thumbnail_key"),
54 | previewUrl: text("preview_url"),
55 | previewKey: text("preview_key"),
56 | duration: integer("duration").default(0).notNull(),
57 | visibility: videoVisibility("visibility").notNull().default("private"),
58 | userId: uuid("user_id")
59 | .references(() => users.id, { onDelete: "cascade" })
60 | .notNull(),
61 | categoryId: uuid("category_id").references(() => categories.id, {
62 | onDelete: "set null",
63 | }),
64 | createdAt: timestamp("created_at").defaultNow().notNull(),
65 | updatedAt: timestamp("updated_at").defaultNow().notNull(),
66 | });
67 |
68 | export const videoInsertSchema = createInsertSchema(videos);
69 | export const videoUpdateSchema = createUpdateSchema(videos);
70 | export const videoSelectSchema = createSelectSchema(videos);
71 |
72 | export const videoRelations = relations(videos, ({ one }) => ({
73 | user: one(users, {
74 | fields: [videos.userId],
75 | references: [users.id],
76 | }),
77 | category: one(categories, {
78 | fields: [videos.categoryId],
79 | references: [categories.id],
80 | }),
81 | }));
82 |
--------------------------------------------------------------------------------
/src/components/filter-carousel.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { cn } from "@/lib/utils";
4 | import { useEffect, useState } from "react";
5 | import { Badge } from "./ui/badge";
6 | import { Carousel, CarouselApi, CarouselContent, CarouselItem, CarouselNext, CarouselPrevious } from "./ui/carousel";
7 | import { Skeleton } from "./ui/skeleton";
8 |
9 | interface FilterCarouselProps {
10 | value?: string | null;
11 | isLoading?: boolean;
12 | onSelect: (value: string | null) => void;
13 | data: {
14 | value: string;
15 | label: string;
16 | }[];
17 | }
18 |
19 | export const FilterCarousel = ({ value, isLoading, onSelect, data }: FilterCarouselProps) => {
20 | const [api, setApi] = useState();
21 | const [current, setCurrent] = useState(0);
22 | const [count, setCount] = useState(0);
23 |
24 | useEffect(() => {
25 | if (!api) return;
26 | setCount(api.scrollSnapList().length);
27 | setCurrent(api.selectedScrollSnap() + 1);
28 | api.on("select", () => {
29 | setCurrent(api.selectedScrollSnap() + 1);
30 | });
31 | }, [api]);
32 |
33 | return (
34 |
35 | {/* Left fade */}
36 |
42 |
43 |
51 |
52 | {!isLoading && (
53 | onSelect(null)}>
54 |
58 | All
59 |
60 |
61 | )}
62 | {isLoading &&
63 | Array.from({ length: 14 }).map((_, index) => (
64 |
65 |
66 |
67 | ))}
68 |
69 | {!isLoading &&
70 | data.map((item) => (
71 | onSelect(item.value)}>
72 | onSelect(item.value)}
76 | >
77 | {item.label}
78 |
79 |
80 | ))}
81 |
82 |
83 |
84 |
85 |
86 | {/* Right fade */}
87 |
93 |
94 | );
95 | };
96 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "youtube-clone",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev:all": "concurrently \"bun run dev\" \"bun run dev:webhook\"",
7 | "dev:webhook": "node scripts/start-ngrok.mjs",
8 | "dev": "next dev --turbopack",
9 | "drizzle:push": "drizzle-kit push",
10 | "drizzle:studio": "drizzle-kit studio",
11 | "build": "next build",
12 | "start": "next start",
13 | "lint": "next lint",
14 | "seed": "bun ./src/scripts/seed-categories.ts"
15 | },
16 | "dependencies": {
17 | "@clerk/nextjs": "^6.34.1",
18 | "@hookform/resolvers": "^5.2.2",
19 | "@mux/mux-node": "^12.8.0",
20 | "@mux/mux-player-react": "^3.8.0",
21 | "@mux/mux-uploader-react": "^1.3.0",
22 | "@neondatabase/serverless": "^1.0.2",
23 | "@radix-ui/react-accordion": "^1.2.12",
24 | "@radix-ui/react-alert-dialog": "^1.1.15",
25 | "@radix-ui/react-aspect-ratio": "^1.1.7",
26 | "@radix-ui/react-avatar": "^1.1.10",
27 | "@radix-ui/react-checkbox": "^1.3.3",
28 | "@radix-ui/react-collapsible": "^1.1.12",
29 | "@radix-ui/react-context-menu": "^2.2.16",
30 | "@radix-ui/react-dialog": "^1.1.15",
31 | "@radix-ui/react-dropdown-menu": "^2.1.16",
32 | "@radix-ui/react-hover-card": "^1.1.15",
33 | "@radix-ui/react-label": "^2.1.7",
34 | "@radix-ui/react-menubar": "^1.1.16",
35 | "@radix-ui/react-navigation-menu": "^1.2.14",
36 | "@radix-ui/react-popover": "^1.1.15",
37 | "@radix-ui/react-progress": "^1.1.7",
38 | "@radix-ui/react-radio-group": "^1.3.8",
39 | "@radix-ui/react-scroll-area": "^1.2.10",
40 | "@radix-ui/react-select": "^2.2.6",
41 | "@radix-ui/react-separator": "^1.1.7",
42 | "@radix-ui/react-slider": "^1.3.6",
43 | "@radix-ui/react-slot": "^1.2.3",
44 | "@radix-ui/react-switch": "^1.2.6",
45 | "@radix-ui/react-tabs": "^1.1.13",
46 | "@radix-ui/react-toast": "^1.2.15",
47 | "@radix-ui/react-toggle": "^1.1.10",
48 | "@radix-ui/react-toggle-group": "^1.1.11",
49 | "@radix-ui/react-tooltip": "^1.2.8",
50 | "@tanstack/react-query": "^5.90.6",
51 | "@trpc/client": "^11.7.1",
52 | "@trpc/react-query": "^11.7.1",
53 | "@trpc/server": "^11.7.1",
54 | "@trpc/tanstack-react-query": "^11.7.1",
55 | "@uploadthing/react": "^7.3.3",
56 | "@upstash/ratelimit": "^2.0.7",
57 | "@upstash/redis": "^1.35.6",
58 | "@vercel/speed-insights": "^1.2.0",
59 | "class-variance-authority": "^0.7.1",
60 | "client-only": "^0.0.1",
61 | "clsx": "^2.1.1",
62 | "cmdk": "^1.1.1",
63 | "date-fns": "^4.1.0",
64 | "dotenv": "^17.2.3",
65 | "drizzle-orm": "^0.44.7",
66 | "drizzle-zod": "^0.8.3",
67 | "embla-carousel-react": "^8.6.0",
68 | "input-otp": "^1.4.2",
69 | "lucide-react": "^0.525.0",
70 | "next": "^15.5.6",
71 | "next-themes": "^0.4.6",
72 | "react": "^19.2.0",
73 | "react-day-picker": "^9.11.1",
74 | "react-dom": "^19.2.0",
75 | "react-error-boundary": "^6.0.0",
76 | "react-hook-form": "^7.66.0",
77 | "react-resizable-panels": "^3.0.6",
78 | "recharts": "^3.3.0",
79 | "server-only": "^0.0.1",
80 | "sonner": "^2.0.7",
81 | "superjson": "^2.2.5",
82 | "svix": "^1.81.0",
83 | "tailwind-merge": "^3.3.1",
84 | "tailwindcss-animate": "^1.0.7",
85 | "uploadthing": "^7.7.4",
86 | "vaul": "^1.1.2",
87 | "zod": "^4.1.12"
88 | },
89 | "devDependencies": {
90 | "@eslint/eslintrc": "^3.3.1",
91 | "@tailwindcss/postcss": "^4.1.16",
92 | "@types/node": "^24.10.0",
93 | "@types/react": "^19.2.2",
94 | "@types/react-dom": "^19.2.2",
95 | "concurrently": "^9.2.1",
96 | "drizzle-kit": "^0.31.6",
97 | "eslint": "^9.39.1",
98 | "eslint-config-next": "15.4.1",
99 | "postcss": "^8.5.6",
100 | "tailwindcss": "^4.1.16",
101 | "tsx": "^4.20.6",
102 | "typescript": "^5.9.3"
103 | }
104 | }
105 |
--------------------------------------------------------------------------------
/src/components/ui/dialog.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as DialogPrimitive from "@radix-ui/react-dialog"
5 | import { X } from "lucide-react"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const Dialog = DialogPrimitive.Root
10 |
11 | const DialogTrigger = DialogPrimitive.Trigger
12 |
13 | const DialogPortal = DialogPrimitive.Portal
14 |
15 | const DialogClose = DialogPrimitive.Close
16 |
17 | const DialogOverlay = React.forwardRef<
18 | React.ElementRef,
19 | React.ComponentPropsWithoutRef
20 | >(({ className, ...props }, ref) => (
21 |
29 | ))
30 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
31 |
32 | const DialogContent = React.forwardRef<
33 | React.ElementRef,
34 | React.ComponentPropsWithoutRef
35 | >(({ className, children, ...props }, ref) => (
36 |
37 |
38 |
46 | {children}
47 |
48 |
49 | Close
50 |
51 |
52 |
53 | ))
54 | DialogContent.displayName = DialogPrimitive.Content.displayName
55 |
56 | const DialogHeader = ({
57 | className,
58 | ...props
59 | }: React.HTMLAttributes) => (
60 |
67 | )
68 | DialogHeader.displayName = "DialogHeader"
69 |
70 | const DialogFooter = ({
71 | className,
72 | ...props
73 | }: React.HTMLAttributes) => (
74 |
81 | )
82 | DialogFooter.displayName = "DialogFooter"
83 |
84 | const DialogTitle = React.forwardRef<
85 | React.ElementRef,
86 | React.ComponentPropsWithoutRef
87 | >(({ className, ...props }, ref) => (
88 |
96 | ))
97 | DialogTitle.displayName = DialogPrimitive.Title.displayName
98 |
99 | const DialogDescription = React.forwardRef<
100 | React.ElementRef,
101 | React.ComponentPropsWithoutRef
102 | >(({ className, ...props }, ref) => (
103 |
108 | ))
109 | DialogDescription.displayName = DialogPrimitive.Description.displayName
110 |
111 | export {
112 | Dialog,
113 | DialogPortal,
114 | DialogOverlay,
115 | DialogTrigger,
116 | DialogClose,
117 | DialogContent,
118 | DialogHeader,
119 | DialogFooter,
120 | DialogTitle,
121 | DialogDescription,
122 | }
123 |
--------------------------------------------------------------------------------
/src/components/ui/form.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as LabelPrimitive from "@radix-ui/react-label"
5 | import { Slot } from "@radix-ui/react-slot"
6 | import {
7 | Controller,
8 | FormProvider,
9 | useFormContext,
10 | useFormState,
11 | type ControllerProps,
12 | type FieldPath,
13 | type FieldValues,
14 | } from "react-hook-form"
15 |
16 | import { cn } from "@/lib/utils"
17 | import { Label } from "@/components/ui/label"
18 |
19 | const Form = FormProvider
20 |
21 | type FormFieldContextValue<
22 | TFieldValues extends FieldValues = FieldValues,
23 | TName extends FieldPath = FieldPath,
24 | > = {
25 | name: TName
26 | }
27 |
28 | const FormFieldContext = React.createContext(
29 | {} as FormFieldContextValue
30 | )
31 |
32 | const FormField = <
33 | TFieldValues extends FieldValues = FieldValues,
34 | TName extends FieldPath = FieldPath,
35 | >({
36 | ...props
37 | }: ControllerProps) => {
38 | return (
39 |
40 |
41 |
42 | )
43 | }
44 |
45 | const useFormField = () => {
46 | const fieldContext = React.useContext(FormFieldContext)
47 | const itemContext = React.useContext(FormItemContext)
48 | const { getFieldState } = useFormContext()
49 | const formState = useFormState({ name: fieldContext.name })
50 | const fieldState = getFieldState(fieldContext.name, formState)
51 |
52 | if (!fieldContext) {
53 | throw new Error("useFormField should be used within ")
54 | }
55 |
56 | const { id } = itemContext
57 |
58 | return {
59 | id,
60 | name: fieldContext.name,
61 | formItemId: `${id}-form-item`,
62 | formDescriptionId: `${id}-form-item-description`,
63 | formMessageId: `${id}-form-item-message`,
64 | ...fieldState,
65 | }
66 | }
67 |
68 | type FormItemContextValue = {
69 | id: string
70 | }
71 |
72 | const FormItemContext = React.createContext(
73 | {} as FormItemContextValue
74 | )
75 |
76 | function FormItem({ className, ...props }: React.ComponentProps<"div">) {
77 | const id = React.useId()
78 |
79 | return (
80 |
81 |
86 |
87 | )
88 | }
89 |
90 | function FormLabel({
91 | className,
92 | ...props
93 | }: React.ComponentProps) {
94 | const { error, formItemId } = useFormField()
95 |
96 | return (
97 |
104 | )
105 | }
106 |
107 | function FormControl({ ...props }: React.ComponentProps) {
108 | const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
109 |
110 | return (
111 |
122 | )
123 | }
124 |
125 | function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
126 | const { formDescriptionId } = useFormField()
127 |
128 | return (
129 |
135 | )
136 | }
137 |
138 | function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
139 | const { error, formMessageId } = useFormField()
140 | const body = error ? String(error?.message ?? "") : props.children
141 |
142 | if (!body) {
143 | return null
144 | }
145 |
146 | return (
147 |
153 | {body}
154 |
155 | )
156 | }
157 |
158 | export {
159 | useFormField,
160 | Form,
161 | FormItem,
162 | FormLabel,
163 | FormControl,
164 | FormDescription,
165 | FormMessage,
166 | FormField,
167 | }
168 |
--------------------------------------------------------------------------------
/src/components/ui/drawer.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import { Drawer as DrawerPrimitive } from "vaul"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | function Drawer({
9 | ...props
10 | }: React.ComponentProps) {
11 | return
12 | }
13 |
14 | function DrawerTrigger({
15 | ...props
16 | }: React.ComponentProps) {
17 | return
18 | }
19 |
20 | function DrawerPortal({
21 | ...props
22 | }: React.ComponentProps) {
23 | return
24 | }
25 |
26 | function DrawerClose({
27 | ...props
28 | }: React.ComponentProps) {
29 | return
30 | }
31 |
32 | function DrawerOverlay({
33 | className,
34 | ...props
35 | }: React.ComponentProps) {
36 | return (
37 |
45 | )
46 | }
47 |
48 | function DrawerContent({
49 | className,
50 | children,
51 | ...props
52 | }: React.ComponentProps) {
53 | return (
54 |
55 |
56 |
68 |
69 | {children}
70 |
71 |
72 | )
73 | }
74 |
75 | function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
76 | return (
77 |
82 | )
83 | }
84 |
85 | function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
86 | return (
87 |
92 | )
93 | }
94 |
95 | function DrawerTitle({
96 | className,
97 | ...props
98 | }: React.ComponentProps) {
99 | return (
100 |
105 | )
106 | }
107 |
108 | function DrawerDescription({
109 | className,
110 | ...props
111 | }: React.ComponentProps) {
112 | return (
113 |
118 | )
119 | }
120 |
121 | export {
122 | Drawer,
123 | DrawerPortal,
124 | DrawerOverlay,
125 | DrawerTrigger,
126 | DrawerClose,
127 | DrawerContent,
128 | DrawerHeader,
129 | DrawerFooter,
130 | DrawerTitle,
131 | DrawerDescription,
132 | }
133 |
--------------------------------------------------------------------------------
/src/components/ui/sheet.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as SheetPrimitive from "@radix-ui/react-dialog"
5 | import { cva, type VariantProps } from "class-variance-authority"
6 | import { X } from "lucide-react"
7 |
8 | import { cn } from "@/lib/utils"
9 |
10 | const Sheet = SheetPrimitive.Root
11 |
12 | const SheetTrigger = SheetPrimitive.Trigger
13 |
14 | const SheetClose = SheetPrimitive.Close
15 |
16 | const SheetPortal = SheetPrimitive.Portal
17 |
18 | const SheetOverlay = React.forwardRef<
19 | React.ElementRef,
20 | React.ComponentPropsWithoutRef
21 | >(({ className, ...props }, ref) => (
22 |
30 | ))
31 | SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
32 |
33 | const sheetVariants = cva(
34 | "fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out",
35 | {
36 | variants: {
37 | side: {
38 | top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
39 | bottom:
40 | "inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
41 | left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
42 | right:
43 | "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
44 | },
45 | },
46 | defaultVariants: {
47 | side: "right",
48 | },
49 | }
50 | )
51 |
52 | interface SheetContentProps
53 | extends React.ComponentPropsWithoutRef,
54 | VariantProps {}
55 |
56 | const SheetContent = React.forwardRef<
57 | React.ElementRef,
58 | SheetContentProps
59 | >(({ side = "right", className, children, ...props }, ref) => (
60 |
61 |
62 |
67 |
68 |
69 | Close
70 |
71 | {children}
72 |
73 |
74 | ))
75 | SheetContent.displayName = SheetPrimitive.Content.displayName
76 |
77 | const SheetHeader = ({
78 | className,
79 | ...props
80 | }: React.HTMLAttributes) => (
81 |
88 | )
89 | SheetHeader.displayName = "SheetHeader"
90 |
91 | const SheetFooter = ({
92 | className,
93 | ...props
94 | }: React.HTMLAttributes) => (
95 |
102 | )
103 | SheetFooter.displayName = "SheetFooter"
104 |
105 | const SheetTitle = React.forwardRef<
106 | React.ElementRef,
107 | React.ComponentPropsWithoutRef
108 | >(({ className, ...props }, ref) => (
109 |
114 | ))
115 | SheetTitle.displayName = SheetPrimitive.Title.displayName
116 |
117 | const SheetDescription = React.forwardRef<
118 | React.ElementRef,
119 | React.ComponentPropsWithoutRef
120 | >(({ className, ...props }, ref) => (
121 |
126 | ))
127 | SheetDescription.displayName = SheetPrimitive.Description.displayName
128 |
129 | export {
130 | Sheet,
131 | SheetPortal,
132 | SheetOverlay,
133 | SheetTrigger,
134 | SheetClose,
135 | SheetContent,
136 | SheetHeader,
137 | SheetFooter,
138 | SheetTitle,
139 | SheetDescription,
140 | }
141 |
--------------------------------------------------------------------------------
/src/modules/videos/server/procedures.ts:
--------------------------------------------------------------------------------
1 | import { db } from "@/db";
2 | import { videos, videoUpdateSchema } from "@/db/schema";
3 | import { mux } from "@/lib/mux";
4 | import { createTRPCRouter, protectedProcedure } from "@/trpc/init";
5 | import { TRPCError } from "@trpc/server";
6 | import { and, eq } from "drizzle-orm";
7 | import { UTApi } from "uploadthing/server";
8 | import z from "zod";
9 |
10 | const ensureMuxCredentials = () => {
11 | if (!process.env.MUX_TOKEN_ID || !process.env.MUX_TOKEN_SECRET) {
12 | throw new TRPCError({
13 | code: "INTERNAL_SERVER_ERROR",
14 | message: "Missing Mux credentials. Set MUX_TOKEN_ID and MUX_TOKEN_SECRET.",
15 | });
16 | }
17 | };
18 |
19 | export const videosRouter = createTRPCRouter({
20 | restoreThumbnail: protectedProcedure
21 | .input(z.object({ id: z.uuid() }))
22 | .mutation(async ({ ctx, input }) => {
23 | const { id: userId } = ctx.user;
24 |
25 | const [existingVideo] = await db
26 | .select()
27 | .from(videos)
28 | .where(and(eq(videos.id, input.id), eq(videos.userId, userId)));
29 |
30 | if (!existingVideo) {
31 | throw new TRPCError({ code: "NOT_FOUND" });
32 | }
33 |
34 | if (!existingVideo.muxPlaybackId) {
35 | throw new TRPCError({ code: "BAD_REQUEST" });
36 | }
37 |
38 | const tempthumbnailUrl = `https://image.mux.com/${existingVideo.muxPlaybackId}/thumbnail.png`;
39 |
40 | const utapi = new UTApi();
41 | const uploadedThumbnail = await utapi.uploadFilesFromUrl(tempthumbnailUrl);
42 |
43 | if (!uploadedThumbnail.data) {
44 | throw new TRPCError({ code: "BAD_REQUEST" });
45 | }
46 |
47 | const { key: thumbnailKey, ufsUrl: thumbnailUrl } = uploadedThumbnail.data;
48 |
49 | const [updatedVideo] = await db
50 | .update(videos)
51 | .set({ thumbnailUrl, thumbnailKey })
52 | .where(and(eq(videos.id, input.id), eq(videos.userId, userId)))
53 | .returning();
54 |
55 | return updatedVideo;
56 | }),
57 |
58 | remove: protectedProcedure.input(z.object({ id: z.uuid() })).mutation(async ({ ctx, input }) => {
59 | const { id: userId } = ctx.user;
60 |
61 | const [removeVideo] = await db
62 | .delete(videos)
63 | .where(and(eq(videos.id, input.id), eq(videos.userId, userId)))
64 | .returning();
65 |
66 | if (!removeVideo) {
67 | throw new TRPCError({ code: "NOT_FOUND" });
68 | }
69 |
70 | return removeVideo;
71 | }),
72 | update: protectedProcedure.input(videoUpdateSchema).mutation(async ({ ctx, input }) => {
73 | const { id: userId } = ctx.user;
74 |
75 | if (!input.id) {
76 | throw new TRPCError({ code: "BAD_REQUEST" });
77 | }
78 |
79 | const [updatedVideo] = await db
80 | .update(videos)
81 | .set({
82 | title: input.title,
83 | description: input.description,
84 | categoryId: input.categoryId,
85 | visibility: input.visibility,
86 | updatedAt: new Date(),
87 | })
88 | .where(and(eq(videos.id, input.id), eq(videos.userId, userId)))
89 | .returning();
90 |
91 | if (!updatedVideo) {
92 | throw new TRPCError({ code: "NOT_FOUND" });
93 | }
94 |
95 | return updatedVideo;
96 | }),
97 | create: protectedProcedure.mutation(async ({ ctx }) => {
98 | const { id: userId } = ctx.user;
99 |
100 | try {
101 | ensureMuxCredentials();
102 |
103 | const upload = await mux.video.uploads.create({
104 | new_asset_settings: {
105 | passthrough: userId,
106 | playback_policies: ["public"],
107 | static_renditions: [
108 | {
109 | resolution: "highest",
110 | },
111 | {
112 | resolution: "audio-only",
113 | },
114 | ],
115 | input: [
116 | {
117 | generated_subtitles: [
118 | {
119 | language_code: "en",
120 | name: "English",
121 | },
122 | ],
123 | },
124 | ],
125 | },
126 | cors_origin: "*", // TODO: In production this should be restricted to the domain of the app
127 | });
128 |
129 | const [video] = await db
130 | .insert(videos)
131 | .values({
132 | userId,
133 | title: "Untitled",
134 | muxStatus: "waiting",
135 | muxUploadId: upload.id,
136 | })
137 | .returning();
138 |
139 | return {
140 | video,
141 | url: upload.url,
142 | };
143 | } catch (error) {
144 | console.error("Failed to create Mux upload", error);
145 | throw new TRPCError({
146 | code: "INTERNAL_SERVER_ERROR",
147 | message: "Unable to create upload. Check your Mux credentials and quota.",
148 | });
149 | }
150 | }),
151 | });
152 |
--------------------------------------------------------------------------------
/src/components/ui/alert-dialog.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
5 |
6 | import { cn } from "@/lib/utils"
7 | import { buttonVariants } from "@/components/ui/button"
8 |
9 | const AlertDialog = AlertDialogPrimitive.Root
10 |
11 | const AlertDialogTrigger = AlertDialogPrimitive.Trigger
12 |
13 | const AlertDialogPortal = AlertDialogPrimitive.Portal
14 |
15 | const AlertDialogOverlay = React.forwardRef<
16 | React.ElementRef,
17 | React.ComponentPropsWithoutRef
18 | >(({ className, ...props }, ref) => (
19 |
27 | ))
28 | AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
29 |
30 | const AlertDialogContent = React.forwardRef<
31 | React.ElementRef,
32 | React.ComponentPropsWithoutRef
33 | >(({ className, ...props }, ref) => (
34 |
35 |
36 |
44 |
45 | ))
46 | AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
47 |
48 | const AlertDialogHeader = ({
49 | className,
50 | ...props
51 | }: React.HTMLAttributes) => (
52 |
59 | )
60 | AlertDialogHeader.displayName = "AlertDialogHeader"
61 |
62 | const AlertDialogFooter = ({
63 | className,
64 | ...props
65 | }: React.HTMLAttributes) => (
66 |
73 | )
74 | AlertDialogFooter.displayName = "AlertDialogFooter"
75 |
76 | const AlertDialogTitle = React.forwardRef<
77 | React.ElementRef,
78 | React.ComponentPropsWithoutRef
79 | >(({ className, ...props }, ref) => (
80 |
85 | ))
86 | AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
87 |
88 | const AlertDialogDescription = React.forwardRef<
89 | React.ElementRef,
90 | React.ComponentPropsWithoutRef
91 | >(({ className, ...props }, ref) => (
92 |
97 | ))
98 | AlertDialogDescription.displayName =
99 | AlertDialogPrimitive.Description.displayName
100 |
101 | const AlertDialogAction = React.forwardRef<
102 | React.ElementRef,
103 | React.ComponentPropsWithoutRef
104 | >(({ className, ...props }, ref) => (
105 |
110 | ))
111 | AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
112 |
113 | const AlertDialogCancel = React.forwardRef<
114 | React.ElementRef,
115 | React.ComponentPropsWithoutRef
116 | >(({ className, ...props }, ref) => (
117 |
126 | ))
127 | AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
128 |
129 | export {
130 | AlertDialog,
131 | AlertDialogPortal,
132 | AlertDialogOverlay,
133 | AlertDialogTrigger,
134 | AlertDialogContent,
135 | AlertDialogHeader,
136 | AlertDialogFooter,
137 | AlertDialogTitle,
138 | AlertDialogDescription,
139 | AlertDialogAction,
140 | AlertDialogCancel,
141 | }
142 |
--------------------------------------------------------------------------------
/src/components/ui/command.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import { type DialogProps } from "@radix-ui/react-dialog"
5 | import { Command as CommandPrimitive } from "cmdk"
6 | import { Search } from "lucide-react"
7 |
8 | import { cn } from "@/lib/utils"
9 | import { Dialog, DialogContent } from "@/components/ui/dialog"
10 |
11 | const Command = React.forwardRef<
12 | React.ElementRef,
13 | React.ComponentPropsWithoutRef
14 | >(({ className, ...props }, ref) => (
15 |
23 | ))
24 | Command.displayName = CommandPrimitive.displayName
25 |
26 | const CommandDialog = ({ children, ...props }: DialogProps) => {
27 | return (
28 |
35 | )
36 | }
37 |
38 | const CommandInput = React.forwardRef<
39 | React.ElementRef,
40 | React.ComponentPropsWithoutRef
41 | >(({ className, ...props }, ref) => (
42 |
43 |
44 |
52 |
53 | ))
54 |
55 | CommandInput.displayName = CommandPrimitive.Input.displayName
56 |
57 | const CommandList = React.forwardRef<
58 | React.ElementRef,
59 | React.ComponentPropsWithoutRef
60 | >(({ className, ...props }, ref) => (
61 |
66 | ))
67 |
68 | CommandList.displayName = CommandPrimitive.List.displayName
69 |
70 | const CommandEmpty = React.forwardRef<
71 | React.ElementRef,
72 | React.ComponentPropsWithoutRef
73 | >((props, ref) => (
74 |
79 | ))
80 |
81 | CommandEmpty.displayName = CommandPrimitive.Empty.displayName
82 |
83 | const CommandGroup = React.forwardRef<
84 | React.ElementRef,
85 | React.ComponentPropsWithoutRef
86 | >(({ className, ...props }, ref) => (
87 |
95 | ))
96 |
97 | CommandGroup.displayName = CommandPrimitive.Group.displayName
98 |
99 | const CommandSeparator = React.forwardRef<
100 | React.ElementRef,
101 | React.ComponentPropsWithoutRef
102 | >(({ className, ...props }, ref) => (
103 |
108 | ))
109 | CommandSeparator.displayName = CommandPrimitive.Separator.displayName
110 |
111 | const CommandItem = React.forwardRef<
112 | React.ElementRef,
113 | React.ComponentPropsWithoutRef
114 | >(({ className, ...props }, ref) => (
115 |
123 | ))
124 |
125 | CommandItem.displayName = CommandPrimitive.Item.displayName
126 |
127 | const CommandShortcut = ({
128 | className,
129 | ...props
130 | }: React.HTMLAttributes) => {
131 | return (
132 |
139 | )
140 | }
141 | CommandShortcut.displayName = "CommandShortcut"
142 |
143 | export {
144 | Command,
145 | CommandDialog,
146 | CommandInput,
147 | CommandList,
148 | CommandEmpty,
149 | CommandGroup,
150 | CommandItem,
151 | CommandShortcut,
152 | CommandSeparator,
153 | }
154 |
--------------------------------------------------------------------------------
/src/components/ui/navigation-menu.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu"
3 | import { cva } from "class-variance-authority"
4 | import { ChevronDown } from "lucide-react"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const NavigationMenu = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, children, ...props }, ref) => (
12 |
20 | {children}
21 |
22 |
23 | ))
24 | NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName
25 |
26 | const NavigationMenuList = React.forwardRef<
27 | React.ElementRef,
28 | React.ComponentPropsWithoutRef
29 | >(({ className, ...props }, ref) => (
30 |
38 | ))
39 | NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName
40 |
41 | const NavigationMenuItem = NavigationMenuPrimitive.Item
42 |
43 | const navigationMenuTriggerStyle = cva(
44 | "group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-hidden disabled:pointer-events-none disabled:opacity-50 data-active:bg-accent/50 data-[state=open]:bg-accent/50"
45 | )
46 |
47 | const NavigationMenuTrigger = React.forwardRef<
48 | React.ElementRef,
49 | React.ComponentPropsWithoutRef
50 | >(({ className, children, ...props }, ref) => (
51 |
56 | {children}{" "}
57 |
61 |
62 | ))
63 | NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName
64 |
65 | const NavigationMenuContent = React.forwardRef<
66 | React.ElementRef,
67 | React.ComponentPropsWithoutRef
68 | >(({ className, ...props }, ref) => (
69 |
77 | ))
78 | NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName
79 |
80 | const NavigationMenuLink = NavigationMenuPrimitive.Link
81 |
82 | const NavigationMenuViewport = React.forwardRef<
83 | React.ElementRef,
84 | React.ComponentPropsWithoutRef
85 | >(({ className, ...props }, ref) => (
86 |
87 |
95 |
96 | ))
97 | NavigationMenuViewport.displayName =
98 | NavigationMenuPrimitive.Viewport.displayName
99 |
100 | const NavigationMenuIndicator = React.forwardRef<
101 | React.ElementRef,
102 | React.ComponentPropsWithoutRef
103 | >(({ className, ...props }, ref) => (
104 |
112 |
113 |
114 | ))
115 | NavigationMenuIndicator.displayName =
116 | NavigationMenuPrimitive.Indicator.displayName
117 |
118 | export {
119 | navigationMenuTriggerStyle,
120 | NavigationMenu,
121 | NavigationMenuList,
122 | NavigationMenuItem,
123 | NavigationMenuContent,
124 | NavigationMenuTrigger,
125 | NavigationMenuLink,
126 | NavigationMenuIndicator,
127 | NavigationMenuViewport,
128 | }
129 |
--------------------------------------------------------------------------------
/docs/WEBHOOK_TROUBLESHOOTING.md:
--------------------------------------------------------------------------------
1 | # Webhook Troubleshooting Guide
2 |
3 | ## Problemas Resueltos
4 |
5 | ### 1. Error 500 en `/api/videos/webhook`
6 |
7 | #### Problemas Identificados y Corregidos:
8 |
9 | 1. **❌ Falta de `break` en el switch statement**
10 |
11 | - El caso `video.asset.created` no tenía `break`, causando que el código continuara ejecutándose en los siguientes casos
12 | - **Solución**: Agregado `break` al final de cada caso
13 |
14 | 2. **❌ Falta de configuración de Route Segment para Next.js 15**
15 |
16 | - Next.js 15 requiere configuraciones explícitas para rutas dinámicas
17 | - **Solución**: Agregadas las siguientes exportaciones:
18 | ```typescript
19 | export const dynamic = "force-dynamic";
20 | export const runtime = "nodejs";
21 | ```
22 |
23 | 3. **❌ Middleware de Clerk interfiriendo con webhooks**
24 |
25 | - El middleware intentaba autenticar las peticiones de webhook
26 | - **Solución**: Agregada excepción para rutas de webhook en `middleware.ts`
27 |
28 | 4. **❌ Falta de manejo de errores**
29 | - No había captura de errores global, dificultando el debugging
30 | - **Solución**: Agregado try-catch global con logging detallado
31 |
32 | ## Verificación de Variables de Entorno
33 |
34 | Asegúrate de tener las siguientes variables configuradas en tu archivo `.env`:
35 |
36 | ```env
37 | # Mux
38 | MUX_TOKEN_ID=your_token_id
39 | MUX_TOKEN_SECRET=your_token_secret
40 | MUX_WEBHOOK_SECRET=your_webhook_secret
41 |
42 | # UploadThing
43 | UPLOADTHING_TOKEN=your_uploadthing_token
44 |
45 | # Database
46 | DATABASE_URL=your_database_url
47 |
48 | # Clerk
49 | NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=your_clerk_key
50 | CLERK_SECRET_KEY=your_clerk_secret
51 | ```
52 |
53 | ## Cómo Probar el Webhook Localmente
54 |
55 | ### 1. Usar ngrok para exponer tu servidor local
56 |
57 | ```bash
58 | # Iniciar tu servidor de desarrollo
59 | npm run dev
60 |
61 | # En otra terminal, exponer el puerto con ngrok
62 | ngrok http 3000
63 | ```
64 |
65 | ### 2. Configurar el webhook en Mux
66 |
67 | 1. Ve a [Mux Dashboard](https://dashboard.mux.com/)
68 | 2. Navega a Settings > Webhooks
69 | 3. Agrega tu URL de ngrok: `https://your-ngrok-url.ngrok.io/api/videos/webhook`
70 | 4. Selecciona los eventos:
71 | - `video.asset.created`
72 | - `video.asset.ready`
73 | - `video.asset.errored`
74 | - `video.asset.deleted`
75 | - `video.asset.track.ready`
76 |
77 | ### 3. Verificar los logs
78 |
79 | El webhook ahora incluye logging detallado:
80 |
81 | ```typescript
82 | console.log("Webhook received:", payload.type);
83 | console.log("Video asset created:", data.id);
84 | console.log("Video asset ready:", data.id);
85 | console.error("Video asset errored:", data.id, data.errors);
86 | ```
87 |
88 | Observa la consola de tu servidor para ver estos mensajes.
89 |
90 | ## Debugging Común
91 |
92 | ### Error: "Mux signature is not set"
93 |
94 | **Causa**: Mux no está enviando el header de firma o no está llegando correctamente.
95 |
96 | **Solución**:
97 |
98 | - Verifica que la URL del webhook esté correctamente configurada en Mux
99 | - Asegúrate de que no haya proxies intermedios que eliminen headers
100 |
101 | ### Error: "Invalid signature"
102 |
103 | **Causa**: La firma del webhook no coincide.
104 |
105 | **Solución**:
106 |
107 | - Verifica que `MUX_WEBHOOK_SECRET` en tu `.env` coincida con el secreto en Mux Dashboard
108 | - Asegúrate de que el body de la petición no esté siendo modificado antes de la verificación
109 |
110 | ### Error: "Upload ID is not set"
111 |
112 | **Causa**: El video no se está creando correctamente en la base de datos.
113 |
114 | **Solución**:
115 |
116 | - Verifica que el procedimiento `create` en `videos/server/procedures.ts` esté funcionando
117 | - Asegúrate de que el `muxUploadId` se esté guardando correctamente
118 |
119 | ### Error: "Failed to upload thumbnails"
120 |
121 | **Causa**: UploadThing no puede subir las imágenes desde Mux.
122 |
123 | **Solución**:
124 |
125 | - Verifica que `UPLOADTHING_TOKEN` esté configurado
126 | - Asegúrate de que la URL de Mux sea accesible: `https://image.mux.com/${playbackId}/thumbnail.png`
127 | - Revisa los logs de UploadThing en su dashboard
128 |
129 | ## Flujo Completo de Subida de Video
130 |
131 | 1. **Usuario sube video**: Se llama al procedimiento `create` en tRPC
132 | 2. **Mux crea upload**: Se obtiene una URL de subida de Mux
133 | 3. **Video se guarda en DB**: Con `muxUploadId` y status `"waiting"`
134 | 4. **Webhook `video.asset.created`**: Actualiza `muxAssetId` y `muxStatus`
135 | 5. **Webhook `video.asset.ready`**:
136 | - Obtiene `playbackId`
137 | - Descarga thumbnail y preview de Mux
138 | - Sube a UploadThing
139 | - Actualiza DB con todas las URLs y duración
140 | 6. **Video listo**: El usuario puede visualizarlo
141 |
142 | ## Comandos Útiles
143 |
144 | ```bash
145 | # Desarrollo con ngrok
146 | npm run dev:all
147 |
148 | # Solo desarrollo
149 | npm run dev
150 |
151 | # Solo webhook/ngrok
152 | npm run dev:webhook
153 |
154 | # Ver base de datos
155 | npm run drizzle:studio
156 |
157 | # Ver logs en tiempo real
158 | # En terminal del servidor de desarrollo
159 | ```
160 |
161 | ## Checklist de Verificación
162 |
163 | - [ ] Variables de entorno configuradas
164 | - [ ] Webhook configurado en Mux Dashboard
165 | - [ ] ngrok funcionando (para desarrollo local)
166 | - [ ] Middleware permite acceso público al webhook
167 | - [ ] Base de datos tiene la tabla `videos` con todas las columnas necesarias
168 | - [ ] UploadThing configurado correctamente
169 |
170 | ## Recursos Adicionales
171 |
172 | - [Mux Webhooks Documentation](https://docs.mux.com/guides/listen-for-webhooks)
173 | - [Next.js 15 Route Handlers](https://nextjs.org/docs/app/building-your-application/routing/route-handlers)
174 | - [UploadThing Documentation](https://docs.uploadthing.com/)
175 |
176 | ## Contacto
177 |
178 | Si sigues teniendo problemas después de seguir esta guía, revisa:
179 |
180 | 1. Los logs de la consola del servidor
181 | 2. Los logs en Mux Dashboard > Webhooks
182 | 3. Los logs en UploadThing Dashboard
183 |
--------------------------------------------------------------------------------
/src/app/globals.css:
--------------------------------------------------------------------------------
1 | @import "tailwindcss";
2 | @plugin 'tailwindcss-animate';
3 |
4 | @import "uploadthing/tw/v4";
5 | @source "../../node_modules/@uploadthing/react/dist"
6 |
7 | @custom-variant dark (&:is(.dark *));
8 |
9 | *,
10 | ::after,
11 | ::before,
12 | ::backdrop,
13 | html,
14 | body {
15 | scrollbar-width: none;
16 | -ms-overflow-style: none;
17 | }
18 |
19 | @theme {
20 | --color-background: hsl(var(--background));
21 | --color-foreground: hsl(var(--foreground));
22 |
23 | --color-card: hsl(var(--card));
24 | --color-card-foreground: hsl(var(--card-foreground));
25 |
26 | --color-popover: hsl(var(--popover));
27 | --color-popover-foreground: hsl(var(--popover-foreground));
28 |
29 | --color-primary: hsl(var(--primary));
30 | --color-primary-foreground: hsl(var(--primary-foreground));
31 |
32 | --color-secondary: hsl(var(--secondary));
33 | --color-secondary-foreground: hsl(var(--secondary-foreground));
34 |
35 | --color-muted: hsl(var(--muted));
36 | --color-muted-foreground: hsl(var(--muted-foreground));
37 |
38 | --color-accent: hsl(var(--accent));
39 | --color-accent-foreground: hsl(var(--accent-foreground));
40 |
41 | --color-destructive: hsl(var(--destructive));
42 | --color-destructive-foreground: hsl(var(--destructive-foreground));
43 |
44 | --color-border: hsl(var(--border));
45 | --color-input: hsl(var(--input));
46 | --color-ring: hsl(var(--ring));
47 |
48 | --color-chart-1: hsl(var(--chart-1));
49 | --color-chart-2: hsl(var(--chart-2));
50 | --color-chart-3: hsl(var(--chart-3));
51 | --color-chart-4: hsl(var(--chart-4));
52 | --color-chart-5: hsl(var(--chart-5));
53 |
54 | --color-sidebar: hsl(var(--sidebar-background));
55 | --color-sidebar-foreground: hsl(var(--sidebar-foreground));
56 | --color-sidebar-primary: hsl(var(--sidebar-primary));
57 | --color-sidebar-primary-foreground: hsl(var(--sidebar-primary-foreground));
58 | --color-sidebar-accent: hsl(var(--sidebar-accent));
59 | --color-sidebar-accent-foreground: hsl(var(--sidebar-accent-foreground));
60 | --color-sidebar-border: hsl(var(--sidebar-border));
61 | --color-sidebar-ring: hsl(var(--sidebar-ring));
62 |
63 | --radius-lg: var(--radius);
64 | --radius-md: calc(var(--radius) - 2px);
65 | --radius-sm: calc(var(--radius) - 4px);
66 |
67 | --animate-accordion-down: accordion-down 0.2s ease-out;
68 | --animate-accordion-up: accordion-up 0.2s ease-out;
69 |
70 | @keyframes accordion-down {
71 | from {
72 | height: 0;
73 | }
74 | to {
75 | height: var(--radix-accordion-content-height);
76 | }
77 | }
78 | @keyframes accordion-up {
79 | from {
80 | height: var(--radix-accordion-content-height);
81 | }
82 | to {
83 | height: 0;
84 | }
85 | }
86 | }
87 |
88 | /*
89 | The default border color has changed to `currentColor` in Tailwind CSS v4,
90 | so we've added these compatibility styles to make sure everything still
91 | looks the same as it did with Tailwind CSS v3.
92 |
93 | If we ever want to remove these styles, we need to add an explicit border
94 | color utility to any element that depends on these defaults.
95 | */
96 | @layer base {
97 | *,
98 | ::after,
99 | ::before,
100 | ::backdrop,
101 | ::file-selector-button {
102 | border-color: var(--color-gray-200, currentColor);
103 | }
104 | }
105 |
106 | @layer utilities {
107 | body {
108 | font-family: Arial, Helvetica, sans-serif;
109 | }
110 | }
111 |
112 | @layer base {
113 | :root {
114 | --background: 0 0% 100%;
115 | --foreground: 0 0% 3.9%;
116 | --card: 0 0% 100%;
117 | --card-foreground: 0 0% 3.9%;
118 | --popover: 0 0% 100%;
119 | --popover-foreground: 0 0% 3.9%;
120 | --primary: 0 0% 9%;
121 | --primary-foreground: 0 0% 98%;
122 | --secondary: 0 0% 96.1%;
123 | --secondary-foreground: 0 0% 9%;
124 | --muted: 0 0% 96.1%;
125 | --muted-foreground: 0 0% 45.1%;
126 | --accent: 0 0% 96.1%;
127 | --accent-foreground: 0 0% 9%;
128 | --destructive: 0 84.2% 60.2%;
129 | --destructive-foreground: 0 0% 98%;
130 | --border: 0 0% 89.8%;
131 | --input: 0 0% 89.8%;
132 | --ring: 0 0% 3.9%;
133 | --chart-1: 12 76% 61%;
134 | --chart-2: 173 58% 39%;
135 | --chart-3: 197 37% 24%;
136 | --chart-4: 43 74% 66%;
137 | --chart-5: 27 87% 67%;
138 | --radius: 0.5rem;
139 | --sidebar-background: 0 0% 98%;
140 | --sidebar-foreground: 240 5.3% 26.1%;
141 | --sidebar-primary: 240 5.9% 10%;
142 | --sidebar-primary-foreground: 0 0% 98%;
143 | --sidebar-accent: 240 4.8% 95.9%;
144 | --sidebar-accent-foreground: 240 5.9% 10%;
145 | --sidebar-border: 220 13% 91%;
146 | --sidebar-ring: 217.2 91.2% 59.8%;
147 | }
148 | .dark {
149 | --background: 0 0% 3.9%;
150 | --foreground: 0 0% 98%;
151 | --card: 0 0% 3.9%;
152 | --card-foreground: 0 0% 98%;
153 | --popover: 0 0% 3.9%;
154 | --popover-foreground: 0 0% 98%;
155 | --primary: 0 0% 98%;
156 | --primary-foreground: 0 0% 9%;
157 | --secondary: 0 0% 14.9%;
158 | --secondary-foreground: 0 0% 98%;
159 | --muted: 0 0% 14.9%;
160 | --muted-foreground: 0 0% 63.9%;
161 | --accent: 0 0% 14.9%;
162 | --accent-foreground: 0 0% 98%;
163 | --destructive: 0 62.8% 30.6%;
164 | --destructive-foreground: 0 0% 98%;
165 | --border: 0 0% 14.9%;
166 | --input: 0 0% 14.9%;
167 | --ring: 0 0% 83.1%;
168 | --chart-1: 220 70% 50%;
169 | --chart-2: 160 60% 45%;
170 | --chart-3: 30 80% 55%;
171 | --chart-4: 280 65% 60%;
172 | --chart-5: 340 75% 55%;
173 | --sidebar-background: 240 5.9% 10%;
174 | --sidebar-foreground: 240 4.8% 95.9%;
175 | --sidebar-primary: 224.3 76.3% 48%;
176 | --sidebar-primary-foreground: 0 0% 100%;
177 | --sidebar-accent: 240 3.7% 15.9%;
178 | --sidebar-accent-foreground: 240 4.8% 95.9%;
179 | --sidebar-border: 240 3.7% 15.9%;
180 | --sidebar-ring: 217.2 91.2% 59.8%;
181 | }
182 | }
183 |
184 | @layer base {
185 | * {
186 | @apply border-border;
187 | }
188 | body {
189 | @apply bg-background text-foreground;
190 | }
191 | }
192 |
193 | .scroll-hidden {
194 | scrollbar-width: none;
195 | -ms-overflow-style: none;
196 | scrollbar-color: transparent transparent;
197 | scroll-behavior: smooth;
198 | -webkit-overflow-scrolling: touch;
199 | }
200 |
201 | .scroll-hidden::-webkit-scrollbar {
202 | display: none;
203 | }
204 |
205 | .scroll-hidden::-webkit-scrollbar-thumb {
206 | background-color: transparent;
207 | }
208 |
--------------------------------------------------------------------------------
/src/components/ui/carousel.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import useEmblaCarousel, {
5 | type UseEmblaCarouselType,
6 | } from "embla-carousel-react"
7 | import { ArrowLeft, ArrowRight } from "lucide-react"
8 |
9 | import { cn } from "@/lib/utils"
10 | import { Button } from "@/components/ui/button"
11 |
12 | type CarouselApi = UseEmblaCarouselType[1]
13 | type UseCarouselParameters = Parameters
14 | type CarouselOptions = UseCarouselParameters[0]
15 | type CarouselPlugin = UseCarouselParameters[1]
16 |
17 | type CarouselProps = {
18 | opts?: CarouselOptions
19 | plugins?: CarouselPlugin
20 | orientation?: "horizontal" | "vertical"
21 | setApi?: (api: CarouselApi) => void
22 | }
23 |
24 | type CarouselContextProps = {
25 | carouselRef: ReturnType[0]
26 | api: ReturnType[1]
27 | scrollPrev: () => void
28 | scrollNext: () => void
29 | canScrollPrev: boolean
30 | canScrollNext: boolean
31 | } & CarouselProps
32 |
33 | const CarouselContext = React.createContext(null)
34 |
35 | function useCarousel() {
36 | const context = React.useContext(CarouselContext)
37 |
38 | if (!context) {
39 | throw new Error("useCarousel must be used within a ")
40 | }
41 |
42 | return context
43 | }
44 |
45 | function Carousel({
46 | orientation = "horizontal",
47 | opts,
48 | setApi,
49 | plugins,
50 | className,
51 | children,
52 | ...props
53 | }: React.ComponentProps<"div"> & CarouselProps) {
54 | const [carouselRef, api] = useEmblaCarousel(
55 | {
56 | ...opts,
57 | axis: orientation === "horizontal" ? "x" : "y",
58 | },
59 | plugins
60 | )
61 | const [canScrollPrev, setCanScrollPrev] = React.useState(false)
62 | const [canScrollNext, setCanScrollNext] = React.useState(false)
63 |
64 | const onSelect = React.useCallback((api: CarouselApi) => {
65 | if (!api) return
66 | setCanScrollPrev(api.canScrollPrev())
67 | setCanScrollNext(api.canScrollNext())
68 | }, [])
69 |
70 | const scrollPrev = React.useCallback(() => {
71 | api?.scrollPrev()
72 | }, [api])
73 |
74 | const scrollNext = React.useCallback(() => {
75 | api?.scrollNext()
76 | }, [api])
77 |
78 | const handleKeyDown = React.useCallback(
79 | (event: React.KeyboardEvent) => {
80 | if (event.key === "ArrowLeft") {
81 | event.preventDefault()
82 | scrollPrev()
83 | } else if (event.key === "ArrowRight") {
84 | event.preventDefault()
85 | scrollNext()
86 | }
87 | },
88 | [scrollPrev, scrollNext]
89 | )
90 |
91 | React.useEffect(() => {
92 | if (!api || !setApi) return
93 | setApi(api)
94 | }, [api, setApi])
95 |
96 | React.useEffect(() => {
97 | if (!api) return
98 | onSelect(api)
99 | api.on("reInit", onSelect)
100 | api.on("select", onSelect)
101 |
102 | return () => {
103 | api?.off("select", onSelect)
104 | }
105 | }, [api, onSelect])
106 |
107 | return (
108 |
121 |
129 | {children}
130 |
131 |
132 | )
133 | }
134 |
135 | function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
136 | const { carouselRef, orientation } = useCarousel()
137 |
138 | return (
139 |
153 | )
154 | }
155 |
156 | function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
157 | const { orientation } = useCarousel()
158 |
159 | return (
160 |
171 | )
172 | }
173 |
174 | function CarouselPrevious({
175 | className,
176 | variant = "outline",
177 | size = "icon",
178 | ...props
179 | }: React.ComponentProps) {
180 | const { orientation, scrollPrev, canScrollPrev } = useCarousel()
181 |
182 | return (
183 |
201 | )
202 | }
203 |
204 | function CarouselNext({
205 | className,
206 | variant = "outline",
207 | size = "icon",
208 | ...props
209 | }: React.ComponentProps) {
210 | const { orientation, scrollNext, canScrollNext } = useCarousel()
211 |
212 | return (
213 |
231 | )
232 | }
233 |
234 | export {
235 | type CarouselApi,
236 | Carousel,
237 | CarouselContent,
238 | CarouselItem,
239 | CarouselPrevious,
240 | CarouselNext,
241 | }
242 |
--------------------------------------------------------------------------------
/src/components/ui/select.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as SelectPrimitive from "@radix-ui/react-select"
5 | import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | function Select({
10 | ...props
11 | }: React.ComponentProps) {
12 | return
13 | }
14 |
15 | function SelectGroup({
16 | ...props
17 | }: React.ComponentProps) {
18 | return
19 | }
20 |
21 | function SelectValue({
22 | ...props
23 | }: React.ComponentProps) {
24 | return
25 | }
26 |
27 | function SelectTrigger({
28 | className,
29 | size = "default",
30 | children,
31 | ...props
32 | }: React.ComponentProps & {
33 | size?: "sm" | "default"
34 | }) {
35 | return (
36 |
45 | {children}
46 |
47 |
48 |
49 |
50 | )
51 | }
52 |
53 | function SelectContent({
54 | className,
55 | children,
56 | position = "popper",
57 | ...props
58 | }: React.ComponentProps) {
59 | return (
60 |
61 |
72 |
73 |
80 | {children}
81 |
82 |
83 |
84 |
85 | )
86 | }
87 |
88 | function SelectLabel({
89 | className,
90 | ...props
91 | }: React.ComponentProps) {
92 | return (
93 |
98 | )
99 | }
100 |
101 | function SelectItem({
102 | className,
103 | children,
104 | ...props
105 | }: React.ComponentProps) {
106 | return (
107 |
115 |
116 |
117 |
118 |
119 |
120 | {children}
121 |
122 | )
123 | }
124 |
125 | function SelectSeparator({
126 | className,
127 | ...props
128 | }: React.ComponentProps) {
129 | return (
130 |
135 | )
136 | }
137 |
138 | function SelectScrollUpButton({
139 | className,
140 | ...props
141 | }: React.ComponentProps) {
142 | return (
143 |
151 |
152 |
153 | )
154 | }
155 |
156 | function SelectScrollDownButton({
157 | className,
158 | ...props
159 | }: React.ComponentProps) {
160 | return (
161 |
169 |
170 |
171 | )
172 | }
173 |
174 | export {
175 | Select,
176 | SelectContent,
177 | SelectGroup,
178 | SelectItem,
179 | SelectLabel,
180 | SelectScrollDownButton,
181 | SelectScrollUpButton,
182 | SelectSeparator,
183 | SelectTrigger,
184 | SelectValue,
185 | }
186 |
--------------------------------------------------------------------------------
/src/modules/studio/ui/sections/videos-section.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import { InfiniteScroll } from "@/components/infinite-scroll";
3 | import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
4 | import { DEFAULT_LIMIT } from "@/constants";
5 | import { snakeCaseToTitleCase } from "@/lib/utils";
6 | import { VideoThumbnail } from "@/modules/videos/ui/components/video-thumbnail";
7 | import { useTRPC } from "@/trpc/client";
8 | import { useSuspenseInfiniteQuery } from "@tanstack/react-query";
9 | import { format } from "date-fns";
10 | import { ErrorBoundary } from "react-error-boundary";
11 |
12 | import { Skeleton } from "@/components/ui/skeleton";
13 | import { GlobeIcon, LockIcon } from "lucide-react";
14 | import Link from "next/link";
15 | import { Suspense, useEffect } from "react";
16 |
17 | export const VideosSection = () => {
18 | return (
19 | }>
20 | Error...}>
21 |
22 |
23 |
24 | );
25 | };
26 |
27 | const VideoSectionSkeleton = () => {
28 | return (
29 | <>
30 |
31 |
32 |
33 |
34 | Video
35 | Visibility
36 | Status
37 | Date
38 | Views
39 | Comments
40 | Likes
41 |
42 |
43 |
44 | {Array.from({ length: 4 }).map((_, index) => (
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 | Public
59 |
60 |
61 | Ready
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 | ))}
76 |
77 |
78 |
79 | >
80 | );
81 | };
82 |
83 | export const VideosSectionSuspense = () => {
84 | const trpc = useTRPC();
85 | const {
86 | data: videos,
87 | hasNextPage,
88 | isFetchingNextPage,
89 | fetchNextPage,
90 | refetch,
91 | } = useSuspenseInfiniteQuery(
92 | trpc.studio.getMany.infiniteQueryOptions(
93 | { limit: DEFAULT_LIMIT },
94 | {
95 | getNextPageParam(lastPage) {
96 | return lastPage.nextCursor;
97 | },
98 | }
99 | )
100 | );
101 |
102 | const shouldPoll = videos.pages.some((page) =>
103 | page.items.some(
104 | (video) =>
105 | [video.muxStatus, video.muxTrackStatus].some((status) => status && status !== "ready") ||
106 | !video.thumbnailUrl ||
107 | !video.previewUrl ||
108 | !video.muxPlaybackId
109 | )
110 | );
111 |
112 | useEffect(() => {
113 | if (!shouldPoll) return;
114 | const intervalId = setInterval(() => {
115 | void refetch();
116 | }, 5000);
117 |
118 | return () => {
119 | clearInterval(intervalId);
120 | };
121 | }, [shouldPoll, refetch]);
122 |
123 | return (
124 |
125 |
126 |
127 |
128 |
129 | Video
130 | Visibility
131 | Status
132 | Date
133 | Views
134 | Comments
135 | Likes
136 |
137 |
138 |
139 | {videos.pages
140 | .flatMap((page) => page.items)
141 | .map((video) => (
142 |
143 |
144 |
145 |
146 |
147 |
153 |
154 |
155 |
156 | {video.title}
157 |
158 |
159 | {video.description || "No description"}
160 |
161 |
162 |
163 |
164 |
165 |
166 |
167 | {video.visibility === "private" ? (
168 |
169 | ) : (
170 |
171 | )}
172 | {snakeCaseToTitleCase(video.visibility)}
173 |
174 |
175 |
176 |
177 | {snakeCaseToTitleCase(video.muxStatus || "error loading")}
178 |
179 |
180 | {format(video.createdAt, "d MMM yyyy")}
181 | Views
182 | Comments
183 | Likes
184 |
185 | ))}
186 |
187 |
188 |
189 |
195 |
196 | );
197 | };
198 |
--------------------------------------------------------------------------------
/src/app/api/videos/webhook/route.ts:
--------------------------------------------------------------------------------
1 | import { eq } from "drizzle-orm";
2 | import { headers } from "next/headers";
3 |
4 | import { UTApi } from "uploadthing/server";
5 |
6 | import { db } from "@/db";
7 | import { videos } from "@/db/schema";
8 | import { mux } from "@/lib/mux";
9 | import {
10 | VideoAssetCreatedWebhookEvent,
11 | VideoAssetDeletedWebhookEvent,
12 | VideoAssetErroredWebhookEvent,
13 | VideoAssetReadyWebhookEvent,
14 | VideoAssetTrackReadyWebhookEvent,
15 | } from "@mux/mux-node/resources/webhooks";
16 | import { NextRequest } from "next/server";
17 |
18 | // Configuración necesaria para Next.js 15
19 | export const dynamic = "force-dynamic";
20 | export const runtime = "nodejs";
21 |
22 | const SIGNING_SECRET = process.env.MUX_WEBHOOK_SECRET!;
23 | const USE_UPLOADTHING = Boolean(process.env.UPLOADTHING_TOKEN);
24 |
25 | type WebHookEvent =
26 | | VideoAssetCreatedWebhookEvent
27 | | VideoAssetErroredWebhookEvent
28 | | VideoAssetReadyWebhookEvent
29 | | VideoAssetTrackReadyWebhookEvent
30 | | VideoAssetDeletedWebhookEvent;
31 |
32 | export const POST = async (request: NextRequest) => {
33 | try {
34 | if (!SIGNING_SECRET) {
35 | console.error("MUX_WEBHOOK_SECRET is not set");
36 | return new Response("MUX_WEBHOOK_SECRET is not set", { status: 500 });
37 | }
38 |
39 | const headersPayload = await headers();
40 | const muxSignature = headersPayload.get("mux-signature");
41 |
42 | if (!muxSignature) {
43 | console.error("Mux signature is not set");
44 | return new Response("Mux signature is not set", { status: 400 });
45 | }
46 |
47 | const payload = await request.json();
48 | const body = JSON.stringify(payload);
49 |
50 | // Verificar la firma del webhook
51 | try {
52 | mux.webhooks.verifySignature(
53 | body,
54 | {
55 | "mux-signature": muxSignature,
56 | },
57 | SIGNING_SECRET
58 | );
59 | } catch (error) {
60 | console.error("Failed to verify Mux webhook signature:", error);
61 | return new Response("Invalid signature", { status: 401 });
62 | }
63 |
64 | console.log("Webhook received:", payload.type);
65 | if (!USE_UPLOADTHING) {
66 | console.log("⚠️ UploadThing not configured - using Mux URLs directly");
67 | }
68 |
69 | switch (payload.type as WebHookEvent["type"]) {
70 | case "video.asset.created": {
71 | const data = payload.data as VideoAssetCreatedWebhookEvent["data"];
72 | if (!data.upload_id) {
73 | return new Response("Upload ID is not set", { status: 400 });
74 | }
75 | await db
76 | .update(videos)
77 | .set({
78 | muxAssetId: data.id,
79 | muxStatus: data.status,
80 | updatedAt: new Date(),
81 | })
82 | .where(eq(videos.muxUploadId, data.upload_id));
83 |
84 | console.log("✅ Video asset created:", data.id);
85 | break;
86 | }
87 |
88 | case "video.asset.ready": {
89 | const data = payload.data as VideoAssetReadyWebhookEvent["data"];
90 | const playbackId = data.playback_ids?.[0]?.id;
91 |
92 | if (!data.upload_id) {
93 | return new Response("Upload ID is not set", { status: 400 });
94 | }
95 |
96 | if (!playbackId) {
97 | return new Response("Missing playback ID ", { status: 400 });
98 | }
99 |
100 | const tempThumbnailUrl = `https://image.mux.com/${playbackId}/thumbnail.png`;
101 | const tempPreviewUrl = `https://image.mux.com/${playbackId}/animated.gif`;
102 | const duration = data.duration ? Math.round(data.duration * 1000) : 0;
103 |
104 | let thumbnailUrl = tempThumbnailUrl;
105 | let thumbnailKey: string | null = null;
106 | let previewUrl = tempPreviewUrl;
107 | let previewKey: string | null = null;
108 |
109 | // Si UploadThing está configurado, subir las imágenes allí
110 | if (USE_UPLOADTHING) {
111 | try {
112 | console.log("📤 Uploading thumbnails to UploadThing...");
113 | const utapi = new UTApi();
114 | const [uploadedThumbnail, uploadedPreview] = await utapi.uploadFilesFromUrl([
115 | tempThumbnailUrl,
116 | tempPreviewUrl,
117 | ]);
118 |
119 | if (uploadedThumbnail.data && uploadedPreview.data) {
120 | thumbnailUrl = uploadedThumbnail.data.ufsUrl;
121 | thumbnailKey = uploadedThumbnail.data.key;
122 | previewUrl = uploadedPreview.data.ufsUrl;
123 | previewKey = uploadedPreview.data.key;
124 | console.log("✅ Thumbnails uploaded to UploadThing");
125 | } else {
126 | console.warn("⚠️ Failed to upload to UploadThing, using Mux URLs");
127 | }
128 | } catch (error) {
129 | console.error("⚠️ Error uploading to UploadThing, using Mux URLs:", error);
130 | }
131 | } else {
132 | console.log("ℹ️ Using Mux URLs directly (UploadThing not configured)");
133 | }
134 |
135 | await db
136 | .update(videos)
137 | .set({
138 | muxStatus: data.status,
139 | muxPlaybackId: playbackId,
140 | muxAssetId: data.id,
141 | thumbnailUrl,
142 | thumbnailKey,
143 | previewUrl,
144 | previewKey,
145 | duration,
146 | updatedAt: new Date(),
147 | })
148 | .where(eq(videos.muxUploadId, data.upload_id));
149 |
150 | console.log("✅ Video asset ready:", data.id);
151 | break;
152 | }
153 |
154 | case "video.asset.errored": {
155 | const data = payload.data as VideoAssetErroredWebhookEvent["data"];
156 | if (!data.upload_id) {
157 | return new Response("Upload ID is not set", { status: 400 });
158 | }
159 | await db
160 | .update(videos)
161 | .set({
162 | muxStatus: data.status,
163 | updatedAt: new Date(),
164 | })
165 | .where(eq(videos.muxUploadId, data.upload_id));
166 |
167 | console.error("❌ Video asset errored:", data.id, data.errors);
168 | break;
169 | }
170 |
171 | case "video.asset.deleted": {
172 | const data = payload.data as VideoAssetDeletedWebhookEvent["data"];
173 | if (!data.upload_id) {
174 | return new Response("Upload ID is not set", { status: 400 });
175 | }
176 | await db.delete(videos).where(eq(videos.muxUploadId, data.upload_id));
177 | console.log("🗑️ Video deleted:", data.upload_id);
178 | break;
179 | }
180 |
181 | case "video.asset.track.ready": {
182 | const data = payload.data as VideoAssetTrackReadyWebhookEvent["data"] & {
183 | asset_id: string;
184 | };
185 |
186 | console.log("📝 Video asset track ready:", data);
187 |
188 | const assetId = data.asset_id;
189 | const trackId = data.id;
190 | const status = data.status;
191 |
192 | if (!assetId) {
193 | return new Response("Missing asset ID", { status: 400 });
194 | }
195 | await db
196 | .update(videos)
197 | .set({
198 | muxTrackId: trackId,
199 | muxTrackStatus: status,
200 | updatedAt: new Date(),
201 | })
202 | .where(eq(videos.muxAssetId, assetId));
203 |
204 | console.log("🎵 Track processed", {
205 | assetId,
206 | trackId,
207 | status,
208 | });
209 |
210 | break;
211 | }
212 |
213 | default: {
214 | console.log("ℹ️ Unhandled webhook type", payload.type);
215 | break;
216 | }
217 | }
218 |
219 | return new Response("Webhook processed", { status: 200 });
220 | } catch (error) {
221 | console.error("Unhandled webhook error", error);
222 | return new Response("Internal Server Error", { status: 500 });
223 | }
224 | };
225 |
--------------------------------------------------------------------------------
/src/components/ui/context-menu.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"
5 | import { Check, ChevronRight, Circle } from "lucide-react"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const ContextMenu = ContextMenuPrimitive.Root
10 |
11 | const ContextMenuTrigger = ContextMenuPrimitive.Trigger
12 |
13 | const ContextMenuGroup = ContextMenuPrimitive.Group
14 |
15 | const ContextMenuPortal = ContextMenuPrimitive.Portal
16 |
17 | const ContextMenuSub = ContextMenuPrimitive.Sub
18 |
19 | const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup
20 |
21 | const ContextMenuSubTrigger = React.forwardRef<
22 | React.ElementRef,
23 | React.ComponentPropsWithoutRef & {
24 | inset?: boolean
25 | }
26 | >(({ className, inset, children, ...props }, ref) => (
27 |
36 | {children}
37 |
38 |
39 | ))
40 | ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName
41 |
42 | const ContextMenuSubContent = React.forwardRef<
43 | React.ElementRef,
44 | React.ComponentPropsWithoutRef
45 | >(({ className, ...props }, ref) => (
46 |
54 | ))
55 | ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName
56 |
57 | const ContextMenuContent = React.forwardRef<
58 | React.ElementRef,
59 | React.ComponentPropsWithoutRef
60 | >(({ className, ...props }, ref) => (
61 |
62 |
70 |
71 | ))
72 | ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName
73 |
74 | const ContextMenuItem = React.forwardRef<
75 | React.ElementRef,
76 | React.ComponentPropsWithoutRef & {
77 | inset?: boolean
78 | }
79 | >(({ className, inset, ...props }, ref) => (
80 |
89 | ))
90 | ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName
91 |
92 | const ContextMenuCheckboxItem = React.forwardRef<
93 | React.ElementRef,
94 | React.ComponentPropsWithoutRef
95 | >(({ className, children, checked, ...props }, ref) => (
96 |
105 |
106 |
107 |
108 |
109 |
110 | {children}
111 |
112 | ))
113 | ContextMenuCheckboxItem.displayName =
114 | ContextMenuPrimitive.CheckboxItem.displayName
115 |
116 | const ContextMenuRadioItem = React.forwardRef<
117 | React.ElementRef,
118 | React.ComponentPropsWithoutRef
119 | >(({ className, children, ...props }, ref) => (
120 |
128 |
129 |
130 |
131 |
132 |
133 | {children}
134 |
135 | ))
136 | ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName
137 |
138 | const ContextMenuLabel = React.forwardRef<
139 | React.ElementRef,
140 | React.ComponentPropsWithoutRef & {
141 | inset?: boolean
142 | }
143 | >(({ className, inset, ...props }, ref) => (
144 |
153 | ))
154 | ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName
155 |
156 | const ContextMenuSeparator = React.forwardRef<
157 | React.ElementRef,
158 | React.ComponentPropsWithoutRef
159 | >(({ className, ...props }, ref) => (
160 |
165 | ))
166 | ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName
167 |
168 | const ContextMenuShortcut = ({
169 | className,
170 | ...props
171 | }: React.HTMLAttributes) => {
172 | return (
173 |
180 | )
181 | }
182 | ContextMenuShortcut.displayName = "ContextMenuShortcut"
183 |
184 | export {
185 | ContextMenu,
186 | ContextMenuTrigger,
187 | ContextMenuContent,
188 | ContextMenuItem,
189 | ContextMenuCheckboxItem,
190 | ContextMenuRadioItem,
191 | ContextMenuLabel,
192 | ContextMenuSeparator,
193 | ContextMenuShortcut,
194 | ContextMenuGroup,
195 | ContextMenuPortal,
196 | ContextMenuSub,
197 | ContextMenuSubContent,
198 | ContextMenuSubTrigger,
199 | ContextMenuRadioGroup,
200 | }
201 |
--------------------------------------------------------------------------------
/src/components/ui/menubar.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as MenubarPrimitive from "@radix-ui/react-menubar"
5 | import { Check, ChevronRight, Circle } from "lucide-react"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const MenubarMenu = MenubarPrimitive.Menu
10 |
11 | const MenubarGroup = MenubarPrimitive.Group
12 |
13 | const MenubarPortal = MenubarPrimitive.Portal
14 |
15 | const MenubarSub = MenubarPrimitive.Sub
16 |
17 | const MenubarRadioGroup = MenubarPrimitive.RadioGroup
18 |
19 | const Menubar = React.forwardRef<
20 | React.ElementRef,
21 | React.ComponentPropsWithoutRef
22 | >(({ className, ...props }, ref) => (
23 |
31 | ))
32 | Menubar.displayName = MenubarPrimitive.Root.displayName
33 |
34 | const MenubarTrigger = React.forwardRef<
35 | React.ElementRef,
36 | React.ComponentPropsWithoutRef
37 | >(({ className, ...props }, ref) => (
38 |
46 | ))
47 | MenubarTrigger.displayName = MenubarPrimitive.Trigger.displayName
48 |
49 | const MenubarSubTrigger = React.forwardRef<
50 | React.ElementRef,
51 | React.ComponentPropsWithoutRef & {
52 | inset?: boolean
53 | }
54 | >(({ className, inset, children, ...props }, ref) => (
55 |
64 | {children}
65 |
66 |
67 | ))
68 | MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName
69 |
70 | const MenubarSubContent = React.forwardRef<
71 | React.ElementRef,
72 | React.ComponentPropsWithoutRef
73 | >(({ className, ...props }, ref) => (
74 |
82 | ))
83 | MenubarSubContent.displayName = MenubarPrimitive.SubContent.displayName
84 |
85 | const MenubarContent = React.forwardRef<
86 | React.ElementRef,
87 | React.ComponentPropsWithoutRef
88 | >(
89 | (
90 | { className, align = "start", alignOffset = -4, sideOffset = 8, ...props },
91 | ref
92 | ) => (
93 |
94 |
105 |
106 | )
107 | )
108 | MenubarContent.displayName = MenubarPrimitive.Content.displayName
109 |
110 | const MenubarItem = React.forwardRef<
111 | React.ElementRef,
112 | React.ComponentPropsWithoutRef & {
113 | inset?: boolean
114 | }
115 | >(({ className, inset, ...props }, ref) => (
116 |
125 | ))
126 | MenubarItem.displayName = MenubarPrimitive.Item.displayName
127 |
128 | const MenubarCheckboxItem = React.forwardRef<
129 | React.ElementRef,
130 | React.ComponentPropsWithoutRef
131 | >(({ className, children, checked, ...props }, ref) => (
132 |
141 |
142 |
143 |
144 |
145 |
146 | {children}
147 |
148 | ))
149 | MenubarCheckboxItem.displayName = MenubarPrimitive.CheckboxItem.displayName
150 |
151 | const MenubarRadioItem = React.forwardRef<
152 | React.ElementRef,
153 | React.ComponentPropsWithoutRef
154 | >(({ className, children, ...props }, ref) => (
155 |
163 |
164 |
165 |
166 |
167 |
168 | {children}
169 |
170 | ))
171 | MenubarRadioItem.displayName = MenubarPrimitive.RadioItem.displayName
172 |
173 | const MenubarLabel = React.forwardRef<
174 | React.ElementRef,
175 | React.ComponentPropsWithoutRef & {
176 | inset?: boolean
177 | }
178 | >(({ className, inset, ...props }, ref) => (
179 |
188 | ))
189 | MenubarLabel.displayName = MenubarPrimitive.Label.displayName
190 |
191 | const MenubarSeparator = React.forwardRef<
192 | React.ElementRef,
193 | React.ComponentPropsWithoutRef
194 | >(({ className, ...props }, ref) => (
195 |
200 | ))
201 | MenubarSeparator.displayName = MenubarPrimitive.Separator.displayName
202 |
203 | const MenubarShortcut = ({
204 | className,
205 | ...props
206 | }: React.HTMLAttributes) => {
207 | return (
208 |
215 | )
216 | }
217 | MenubarShortcut.displayname = "MenubarShortcut"
218 |
219 | export {
220 | Menubar,
221 | MenubarMenu,
222 | MenubarTrigger,
223 | MenubarContent,
224 | MenubarItem,
225 | MenubarSeparator,
226 | MenubarLabel,
227 | MenubarCheckboxItem,
228 | MenubarRadioGroup,
229 | MenubarRadioItem,
230 | MenubarPortal,
231 | MenubarSubContent,
232 | MenubarSubTrigger,
233 | MenubarGroup,
234 | MenubarSub,
235 | MenubarShortcut,
236 | }
237 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | # 🚀 NewTube — Tu Plataforma de Video, Reinventada
4 |
5 | ¡Bienvenido a **NewTube**! Este proyecto es mi laboratorio personal, donde fusiono lo mejor de la tecnología web moderna con mi visión única de cómo debería ser una plataforma de video. Aquí no solo clono YouTube: lo reinvento, lo personalizo y lo hago mío. Cada línea de código, cada decisión de diseño y cada feature están pensados para reflejar mi estilo, creatividad y pasión por el desarrollo.
6 |
7 | ---
8 |
9 | ## 🧑💻 Sobre el Proyecto
10 |
11 | **NewTube** es un clon avanzado de YouTube construido con Next.js 15, TypeScript y Tailwind CSS 4, pero va mucho más allá de un simple clon. Es un entorno de experimentación, aprendizaje y demostración de buenas prácticas, arquitectura modular y experiencia de usuario de alto nivel. Aquí, la innovación y la personalización son la norma.
12 |
13 | > "El código es mi arte, la web mi lienzo. Cada pixel y cada línea aquí me representan."
14 |
15 | ---
16 |
17 | ## 🏆 Estado Actual
18 |
19 | ### ✅ Listo y funcionando
20 |
21 | - UI ultra moderna y responsiva (Tailwind CSS 4, Radix UI, Lucide)
22 | - SSR y App Router (Next.js 15)
23 | - Autenticación segura (Clerk)
24 | - Sidebar y navegación adaptativa
25 | - Búsqueda optimizada y rápida
26 | - Página principal con filtrado por categorías
27 | - Sistema de categorías dinámico
28 | - API type-safe (tRPC)
29 | - ORM y base de datos (Drizzle ORM + NeonDB)
30 | - Rate limiting y caching (Upstash Redis)
31 | - Pipeline de subida de video con Mux Direct Uploads + thumbnails vía UploadThing
32 | - Webhooks de Mux + UploadThing enrutados con revalidación automática en el dashboard
33 | - Prefetching, polling inteligente y data fetching eficiente (React Query)
34 | - Componentes reutilizables y arquitectura modular
35 | - Scripts de seed y utilidades para desarrollo
36 |
37 | ### 🛠️ En desarrollo y próximos pasos
38 |
39 | - Perfiles y canales de usuario personalizables
40 | - Recomendaciones de video inteligentes
41 | - Sistema de comentarios en tiempo real
42 | - Playlists, suscripciones y notificaciones
43 | - Historial de visualización y analíticas para creadores
44 | - Testing unitario y E2E (Vitest, Cypress)
45 | - Mejoras de accesibilidad (WCAG)
46 | - Optimización de performance (Lighthouse, bundle splitting)
47 |
48 | ---
49 |
50 | ## 🚨 Webhooks y flujo de subida
51 |
52 | - **Mux Direct Uploads** genera los assets y dispara webhooks cuando el asset cambia de estado (created, ready, track ready, deleted, errored).
53 | - **UploadThing** recibe las thumbnails/preview generadas desde Mux (vía `uploadFilesFromUrl`) y las persiste para el dashboard.
54 | - El handler `/api/videos/webhook` vuelve a validar la firma de Mux y actualiza la base de datos, refrescando thumbnail, preview, duraciones y estados.
55 | - El área de estudio (`/studio`) hace polling inteligente hasta que el asset queda listo, por lo que el estado cambia en vivo sin refrescar.
56 | - Para desarrollo local sigue siendo necesario exponer el servidor con ngrok para que Mux pueda llamar a los webhooks.
57 |
58 | Ejemplo de túnel temporal:
59 |
60 | ```
61 | ngrok http --url=musical-stag-luckily.ngrok-free.app 3000
62 | ```
63 |
64 | ---
65 |
66 | ## 🛠️ Stack Tecnológico
67 |
68 | ### Frontend
69 |
70 | - **Next.js 15** (App Router, SSR, TypeScript)
71 | - **Tailwind CSS 4** (con animaciones y utilidades personalizadas)
72 | - **Radix UI** (componentes accesibles y modernos)
73 | - **Lucide React** (iconografía)
74 | - **React Hook Form + Zod** (formularios y validación)
75 | - **React Query** (data fetching y caching)
76 | - **Embla Carousel** (sliders y carruseles)
77 | - **clsx, tailwind-merge, class-variance-authority** (utilidades de estilos)
78 |
79 | ### Backend & API
80 |
81 | - **tRPC** (APIs type-safe, fullstack)
82 | - **Drizzle ORM** (PostgreSQL, NeonDB)
83 | - **Upstash Redis** (caching y rate limiting)
84 | - **Clerk** (autenticación y gestión de usuarios)
85 | - **Mux** (direct uploads, playback, tracks)
86 | - **UploadThing** (gestión de thumbnails y assets derivados)
87 | - **Svix** (webhooks)
88 | - **SuperJSON** (serialización avanzada)
89 |
90 | ### DevOps & Herramientas
91 |
92 | - **Bun** (package manager ultrarrápido)
93 | - **Netlify** (deploy y CI/CD)
94 | - **Turbopack** (fast refresh)
95 | - **Ngrok** (webhooks temporales)
96 | - **ESLint, Prettier** (calidad de código)
97 | - **TypeScript** (tipado estricto)
98 | - **Vitest, Cypress** (testing, próximamente)
99 |
100 | ---
101 |
102 | ## 📁 Estructura Moderna del Proyecto
103 |
104 | ```shell
105 | src/
106 | app/ # Rutas Next.js (auth, home, studio, api)
107 | components/ # Componentes UI reutilizables
108 | db/ # Configuración y esquema de base de datos
109 | hooks/ # Custom React hooks
110 | lib/ # Utilidades, configuración de Redis, rate limit, etc.
111 | middleware.ts # Middleware global Next.js
112 | modules/ # Módulos por feature (auth, home, studio, videos, categorías)
113 | providers/ # Context providers de React
114 | scripts/ # Scripts utilitarios (seed, etc.)
115 | trpc/ # Configuración y routers de tRPC
116 | public/ # Assets estáticos
117 | config files # Configuración (Tailwind, ESLint, Drizzle, etc.)
118 | ```
119 |
120 | ---
121 |
122 | ## 🚀 Primeros Pasos
123 |
124 | 1. **Clona el repositorio**
125 | ```bash
126 | git clone [URL-del-repo]
127 | cd Build-youtube-clone-with-nextjs
128 | ```
129 | 2. **Instala dependencias**
130 | ```bash
131 | bun install
132 | # o
133 | npm install
134 | ```
135 | 3. **Configura variables de entorno**
136 | Crea un archivo `.env.local` en la raíz con:
137 | ```env
138 | NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=tu_clerk_publishable_key
139 | CLERK_SECRET_KEY=tu_clerk_secret_key
140 | CLERK_SIGNING_SECRET=tu_clerk_signing_secret
141 | DATABASE_URL=tu_neondb_url
142 | UPSTASH_REDIS_REST_URL=tu_redis_url
143 | UPSTASH_REDIS_REST_TOKEN=tu_redis_token
144 | MUX_TOKEN_ID=tu_mux_token_id
145 | MUX_TOKEN_SECRET=tu_mux_token_secret
146 | MUX_WEBHOOK_SECRET=tu_mux_webhook_secret
147 | UPLOADTHING_TOKEN=tu_uploadthing_token
148 | # Opcional
149 | UPLOADTHING_LOG_LEVEL=error
150 | ```
151 | 4. **Seed de la base de datos**
152 | ```bash
153 | bun seed
154 | # o
155 | npm run seed
156 | ```
157 | 5. **Ejecuta el servidor de desarrollo**
158 | ```bash
159 | bun dev
160 | # o
161 | npm run dev
162 | ```
163 | 6. **(Recomendado) Levanta túnel para webhooks**
164 | ```bash
165 | bun run dev:all
166 | # o
167 | npm run dev:all
168 | ```
169 | Esto levanta `next dev` y el script de ngrok (`scripts/start-ngrok.mjs`).
170 | 7. Abre [http://localhost:3000](http://localhost:3000) en tu navegador.
171 |
172 | ---
173 |
174 | ## 🧩 Scripts Disponibles
175 |
176 | - `bun dev` / `npm run dev` — Modo desarrollo
177 | - `bun run dev:all` / `npm run dev:all` — Dev + túnel ngrok para webhooks
178 | - `bun run dev:webhook` / `npm run dev:webhook` — Solo túnel ngrok
179 | - `bun build` / `npm run build` — Build de producción
180 | - `bun start` / `npm start` — Producción
181 | - `bun lint` / `npm run lint` — Linter
182 | - `bun seed` / `npm run seed` — Seed de categorías
183 |
184 | ---
185 |
186 | ## 🏗️ Arquitectura y Filosofía
187 |
188 | - **Modularidad total:** Cada feature es un módulo autocontenible.
189 | - **Fullstack type-safe:** tRPC conecta cliente y servidor con tipado extremo.
190 | - **UI accesible y moderna:** Radix UI + Tailwind + animaciones.
191 | - **Performance y escalabilidad:** SSR, caching, prefetching, polling progresivo y bundle splitting.
192 | - **Seguridad:** Clerk, validación Zod, rate limiting, CSRF, variables seguras.
193 | - **Personalización:** Todo el código y diseño reflejan mi estilo y visión.
194 |
195 | ---
196 |
197 | ## 🤝 Contribuciones
198 |
199 | ¡Toda contribución es bienvenida! Si quieres aportar, sigue estos pasos:
200 |
201 | 1. Haz un fork del repo
202 | 2. Crea tu rama (`git checkout -b feature/mi-feature`)
203 | 3. Haz commit de tus cambios (`git commit -m 'Agrega mi feature'`)
204 | 4. Haz push a tu rama (`git push origin feature/mi-feature`)
205 | 5. Abre un Pull Request
206 |
207 | ---
208 |
209 | ## 📜 Licencia
210 |
211 | Este proyecto está bajo licencia MIT. Consulta el archivo LICENSE para más detalles.
212 |
213 | ---
214 |
215 | ## ✨ Hecho con pasión, café y código por Deus lo vult
216 |
217 | > “El código es mi arte, la web mi lienzo. Cada pixel y cada línea aquí me representan.”
218 |
219 | ---
220 |
221 | ¿Dudas, sugerencias o quieres contactarme?
222 | ¡Abre un issue o escríbeme directamente!
223 |
224 | ---
225 |
226 | **¡Gracias por visitar NewTube!**
227 | _Siéntete libre de explorar, aprender y contribuir a este proyecto que es tan único como yo._
228 |
229 | ---
230 |
231 | ¿Quieres ver el roadmap, avances o contactarme?
232 | ¡Sígueme en [Deus lo Vult]!
233 |
234 | ---
235 |
236 | ¿Listo para el futuro del video?
237 | **¡Bienvenido a NewTube!**
238 |
--------------------------------------------------------------------------------
/src/components/ui/dropdown-menu.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
5 | import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | function DropdownMenu({
10 | ...props
11 | }: React.ComponentProps) {
12 | return
13 | }
14 |
15 | function DropdownMenuPortal({
16 | ...props
17 | }: React.ComponentProps) {
18 | return (
19 |
20 | )
21 | }
22 |
23 | function DropdownMenuTrigger({
24 | ...props
25 | }: React.ComponentProps) {
26 | return (
27 |
31 | )
32 | }
33 |
34 | function DropdownMenuContent({
35 | className,
36 | sideOffset = 4,
37 | ...props
38 | }: React.ComponentProps) {
39 | return (
40 |
41 |
50 |
51 | )
52 | }
53 |
54 | function DropdownMenuGroup({
55 | ...props
56 | }: React.ComponentProps) {
57 | return (
58 |
59 | )
60 | }
61 |
62 | function DropdownMenuItem({
63 | className,
64 | inset,
65 | variant = "default",
66 | ...props
67 | }: React.ComponentProps & {
68 | inset?: boolean
69 | variant?: "default" | "destructive"
70 | }) {
71 | return (
72 |
82 | )
83 | }
84 |
85 | function DropdownMenuCheckboxItem({
86 | className,
87 | children,
88 | checked,
89 | ...props
90 | }: React.ComponentProps) {
91 | return (
92 |
101 |
102 |
103 |
104 |
105 |
106 | {children}
107 |
108 | )
109 | }
110 |
111 | function DropdownMenuRadioGroup({
112 | ...props
113 | }: React.ComponentProps) {
114 | return (
115 |
119 | )
120 | }
121 |
122 | function DropdownMenuRadioItem({
123 | className,
124 | children,
125 | ...props
126 | }: React.ComponentProps) {
127 | return (
128 |
136 |
137 |
138 |
139 |
140 |
141 | {children}
142 |
143 | )
144 | }
145 |
146 | function DropdownMenuLabel({
147 | className,
148 | inset,
149 | ...props
150 | }: React.ComponentProps & {
151 | inset?: boolean
152 | }) {
153 | return (
154 |
163 | )
164 | }
165 |
166 | function DropdownMenuSeparator({
167 | className,
168 | ...props
169 | }: React.ComponentProps) {
170 | return (
171 |
176 | )
177 | }
178 |
179 | function DropdownMenuShortcut({
180 | className,
181 | ...props
182 | }: React.ComponentProps<"span">) {
183 | return (
184 |
192 | )
193 | }
194 |
195 | function DropdownMenuSub({
196 | ...props
197 | }: React.ComponentProps) {
198 | return
199 | }
200 |
201 | function DropdownMenuSubTrigger({
202 | className,
203 | inset,
204 | children,
205 | ...props
206 | }: React.ComponentProps & {
207 | inset?: boolean
208 | }) {
209 | return (
210 |
219 | {children}
220 |
221 |
222 | )
223 | }
224 |
225 | function DropdownMenuSubContent({
226 | className,
227 | ...props
228 | }: React.ComponentProps) {
229 | return (
230 |
238 | )
239 | }
240 |
241 | export {
242 | DropdownMenu,
243 | DropdownMenuPortal,
244 | DropdownMenuTrigger,
245 | DropdownMenuContent,
246 | DropdownMenuGroup,
247 | DropdownMenuLabel,
248 | DropdownMenuItem,
249 | DropdownMenuCheckboxItem,
250 | DropdownMenuRadioGroup,
251 | DropdownMenuRadioItem,
252 | DropdownMenuSeparator,
253 | DropdownMenuShortcut,
254 | DropdownMenuSub,
255 | DropdownMenuSubTrigger,
256 | DropdownMenuSubContent,
257 | }
258 |
--------------------------------------------------------------------------------
|