From d11218b1cd70b878adbfba23b73845458fc4a46f Mon Sep 17 00:00:00 2001 From: Izan Gil <66965250+SrIzan10@users.noreply.github.com> Date: Sun, 15 Dec 2024 20:19:49 +0100 Subject: [PATCH] feat: projects, api, viewer, settings --- .prettierrc | 2 +- .vscode/settings.json | 2 + package.json | 7 +- .../20241214165516_add_projects/migration.sql | 13 + .../migration.sql | 10 + .../migration.sql | 12 + .../migration.sql | 26 ++ .../20241215161725_rate_limit/migration.sql | 3 + prisma/schema.prisma | 26 +- src/app/(protected)/create/page.tsx | 97 ++++ src/app/(protected)/dashboard/page.tsx | 18 +- src/app/(protected)/project/[id].tsx | 0 src/app/(protected)/project/[id]/page.tsx | 94 ++++ .../project/[id]/settings/page.tsx | 15 + src/app/api/feedback/[projectId]/route.ts | 70 +++ src/app/globals.css | 134 +++--- src/components/app/NavBar/NavBar.tsx | 3 +- .../app/ProjectCard/ProjectCard.tsx | 4 +- .../app/ProjectSettings/ProjectSettings.tsx | 222 ++++++++++ .../app/UniversalForm/UniversalForm.tsx | 88 ++++ src/components/app/UniversalForm/types.ts | 20 + src/components/app/UniversalForm/zod.ts | 18 + src/components/ui/breadcrumb.tsx | 115 +++++ src/components/ui/form.tsx | 178 ++++++++ src/components/ui/table.tsx | 124 ++++++ src/components/ui/tabs.tsx | 55 +++ src/lib/auth/actions.ts | 79 ---- src/lib/auth/zod.ts | 6 - src/lib/bodyGen.ts | 11 + src/lib/forms/actions.ts | 98 ++++ src/lib/forms/zod.ts | 8 + src/lib/zodVerify.ts | 34 ++ yarn.lock | 419 ++++-------------- 33 files changed, 1520 insertions(+), 491 deletions(-) create mode 100644 prisma/migrations/20241214165516_add_projects/migration.sql create mode 100644 prisma/migrations/20241214165631_github_url_to_github/migration.sql create mode 100644 prisma/migrations/20241214170206_auto_increment_project_id/migration.sql create mode 100644 prisma/migrations/20241214220443_feedback_i_think/migration.sql create mode 100644 prisma/migrations/20241215161725_rate_limit/migration.sql delete mode 100644 src/app/(protected)/project/[id].tsx create mode 100644 src/app/(protected)/project/[id]/page.tsx create mode 100644 src/app/(protected)/project/[id]/settings/page.tsx create mode 100644 src/app/api/feedback/[projectId]/route.ts create mode 100644 src/components/app/ProjectSettings/ProjectSettings.tsx create mode 100644 src/components/app/UniversalForm/UniversalForm.tsx create mode 100644 src/components/app/UniversalForm/types.ts create mode 100644 src/components/app/UniversalForm/zod.ts create mode 100644 src/components/ui/breadcrumb.tsx create mode 100644 src/components/ui/form.tsx create mode 100644 src/components/ui/table.tsx create mode 100644 src/components/ui/tabs.tsx delete mode 100644 src/lib/auth/zod.ts create mode 100644 src/lib/bodyGen.ts create mode 100644 src/lib/forms/actions.ts create mode 100644 src/lib/forms/zod.ts create mode 100644 src/lib/zodVerify.ts diff --git a/.prettierrc b/.prettierrc index 5324000..6725f25 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,6 +1,6 @@ { "useTabs": false, - "printWidth": 800, + "printWidth": 100, "tabWidth": 2, "singleQuote": true, "trailingComma": "es5", diff --git a/.vscode/settings.json b/.vscode/settings.json index cc9d4f3..eaca7ae 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,4 +2,6 @@ "editor.tabSize": 2, "editor.detectIndentation": false, "editor.insertSpaces": true, + "editor.rulers": [100], + "rewrap.wrappingColumn": 70, } \ No newline at end of file diff --git a/package.json b/package.json index 0929c5f..21fe120 100644 --- a/package.json +++ b/package.json @@ -12,13 +12,15 @@ "ui:add": "shadcn add" }, "dependencies": { + "@hookform/resolvers": "^3.9.1", "@lucia-auth/adapter-prisma": "^4.0.1", "@node-rs/argon2": "^2.0.2", "@prisma/client": "^6.0.1", "@radix-ui/react-avatar": "^1.0.4", "@radix-ui/react-dropdown-menu": "^2.1.2", - "@radix-ui/react-label": "^2.1.0", - "@radix-ui/react-slot": "^1.1.0", + "@radix-ui/react-label": "^2.1.1", + "@radix-ui/react-slot": "^1.1.1", + "@radix-ui/react-tabs": "^1.1.2", "arctic": "^2.3.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.0", @@ -28,6 +30,7 @@ "next-themes": "^0.4.4", "react": "^18", "react-dom": "^18", + "react-hook-form": "^7.54.1", "sonner": "^1.4.41", "tailwind-merge": "^2.2.2", "tailwindcss-animate": "^1.0.7", diff --git a/prisma/migrations/20241214165516_add_projects/migration.sql b/prisma/migrations/20241214165516_add_projects/migration.sql new file mode 100644 index 0000000..47cd4fc --- /dev/null +++ b/prisma/migrations/20241214165516_add_projects/migration.sql @@ -0,0 +1,13 @@ +-- CreateTable +CREATE TABLE "Project" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT NOT NULL, + "githubUrl" TEXT NOT NULL, + "userId" TEXT NOT NULL, + + CONSTRAINT "Project_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "Project" ADD CONSTRAINT "Project_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/migrations/20241214165631_github_url_to_github/migration.sql b/prisma/migrations/20241214165631_github_url_to_github/migration.sql new file mode 100644 index 0000000..22e7000 --- /dev/null +++ b/prisma/migrations/20241214165631_github_url_to_github/migration.sql @@ -0,0 +1,10 @@ +/* + Warnings: + + - You are about to drop the column `githubUrl` on the `Project` table. All the data in the column will be lost. + - Added the required column `github` to the `Project` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "Project" DROP COLUMN "githubUrl", +ADD COLUMN "github" TEXT NOT NULL; diff --git a/prisma/migrations/20241214170206_auto_increment_project_id/migration.sql b/prisma/migrations/20241214170206_auto_increment_project_id/migration.sql new file mode 100644 index 0000000..7ae61d7 --- /dev/null +++ b/prisma/migrations/20241214170206_auto_increment_project_id/migration.sql @@ -0,0 +1,12 @@ +/* + Warnings: + + - The primary key for the `Project` table will be changed. If it partially fails, the table could be left without primary key constraint. + - The `id` column on the `Project` table would be dropped and recreated. This will lead to data loss if there is data in the column. + +*/ +-- AlterTable +ALTER TABLE "Project" DROP CONSTRAINT "Project_pkey", +DROP COLUMN "id", +ADD COLUMN "id" SERIAL NOT NULL, +ADD CONSTRAINT "Project_pkey" PRIMARY KEY ("id"); diff --git a/prisma/migrations/20241214220443_feedback_i_think/migration.sql b/prisma/migrations/20241214220443_feedback_i_think/migration.sql new file mode 100644 index 0000000..ac64429 --- /dev/null +++ b/prisma/migrations/20241214220443_feedback_i_think/migration.sql @@ -0,0 +1,26 @@ +/* + Warnings: + + - The primary key for the `Project` table will be changed. If it partially fails, the table could be left without primary key constraint. + +*/ +-- AlterTable +ALTER TABLE "Project" DROP CONSTRAINT "Project_pkey", +ADD COLUMN "customData" TEXT[], +ALTER COLUMN "id" DROP DEFAULT, +ALTER COLUMN "id" SET DATA TYPE TEXT, +ADD CONSTRAINT "Project_pkey" PRIMARY KEY ("id"); +DROP SEQUENCE "Project_id_seq"; + +-- CreateTable +CREATE TABLE "Feedback" ( + "id" SERIAL NOT NULL, + "message" TEXT NOT NULL, + "customData" TEXT NOT NULL, + "projectId" TEXT NOT NULL, + + CONSTRAINT "Feedback_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "Feedback" ADD CONSTRAINT "Feedback_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/migrations/20241215161725_rate_limit/migration.sql b/prisma/migrations/20241215161725_rate_limit/migration.sql new file mode 100644 index 0000000..0c9e539 --- /dev/null +++ b/prisma/migrations/20241215161725_rate_limit/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "Project" ADD COLUMN "rateLimitReq" INTEGER NOT NULL DEFAULT 5, +ADD COLUMN "rateLimitTime" INTEGER NOT NULL DEFAULT 60; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 25cecb6..9fded08 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -17,6 +17,7 @@ model User { id String @id @default(cuid()) githubId String @unique username String + projects Project[] sessions Session[] } @@ -25,4 +26,27 @@ model Session { userId String expiresAt DateTime user User @relation(references: [id], fields: [userId], onDelete: Cascade) -} \ No newline at end of file +} + +model Project { + id String @id @default(cuid()) + name String + description String + github String + customData String[] + rateLimitReq Int @default(5) + rateLimitTime Int @default(60) + + userId String + user User @relation(fields: [userId], references: [id]) + feedback Feedback[] +} + +model Feedback { + id Int @id @default(autoincrement()) + message String + customData String + + projectId String + project Project @relation(fields: [projectId], references: [id]) +} diff --git a/src/app/(protected)/create/page.tsx b/src/app/(protected)/create/page.tsx index e69de29..a72b0a9 100644 --- a/src/app/(protected)/create/page.tsx +++ b/src/app/(protected)/create/page.tsx @@ -0,0 +1,97 @@ +'use client'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { useForm } from 'react-hook-form'; +import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; +import { Input } from '@/components/ui/input'; +import { useFormState } from 'react-dom'; +import { createSchema, createSchemaType } from '@/lib/forms/zod'; +import { create } from '@/lib/forms/actions'; +import React from 'react'; +import { useRouter } from 'next/navigation'; +import SubmitButton from '@/components/app/SubmitButton/SubmitButton'; + +// TODO: move form to the new universal form component +export default function ProfileForm() { + const router = useRouter(); + const form = useForm({ + resolver: zodResolver(createSchema), + defaultValues: { + name: '', + description: '', + github: '', + }, + }); + + const [formState, formAction] = useFormState(create, null); + React.useEffect(() => { + if (formState && formState.id) { + router.push(`/project/${formState.id}`); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [formState]); + + return ( +
+
+
+ { + const formData = new FormData(); + Object.entries(data).forEach(([key, value]) => { + formData.append(key, value); + }); + formAction(formData); + })} + className="space-y-8" + > + ( + + Project name + + + + How the project is called. + + + )} + /> + ( + + Description + + + + Describe the project a bit. + + + )} + /> + ( + + Github + + + + Your Github repository link. Will come in handy for some integrations! + + + )} + /> + + + +
+
+ ); +} diff --git a/src/app/(protected)/dashboard/page.tsx b/src/app/(protected)/dashboard/page.tsx index 2fe3a48..dbdb8f5 100644 --- a/src/app/(protected)/dashboard/page.tsx +++ b/src/app/(protected)/dashboard/page.tsx @@ -1,18 +1,20 @@ import ProjectCard from '@/components/app/ProjectCard/ProjectCard'; +import { validateRequest } from '@/lib/auth'; +import prisma from '@/lib/db'; import { faker } from '@faker-js/faker'; -export const dummyData = Array.from({ length: 10 }, (_, id) => ({ - id: id + 1, - name: faker.word.noun(), - description: faker.lorem.sentence(), - github: id !== 5 ? faker.internet.url() : undefined, -})); -export default function Page() { +export default async function Page() { + const { user } = await validateRequest(); + const db = await prisma.project.findMany({ + where: { + userId: user!.id, + }, + }); return ( <>

Dashboard

- {dummyData.map((d) => ( + {db.map((d) => ( ))}
diff --git a/src/app/(protected)/project/[id].tsx b/src/app/(protected)/project/[id].tsx deleted file mode 100644 index e69de29..0000000 diff --git a/src/app/(protected)/project/[id]/page.tsx b/src/app/(protected)/project/[id]/page.tsx new file mode 100644 index 0000000..697e9f2 --- /dev/null +++ b/src/app/(protected)/project/[id]/page.tsx @@ -0,0 +1,94 @@ +import { Button } from '@/components/ui/button'; +import { + Table, + TableBody, + TableCaption, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { validateRequest } from '@/lib/auth'; +import prisma from '@/lib/db'; +import Link from 'next/link'; +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator, +} from "@/components/ui/breadcrumb" + +// TODO: refactor to maybe append the no feedback message to the table div +export default async function Page({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const { user } = await validateRequest(); + const project = await prisma.project.findFirst({ + where: { id, userId: user!.id }, + include: { feedback: true }, + }); + + if (!project) { + return
Project not found
; + } + + return ( +
+
+ + + + Dashboard + + + + {project.name} + + + +
+

{project.name}

+

{project.description}

+ + + +
+ {project.feedback.length === 0 ? ( +
+

No feedback!

+

+ Once you start receiving feedback, it will appear here. +

+
+ ) : ( +
+ + + + ID + Message + {project.customData.map((key) => ( + {key} + ))} + + + + {/* using toReversed to not change the upstream array in case of other data treatments needed */} + {project.feedback.toReversed().map((feedback) => ( + + {feedback.id} + {feedback.message} + {Object.entries(JSON.parse(feedback.customData)).map(([key, value]) => ( + {value as string} + ))} + + ))} + +
+
+ )} +
+
+ ); +} diff --git a/src/app/(protected)/project/[id]/settings/page.tsx b/src/app/(protected)/project/[id]/settings/page.tsx new file mode 100644 index 0000000..537af81 --- /dev/null +++ b/src/app/(protected)/project/[id]/settings/page.tsx @@ -0,0 +1,15 @@ +import ProjectSettings from "@/components/app/ProjectSettings/ProjectSettings"; +import { validateRequest } from "@/lib/auth"; +import prisma from "@/lib/db"; + +export default async function Page({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const { user } = await validateRequest(); + const project = await prisma.project.findFirst({ + where: { id, userId: user!.id }, + }); + if (!project) { + return

Project not found

; + } + return ; +} \ No newline at end of file diff --git a/src/app/api/feedback/[projectId]/route.ts b/src/app/api/feedback/[projectId]/route.ts new file mode 100644 index 0000000..e36693e --- /dev/null +++ b/src/app/api/feedback/[projectId]/route.ts @@ -0,0 +1,70 @@ +import prisma from '@/lib/db'; + +export const dynamic = 'force-dynamic'; + +export async function POST(request: Request, { params }: { params: { projectId: string } }) { + const { projectId } = params; + const body = await request.json(); + const queryProject = await prisma.project.findFirst({ + where: { + id: projectId, + }, + }); + if (!queryProject) { + return Response.json({ success: false, error: 'Project not found' }, { status: 404 }); + } + + // Convert customKeys to regular array and add message + const customKeys = [...queryProject.customData, 'message']; + const bodyKeys = Object.keys(body); + console.log(bodyKeys); + + // Find missing required keys (keys that should be in body but aren't) + const keysLeft = customKeys.filter((key) => !bodyKeys.includes(key)); + console.log(keysLeft); + + // Find invalid keys (keys in body that aren't allowed) + const invalidKeys = bodyKeys.filter((key) => !customKeys.includes(key)); + console.log(invalidKeys); + + if (keysLeft.length || invalidKeys.length) { + return Response.json( + { + success: false, + error: `Invalid keys: ${invalidKeys.join(', ')}, keys left: ${keysLeft.join(', ')}`, + }, + { status: 400 } + ); + } + // check if all values of the keys are strings. this will prevent + // any type of injection or unexpected behavior. + const invalidValues = Object.entries(body).filter(([key, value]) => typeof value !== 'string'); + if (invalidValues.length) { + return Response.json( + { + success: false, + error: `Invalid values for keys: ${invalidValues + .map(([key]) => key) + .join(', ')}. Make sure it is a string.`, + }, + { status: 400 } + ); + } + + const noMessageBody = Object.fromEntries( + Object.entries(body).filter(([key]) => key !== 'message') + ); + await prisma.feedback.create({ + data: { + message: body.message, + customData: JSON.stringify(noMessageBody), + project: { + connect: { + id: projectId, + }, + }, + }, + }); + + return Response.json({ success: true }); +} diff --git a/src/app/globals.css b/src/app/globals.css index 010b5af..396d1cd 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -2,79 +2,85 @@ @tailwind components; @tailwind utilities; - @layer base { - :root { - --background: 220 23.077% 94.902%; /* base */ - --foreground: 233.793 16.022% 35.490%; /* text */ +@layer base { + :root { + --background: 220 23.077% 94.902%; /* base */ + --foreground: 233.793 16.022% 35.49%; /* text */ - --muted: 222.857 15.909% 82.745%; /* surface0 */ - --muted-foreground: 233.333 12.796% 41.373%; /* subtext1 */ + --muted: 222.857 15.909% 82.745%; /* surface0 */ + --muted-foreground: 233.333 12.796% 41.373%; /* subtext1 */ - --popover: 220 23.077% 94.902%; /* base */ - --popover-foreground: 233.793 16.022% 35.490%; /* text */ + --popover: 220 23.077% 94.902%; /* base */ + --popover-foreground: 233.793 16.022% 35.49%; /* text */ - --card: 220 23.077% 94.902%; /* base */ - --card-foreground: 233.793 16.022% 35.490%; /* text */ + --card: 220 23.077% 94.902%; /* base */ + --card-foreground: 233.793 16.022% 35.49%; /* text */ - --border: 225 13.559% 76.863%; /* surface1 */ - --input: 225 13.559% 76.863%; /* surface1 */ + --border: 225 13.559% 76.863%; /* surface1 */ + --input: 225 13.559% 76.863%; /* surface1 */ - --primary: 219.907 91.489% 53.922%; /* blue */ - --primary-foreground: 220 23.077% 94.902%; /* base */ + --primary: 219.907 91.489% 53.922%; /* blue */ + --primary-foreground: 220 23.077% 94.902%; /* base */ - --secondary: 222.857 15.909% 82.745%; /* surface0 */ - --secondary-foreground: 233.793 16.022% 35.490%; /* text */ + --secondary: 222.857 15.909% 82.745%; /* surface0 */ + --secondary-foreground: 233.793 16.022% 35.49%; /* text */ - --accent: 222.857 15.909% 82.745%; /* surface0 */ - --accent-foreground: 233.793 16.022% 35.490%; /* text */ + --accent: 222.857 15.909% 82.745%; /* surface0 */ + --accent-foreground: 233.793 16.022% 35.49%; /* text */ - --destructive: 347.077 86.667% 44.118%; /* red */ - --destructive-foreground: 220 21.951% 91.961%; /* mantle */ + --destructive: 347.077 86.667% 44.118%; /* red */ + --destructive-foreground: 220 21.951% 91.961%; /* mantle */ - --ring: 233.793 16.022% 35.490%; /* text */ + --ring: 233.793 16.022% 35.49%; /* text */ - --radius: 0.5rem; - } - - .dark { - --background: 240 21.053% 14.902%; /* base */ - --foreground: 226.154 63.934% 88.039%; /* text */ - - --muted: 236.842 16.239% 22.941%; /* surface0 */ - --muted-foreground: 226.667 35.294% 80.000%; /* subtext1 */ - - --popover: 240 21.053% 14.902%; /* base */ - --popover-foreground: 226.154 63.934% 88.039%; /* text */ - - --card: 240 21.053% 14.902%; /* base */ - --card-foreground: 226.154 63.934% 88.039%; /* text */ - - --border: 234.286 13.208% 31.176%; /* surface1 */ - --input: 234.286 13.208% 31.176%; /* surface1 */ - - --primary: 217.168 91.870% 75.882%; /* blue */ - --primary-foreground: 240 21.053% 14.902%; /* base */ - - --secondary: 236.842 16.239% 22.941%; /* surface0 */ - --secondary-foreground: 226.154 63.934% 88.039%; /* text */ - - --accent: 236.842 16.239% 22.941%; /* surface0 */ - --accent-foreground: 226.154 63.934% 88.039%; /* text */ - - --destructive: 343.269 81.250% 74.902%; /* red */ - --destructive-foreground: 240 21.311% 11.961%; /* mantle */ - - --ring: 226.154 63.934% 88.039%; /* text */ - - --radius: 0.5rem; - } + --radius: 0.5rem; } - @layer base { - * { - @apply border-border; - } - body { - @apply bg-background text-foreground; - } - } \ No newline at end of file + .dark { + --background: 240 21.053% 14.902%; /* base */ + --foreground: 226.154 63.934% 88.039%; /* text */ + + --muted: 236.842 16.239% 22.941%; /* surface0 */ + --muted-foreground: 226.667 35.294% 80%; /* subtext1 */ + + --popover: 240 21.053% 14.902%; /* base */ + --popover-foreground: 226.154 63.934% 88.039%; /* text */ + + --card: 240 21.053% 14.902%; /* base */ + --card-foreground: 226.154 63.934% 88.039%; /* text */ + + --border: 234.286 13.208% 31.176%; /* surface1 */ + --input: 234.286 13.208% 31.176%; /* surface1 */ + + --primary: 217.168 91.87% 75.882%; /* blue */ + --primary-foreground: 240 21.053% 14.902%; /* base */ + + --secondary: 236.842 16.239% 22.941%; /* surface0 */ + --secondary-foreground: 226.154 63.934% 88.039%; /* text */ + + --accent: 236.842 16.239% 22.941%; /* surface0 */ + --accent-foreground: 226.154 63.934% 88.039%; /* text */ + + --destructive: 343.269 81.25% 74.902%; /* red */ + --destructive-foreground: 240 21.311% 11.961%; /* mantle */ + + --ring: 226.154 63.934% 88.039%; /* text */ + + --radius: 0.5rem; + } +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } +} +h1 { + @apply scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl; +} +h2 { + @apply scroll-m-20 pb-2 text-3xl font-semibold tracking-tight first:mt-0; +} \ No newline at end of file diff --git a/src/components/app/NavBar/NavBar.tsx b/src/components/app/NavBar/NavBar.tsx index 9bcd690..8b11e85 100644 --- a/src/components/app/NavBar/NavBar.tsx +++ b/src/components/app/NavBar/NavBar.tsx @@ -18,7 +18,8 @@ import { ThemeSwitcher } from "../ThemeSwitcher/ThemeSwitcher" export const links = [ { href: '/', name: 'Home' }, - { href: '/dashboard', name: 'Dashboard' } + { href: '/dashboard', name: 'Dashboard' }, + { href: '/create', name: 'Create' }, ] function NavbarLinks() { diff --git a/src/components/app/ProjectCard/ProjectCard.tsx b/src/components/app/ProjectCard/ProjectCard.tsx index 085a220..ec467cc 100644 --- a/src/components/app/ProjectCard/ProjectCard.tsx +++ b/src/components/app/ProjectCard/ProjectCard.tsx @@ -1,11 +1,11 @@ -import { dummyData } from '@/app/(protected)/dashboard/page'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'; import { Skeleton } from '@/components/ui/skeleton'; +import type { Project } from '@prisma/client'; import { Eye, Github } from 'lucide-react'; import Link from 'next/link'; -export default function ProjectCard(props: (typeof dummyData)[0]) { +export default function ProjectCard(props: Project) { return ( diff --git a/src/components/app/ProjectSettings/ProjectSettings.tsx b/src/components/app/ProjectSettings/ProjectSettings.tsx new file mode 100644 index 0000000..e71c8db --- /dev/null +++ b/src/components/app/ProjectSettings/ProjectSettings.tsx @@ -0,0 +1,222 @@ +'use client'; + +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import type { Project } from '@prisma/client'; +import { UniversalForm } from '../UniversalForm/UniversalForm'; +import { customData, editProject, ratelimitChange } from '@/lib/forms/actions'; +import { bodyGen, bodyGenNoIdent } from '@/lib/bodyGen'; +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator, +} from '@/components/ui/breadcrumb'; + +export default function ProjectSettings(project: Project) { + const url = `https://${window.location.hostname}/api/feedback/${project.id}`; + return ( +
+ + + + Dashboard + + + + {project.name} + + + + Settings + + + +
+

Project Settings

+

Manage your project configuration and preferences

+
+ + + + Project + Github + API + + + +
+ + + Project Details + Basic information about your project + + + + + + + + Custom Data + Custom fields to store additional data + + + + + +
+
+ + +

Soon™

+
+ + + + + Making a Request + Instructions on how to make an API request + + +
+

Endpoint

+ + {url} + +
+
+

Method

+

POST

+
+ {/*
+

Headers

+ + {`Content-Type: application/json + Authorization: Bearer YOUR_API_KEY`} + +
*/} +
+

Body

+ + {bodyGen(project.customData)} + +
+
+

Example Request (cURL)

+ + {stripIndents`curl -X POST \\ + -H "Content-Type: application/json" \\ + -H "Authorization: Bearer YOUR_API_KEY" \\ + -d '${bodyGenNoIdent(project.customData)}' \\ + ${url}`} + +
+
+
+ + + Rate limiting + + Manage your API rate limits. Not implemented but you can change it + + + + + + +
+
+
+ ); +} + +function stripIndents(strings: TemplateStringsArray, ...values: any[]) { + const fullString = strings.reduce((accumulator, str, i) => { + const value = values[i] ? values[i] : ''; + return accumulator + str + value; + }, ''); + + const lines = fullString.split('\n'); + + // Find minimum indentation level (excluding empty lines) + const minIndent = lines + .filter((line) => line.trim().length > 0) + .reduce((min, line) => { + const indent = line.match(/^\s*/)![0].length; + return indent < min ? indent : min; + }, Infinity); + + // Apply minimum indent + 2 spaces to each line + return lines + .map((line) => line.trim()) + .map((line) => (line ? ' '.repeat(minIndent + 2) + line : line)) + .join('\n') + .trim(); +} diff --git a/src/components/app/UniversalForm/UniversalForm.tsx b/src/components/app/UniversalForm/UniversalForm.tsx new file mode 100644 index 0000000..0da7448 --- /dev/null +++ b/src/components/app/UniversalForm/UniversalForm.tsx @@ -0,0 +1,88 @@ +// props to claude for helping out with typescript tomfoolery +// copyleft srizan tho +'use client'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { Path, PathValue, useForm } from 'react-hook-form'; +import { Button } from '@/components/ui/button'; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form'; +import { Input } from '@/components/ui/input'; +import { z } from 'zod'; +import type { UniversalFormProps } from './types'; +import { customDataSchema, projectSettingsSchema, ratelimitChangeSchema } from './zod'; +import SubmitButton from '../SubmitButton/SubmitButton'; +import { useFormState } from 'react-dom'; +import React from 'react'; +import { toast } from 'sonner'; + +export const schemaDb = [ + { name: 'projectSettings', zod: projectSettingsSchema }, + { name: 'ratelimitChange', zod: ratelimitChangeSchema }, + { name: 'customData', zod: customDataSchema }, +] as const; + +export function UniversalForm({ + fields, + schemaName, + action, + defaultValues, + submitText = 'Submit', +}: UniversalFormProps) { + const [state, formAction] = useFormState(action, null); + const schema = schemaDb.find((s) => s.name === schemaName)?.zod; + + if (!schema) { + throw new Error(`Schema "${schemaName}" not found`); + } + + const form = useForm>({ + resolver: zodResolver(schema), + defaultValues: (defaultValues || {}) as z.infer, + }); + + // pretend nothing is happening on here + React.useEffect(() => { + // @ts-ignore + if (state && !state.success) { + // @ts-ignore + toast.error(state.error); + } + }, [state]); + + return ( +
+ + {fields.map((field) => ( + >} + defaultValue={field.value as PathValue, Path>>} + render={({ field: formField }) => ( + + {field.type !== 'hidden' && {field.label}} + + + + {field.description && {field.description}} + + + )} + /> + ))} + + + + ); +} diff --git a/src/components/app/UniversalForm/types.ts b/src/components/app/UniversalForm/types.ts new file mode 100644 index 0000000..2321e4e --- /dev/null +++ b/src/components/app/UniversalForm/types.ts @@ -0,0 +1,20 @@ +import { z } from 'zod'; +import { HTMLInputTypeAttribute } from 'react'; +import { schemaDb } from './UniversalForm'; + +export type FormFieldConfig = { + name: string; + label: string; + type?: HTMLInputTypeAttribute; + placeholder?: string; + description?: string; + value?: string; +}; + +export type UniversalFormProps = { + fields: FormFieldConfig[]; + schemaName: typeof schemaDb[number]['name']; + action: (prev: any, formData: FormData) => void; + defaultValues?: Partial>; + submitText?: string; +}; diff --git a/src/components/app/UniversalForm/zod.ts b/src/components/app/UniversalForm/zod.ts new file mode 100644 index 0000000..fd37724 --- /dev/null +++ b/src/components/app/UniversalForm/zod.ts @@ -0,0 +1,18 @@ +import { z } from "zod"; + +export const projectSettingsSchema = z.object({ + id: z.string().nonempty(), + name: z.string().nonempty(), + description: z.string().nonempty(), +}) + +export const ratelimitChangeSchema = z.object({ + id: z.string().nonempty(), + requests: z.string().nonempty().transform((val) => parseInt(val, 10)), + duration: z.string().nonempty().transform((val) => parseInt(val, 10)) +}) + +export const customDataSchema = z.object({ + id: z.string().nonempty(), + data: z.string().nonempty() +}) \ No newline at end of file diff --git a/src/components/ui/breadcrumb.tsx b/src/components/ui/breadcrumb.tsx new file mode 100644 index 0000000..60e6c96 --- /dev/null +++ b/src/components/ui/breadcrumb.tsx @@ -0,0 +1,115 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { ChevronRight, MoreHorizontal } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Breadcrumb = React.forwardRef< + HTMLElement, + React.ComponentPropsWithoutRef<"nav"> & { + separator?: React.ReactNode + } +>(({ ...props }, ref) =>