diff --git a/apps/backend/index.ts b/apps/backend/index.ts index f9da50b..c88732d 100644 --- a/apps/backend/index.ts +++ b/apps/backend/index.ts @@ -14,6 +14,7 @@ import dotenv from "dotenv"; import paymentRoutes from "./routes/payment.routes"; import { router as webhookRouter } from "./routes/webhook.routes"; +import { router as packRoutes } from "./routes/packs.routes"; const IMAGE_GEN_CREDITS = 1; const TRAIN_MODEL_CREDITS = 20; @@ -223,13 +224,7 @@ app.post("/pack/generate", authMiddleware, async (req, res) => { }); }); -app.get("/pack/bulk", async (req, res) => { - const packs = await prismaClient.packs.findMany({}); - res.json({ - packs, - }); -}); app.get("/image/bulk", authMiddleware, async (req, res) => { const ids = req.query.ids as string[]; @@ -356,6 +351,7 @@ app.post("/fal-ai/webhook/image", async (req, res) => { app.use("/payment", paymentRoutes); app.use("/api/webhook", webhookRouter); +app.use("/pack", packRoutes); app.listen(PORT, () => { console.log(`Server is running on port ${PORT}`); diff --git a/apps/backend/middleware.ts b/apps/backend/middleware.ts index ee1eab9..7f3b6cb 100644 --- a/apps/backend/middleware.ts +++ b/apps/backend/middleware.ts @@ -7,6 +7,7 @@ declare global { interface Request { userId?: string; user?: { + role?: "admin" | "user"; email: string; }; } @@ -19,6 +20,7 @@ export async function authMiddleware( next: NextFunction ) { try { + console.log("Authenticating request"); const authHeader = req.headers["authorization"]; const token = authHeader?.split(" ")[1]; @@ -26,7 +28,7 @@ export async function authMiddleware( res.status(401).json({ message: "No token provided" }); return; } - + // Debug logs console.log("Received token:", token); @@ -62,6 +64,7 @@ export async function authMiddleware( // Fetch user details from Clerk const user = await clerkClient.users.getUser(userId); + console.log("User details:", user); const primaryEmail = user.emailAddresses.find( (email) => email.id === user.primaryEmailAddressId ); @@ -70,11 +73,13 @@ export async function authMiddleware( console.error("No email found for user"); res.status(400).json({ message: "User email not found" }); return; + } // Attach the user ID and email to the request req.userId = userId; req.user = { + role: (user.publicMetadata.role as "admin" | "user") || "user", email: primaryEmail.emailAddress, }; @@ -99,3 +104,20 @@ export async function authMiddleware( return; } } + +export async function adminMiddleware( + req: Request, + res: Response, + next: NextFunction +) { + console.log("Checking user role"); + await authMiddleware(req, res, () => { + console.log("User role:", req.user?.role); + if (req.user?.role !== "admin") { + res.status(403).json({ error: "Unauthorized" }); + return; + } + + next(); + }); +} diff --git a/apps/backend/package.json b/apps/backend/package.json index c6d3181..b6411c6 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -4,7 +4,7 @@ "type": "module", "scripts": { "start": "bun run index.ts", - "dev": "bun run index.ts" + "dev": "bun run --watch index.ts" }, "devDependencies": { "@types/bun": "latest" diff --git a/apps/backend/routes/packs.routes.ts b/apps/backend/routes/packs.routes.ts new file mode 100644 index 0000000..0963aa5 --- /dev/null +++ b/apps/backend/routes/packs.routes.ts @@ -0,0 +1,119 @@ +import { prismaClient } from "db"; +import { Router } from "express"; +import { adminMiddleware } from "../middleware"; +import { packSchema } from "common/types"; + +export const router: Router = Router(); + +// Fetch all packs +router.get("/bulk", async (req, res) => { + try { + const packs = await prismaClient.packs.findMany(); + res.status(200).json({ packs }); + } catch (error) { + res.status(500).json({ error: "Error fetching packs" }); + } +}); + +// Fetch a pack by ID +router.get("/:id", async (req, res) => { + try { + const pack = await prismaClient.packs.findUnique({ + where: { + id: req.params.id, + }, + include: { + prompts: true, + }, + }); + + if (!pack) { + res.status(404).json({ error: "Pack not found" }); + return; + } + + res.status(200).json({ pack }); + } catch (error) { + res.status(500).json({ error: "Error fetching pack" }); + } +}); + +// Create a new pack +router.post("/",adminMiddleware, async (req, res) => { + try { + const parsedata = packSchema.safeParse(req.body); + if (!parsedata.success) { + res.status(400).json({ error: parsedata.error }); + return; + } + + const newPack = await prismaClient.packs.create({ + data: { + name: parsedata.data.name, + description: parsedata.data.description, + imageUrl1: parsedata.data.imageUrl1, + imageUrl2: parsedata.data.imageUrl2, + prompts: { + create: parsedata.data.prompts.map((prompt) => ({ + prompt: prompt.prompt, + })), + }, + }, + }); + + res.status(201).json({ pack: newPack }); + } catch (error) { + res.status(500).json({ error: "Error creating pack" }); + } +}); + +// Update a pack by ID +router.put("/:id",adminMiddleware, async (req, res) => { + try { + const parsedata = packSchema.safeParse(req.body); + if (!parsedata.success) { + res.status(400).json({ error: parsedata.error }); + return; + } + const updatedPack = await prismaClient.packs.update({ + where: { + id: parsedata.data.id, + }, + data: { + name: parsedata.data.name, + description: parsedata.data.description, + imageUrl1: parsedata.data.imageUrl1, + imageUrl2: parsedata.data.imageUrl2, + prompts: { + upsert: parsedata.data.prompts.map((prompt) => ({ + where: { id: prompt.id }, + update: { prompt: prompt.prompt }, + create: { prompt: prompt.prompt }, + })), + }, + }, + }); + + res.status(200).json({ + pack: updatedPack, + }); + } catch (error) { + res.status(500).json({ error: "Error updating pack" }); + } +}); + +// Delete a pack by ID +router.delete("/:id",adminMiddleware, async (req, res) => { + try { + await prismaClient.packs.delete({ + where: { + id: req.params.id, + }, + }); + + res.status(204).json({ message: "Pack deleted" }); + } catch (error) { + console.error(error); + res.status(500).json({ error: "Error deleting pack" }); + } +}); diff --git a/apps/studio/.gitignore b/apps/studio/.gitignore new file mode 100644 index 0000000..3a254f0 --- /dev/null +++ b/apps/studio/.gitignore @@ -0,0 +1,3 @@ +node_modules +# Keep environment variables out of version control +.env \ No newline at end of file diff --git a/apps/studio/package.json b/apps/studio/package.json new file mode 100644 index 0000000..3f62d06 --- /dev/null +++ b/apps/studio/package.json @@ -0,0 +1,12 @@ +{ + "name": "studio", + "version": "0.0.0", + "scripts": { + "dev": "prisma studio --schema ../../packages/db/prisma/schema.prisma --port 3005", + "clean": "git clean -xdf .cache .turbo dist node_modules", + "typecheck": "tsc --noEmit --emitDeclarationOnly false" + }, + "devDependencies": { + "prisma": "6.3.1" + } + } \ No newline at end of file diff --git a/apps/studio/tsconfig.json b/apps/studio/tsconfig.json new file mode 100644 index 0000000..61b87a1 --- /dev/null +++ b/apps/studio/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "@repo/typescript-config/nextjs.json", + "include": ["**/*.ts", "**/*.tsx"], + "exclude": ["node_modules"] + } \ No newline at end of file diff --git a/apps/web/app/dashboard/page.tsx b/apps/web/app/(main)/dashboard/page.tsx similarity index 100% rename from apps/web/app/dashboard/page.tsx rename to apps/web/app/(main)/dashboard/page.tsx diff --git a/apps/web/app/(main)/layout.tsx b/apps/web/app/(main)/layout.tsx new file mode 100644 index 0000000..a3dd5db --- /dev/null +++ b/apps/web/app/(main)/layout.tsx @@ -0,0 +1,12 @@ +import { Appbar } from "@/components/Appbar"; +import { Footer } from "@/components/Footer"; + +export default function Layout({ children }: { children: React.ReactNode }) { + return ( +
+ +
{children}
+
+ ); +} diff --git a/apps/web/app/page.module.css b/apps/web/app/(main)/page.module.css similarity index 100% rename from apps/web/app/page.module.css rename to apps/web/app/(main)/page.module.css diff --git a/apps/web/app/page.tsx b/apps/web/app/(main)/page.tsx similarity index 100% rename from apps/web/app/page.tsx rename to apps/web/app/(main)/page.tsx diff --git a/apps/web/app/payment/cancel/page.tsx b/apps/web/app/(main)/payment/cancel/page.tsx similarity index 100% rename from apps/web/app/payment/cancel/page.tsx rename to apps/web/app/(main)/payment/cancel/page.tsx diff --git a/apps/web/app/payment/success/page.tsx b/apps/web/app/(main)/payment/success/page.tsx similarity index 100% rename from apps/web/app/payment/success/page.tsx rename to apps/web/app/(main)/payment/success/page.tsx diff --git a/apps/web/app/payment/verify/page.tsx b/apps/web/app/(main)/payment/verify/page.tsx similarity index 100% rename from apps/web/app/payment/verify/page.tsx rename to apps/web/app/(main)/payment/verify/page.tsx diff --git a/apps/web/app/pricing/page.tsx b/apps/web/app/(main)/pricing/page.tsx similarity index 100% rename from apps/web/app/pricing/page.tsx rename to apps/web/app/(main)/pricing/page.tsx diff --git a/apps/web/app/purchases/page.tsx b/apps/web/app/(main)/purchases/page.tsx similarity index 100% rename from apps/web/app/purchases/page.tsx rename to apps/web/app/(main)/purchases/page.tsx diff --git a/apps/web/app/train/page.tsx b/apps/web/app/(main)/train/page.tsx similarity index 99% rename from apps/web/app/train/page.tsx rename to apps/web/app/(main)/train/page.tsx index 5d9ab39..5ae3398 100644 --- a/apps/web/app/train/page.tsx +++ b/apps/web/app/(main)/train/page.tsx @@ -22,7 +22,7 @@ import { UploadModal } from "@/components/ui/upload" import { useState } from "react" import { TrainModelInput } from "common/inferred" import axios from "axios" -import { BACKEND_URL } from "../config" +import { BACKEND_URL } from "../../config" import { useRouter } from "next/navigation" import { useAuth } from "@clerk/nextjs" @@ -53,7 +53,6 @@ export default function Train() { const response = await axios.post(`${BACKEND_URL}/ai/training`, input, { headers: { Authorization - : `Bearer ${token}` } }); diff --git a/apps/web/app/admin/packs/[packId]/page.tsx b/apps/web/app/admin/packs/[packId]/page.tsx new file mode 100644 index 0000000..57336a3 --- /dev/null +++ b/apps/web/app/admin/packs/[packId]/page.tsx @@ -0,0 +1,71 @@ +"use client"; +import { useState, useEffect,use } from "react"; +import axios from "axios"; + +import { PackForm } from "@/components/packs/pack-form"; +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator, +} from "@/components/ui/breadcrumb"; +import { Pack } from "@/types"; + +export default function Page({ params }:{params:{packId:string}}) { + const { packId } = use(params); + const [pack, setPack] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [isError, setIsError] = useState(false); + + useEffect(() => { + const fetchPack = async () => { + setIsLoading(true); + setIsError(false); + try { + const response = await axios.get(`/pack/${packId}`); + console.log(response.data.pack); + setPack(response.data.pack); + } catch (error) { + console.error("Failed to fetch pack:", error); + setIsError(true); + } finally { + setIsLoading(false); + } + }; + fetchPack(); + }, [packId]); + + if (isLoading) { + return
Loading pack...
; + } + if (isError) { + return
Failed to load the pack. Please try again later.
; + } + + return ( +
+
+
+ + + + Admin + + + + Packs + + + + {pack?.name || "Pack"} + + + +
+
+ {pack && } +
+ ); +} diff --git a/apps/web/app/admin/packs/page.tsx b/apps/web/app/admin/packs/page.tsx new file mode 100644 index 0000000..e87494e --- /dev/null +++ b/apps/web/app/admin/packs/page.tsx @@ -0,0 +1,42 @@ +import { Suspense } from "react"; +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator, +} from "@/components/ui/breadcrumb"; + +import { PackGrid } from "@/components/packs/pack-grid"; +import { PackGridSkeleton } from "@/components/packs/pack-grid-skeleton"; +import { AddPackButton } from "@/components/packs/add-pack-button"; + +export default function Page() { + return ( +
+
+ + + + Admin + + + + Packs + + + + +
+ +
+

Content Packs

+
+ + }> + + +
+ ); +} diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index d8c9c45..4e8932f 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -195,3 +195,24 @@ --sidebar-ring: 217.2 91.2% 59.8%; } } +/* width */ +::-webkit-scrollbar { + width: 5px; +} + +/* Track */ +::-webkit-scrollbar-track { + background: #020202; + border-radius: 5px; +} + +/* Handle */ +::-webkit-scrollbar-thumb { + background: #3a3a3a; + border-radius: 5px; +} + +/* Handle on hover */ +::-webkit-scrollbar-thumb:hover { + background: #555; +} \ No newline at end of file diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index bf5c6db..9230e19 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -1,9 +1,7 @@ import type { Metadata } from "next"; import localFont from "next/font/local"; import "./globals.css"; -import { Appbar } from "@/components/Appbar"; import { Providers } from "@/components/providers/Providers"; -import { Footer } from "@/components/Footer"; import Script from "next/script"; const geistSans = localFont({ @@ -41,9 +39,7 @@ export default function RootLayout({ >
-
{children}
-
diff --git a/apps/web/components/packs/add-pack-button.tsx b/apps/web/components/packs/add-pack-button.tsx new file mode 100644 index 0000000..5a359a3 --- /dev/null +++ b/apps/web/components/packs/add-pack-button.tsx @@ -0,0 +1,22 @@ +"use client" + +import { useState } from "react" +import { Plus } from "lucide-react" + +import { Button } from "@/components/ui/button" +import { AddPackDialog } from "@/components/packs/add-pack-dialog" + +export function AddPackButton() { + const [open, setOpen] = useState(false) + + return ( + <> + + + + ) +} + diff --git a/apps/web/components/packs/add-pack-dialog.tsx b/apps/web/components/packs/add-pack-dialog.tsx new file mode 100644 index 0000000..ea3b15f --- /dev/null +++ b/apps/web/components/packs/add-pack-dialog.tsx @@ -0,0 +1,27 @@ +"use client"; + +import { PackForm } from "@/components/packs/pack-form"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; + +interface AddPackDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function AddPackDialog({ open, onOpenChange }: AddPackDialogProps) { + return ( + + + + Add New Pack + + onOpenChange(false)} /> + + + ); +} diff --git a/apps/web/components/packs/delete-pack-dialog.tsx b/apps/web/components/packs/delete-pack-dialog.tsx new file mode 100644 index 0000000..28c2085 --- /dev/null +++ b/apps/web/components/packs/delete-pack-dialog.tsx @@ -0,0 +1,102 @@ +"use client"; + +import { useState } from "react"; +import { Loader2 } from "lucide-react"; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { useToast } from "@/hooks/use-toast"; +import type { Pack } from "@/types"; +import axios from "axios"; +import { useAuth } from "@/hooks/useAuth"; + +interface DeletePackDialogProps { + pack: Pack; + open: boolean; + onOpenChange: (open: boolean) => void; + refetch: () => void; +} + +export function DeletePackDialog({ + pack, + open, + onOpenChange, + refetch, +}: DeletePackDialogProps) { + const [isDeleting, setIsDeleting] = useState(false); + const { toast } = useToast(); + const { getToken } = useAuth(); + + + const handleDelete = async () => { + setIsDeleting(true); + try { + const token = await getToken(); + + await axios.delete(`/pack/${pack.id}`,{ + headers:{ + Authorization: `Bearer ${token}`, + } + }); + + toast({ + title: "Pack deleted", + description: `"${pack.name}" has been successfully deleted.`, + }); + refetch(); + onOpenChange(false); + } catch (error) { + toast({ + variant: "destructive", + title: "Failed to delete pack", + description: "Please try again later.", + }); + } finally { + setIsDeleting(false); + } + }; + + return ( + + + + Are you absolutely sure? + + This will permanently delete the content pack "{pack.name}" and all + its prompts. This action cannot be undone. + + + + + Cancel + + { + e.preventDefault(); + handleDelete(); + }} + disabled={isDeleting} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90 cursor-pointer" + > + {isDeleting ? ( + <> + + Deleting... + + ) : ( + "Delete" + )} + + + + + ); +} diff --git a/apps/web/components/packs/pack-card.tsx b/apps/web/components/packs/pack-card.tsx new file mode 100644 index 0000000..06eeeb4 --- /dev/null +++ b/apps/web/components/packs/pack-card.tsx @@ -0,0 +1,100 @@ +"use client"; + +import Image from "next/image"; +import { MoreHorizontal } from "lucide-react"; + +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import type { Pack } from "@/types"; +import Link from "next/link"; + +interface PackCardProps { + pack: Pack; + onDelete: () => void; +} + +export function PackCard({ pack, onDelete }: PackCardProps) { + return ( + + + +
+ {/* + {pack.prompts.length} {pack.prompts.length === 1 ? "prompt" : "prompts"} + */} + + + + + + { + event.stopPropagation(); + onDelete(); + }} + className="text-destructive focus:text-destructive cursor-pointer" + > + Delete + + + +
+ + {pack.name} + + + {pack.description} + +
+ +
+
+ {pack.imageUrl1 ? ( + {`${pack.name} + ) : ( +
+ No image +
+ )} +
+
+ {pack.imageUrl2 ? ( + {`${pack.name} + ) : ( +
+ No image +
+ )} +
+
+
+
+ + ); +} diff --git a/apps/web/components/packs/pack-form.tsx b/apps/web/components/packs/pack-form.tsx new file mode 100644 index 0000000..2bc094d --- /dev/null +++ b/apps/web/components/packs/pack-form.tsx @@ -0,0 +1,312 @@ + "use client"; + +import { useState } from "react"; +import { useForm, useFieldArray } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Loader2, Plus, Trash2, ImageIcon } from "lucide-react"; +import axios from "axios"; +import { useAuth } from "@/hooks/useAuth"; +import { Button } from "@/components/ui/button"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { Separator } from "@/components/ui/separator"; +import { useToast } from "@/hooks/use-toast"; +import { type Pack, type PackFormValues } from "common/inferred"; +import { packSchema } from "common/types"; + +interface PackFormProps { + pack?: Pack; + onSuccess?: () => void; +} + +export function PackForm({ pack }: PackFormProps) { + const { getToken } = useAuth(); + const [isSubmitting, setIsSubmitting] = useState(false); + const { toast } = useToast(); + + const form = useForm({ + resolver: zodResolver(packSchema), + defaultValues: pack + ? { + id: pack.id, + name: pack.name, + description: pack.description, + imageUrl1: pack.imageUrl1, + imageUrl2: pack.imageUrl2, + prompts: pack.prompts, + } + : { + name: "", + description: "", + imageUrl1: "", + imageUrl2: "", + prompts: [{ prompt: "" }], + }, + mode: "onBlur", + }); + + const { fields, append, remove } = useFieldArray({ + control: form.control, + name: "prompts", + }); + + const updatePack = async (id: string, data: PackFormValues) => { + try { + const token = await getToken(); + const response = await axios.put(`/pack/${id}`, data,{ + headers: { + Authorization: `Bearer ${token}`, + }, + } + ) + console.log(response.data.pack); + } catch (error) { + console.error("Failed to update pack:", error); + } + }; + + const createPack = async (data: PackFormValues) => { + try { + const token = await getToken(); + const response = await axios.post("/pack", data, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + console.log(response.data.pack); + } catch (error) { + console.error("Failed to create pack:", error); + } + } + + + const onSubmit = async (data: PackFormValues) => { + setIsSubmitting(true); + try { + if (pack) { + await updatePack(pack.id, data) + console.log("Updating pack:", data); + toast({ + title: "Pack updated", + description: "Your content pack has been updated successfully.", + }); + } else { + await createPack(data) + console.log("Creating pack:", data); + toast({ + title: "Pack created", + description: "Your new content pack has been created successfully.", + }); + } + // onSuccess() + } catch (error) { + toast({ + variant: "destructive", + title: pack ? "Failed to update pack" : "Failed to create pack", + description: "Please try again later.", + }); + } finally { + setIsSubmitting(false); + } + }; + + return ( +
+ +
+
+

Pack Details

+
+ ( + + Name + + + + + + )} + /> + ( + + Description + +