commit 788b95212784a527bd5943c03141830bf41c6992 Author: Christopher Arraya Date: Sat Nov 18 21:09:24 2023 -0500 initial commit diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..bffb357 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": "next/core-web-vitals" +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fd3dbb5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,36 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js +.yarn/install-state.gz + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env*.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/README.md b/README.md new file mode 100644 index 0000000..9fc1e29 --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +# Skalara, Inc. diff --git a/app/auth/callback/route.ts b/app/auth/callback/route.ts new file mode 100644 index 0000000..962e4e2 --- /dev/null +++ b/app/auth/callback/route.ts @@ -0,0 +1,21 @@ +import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs"; +import { cookies } from "next/headers"; +import { NextResponse } from "next/server"; + +import type { NextRequest } from "next/server"; +import type { Database } from "@/types/supabase"; + +export async function GET(request: NextRequest) { + const requestUrl = new URL(request.url); + const code = requestUrl.searchParams.get("code"); + + if (code) { + const cookieStore = cookies(); + const supabase = createRouteHandlerClient({ + cookies: () => cookieStore, + }); + await supabase.auth.exchangeCodeForSession(code); + } + + return NextResponse.redirect(requestUrl.origin); +} diff --git a/app/auth/login/route.ts b/app/auth/login/route.ts new file mode 100644 index 0000000..3449870 --- /dev/null +++ b/app/auth/login/route.ts @@ -0,0 +1,26 @@ +import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs"; +import { cookies } from "next/headers"; +import { NextResponse } from "next/server"; + +export const dynamic = "force-dynamic"; + +export async function POST(request: Request) { + const formData = await request.json(); + const email = String(formData.email); + const password = String(formData.password); + const supabase = createRouteHandlerClient({ cookies }); + + const { error } = await supabase.auth.signInWithPassword({ + email, + password, + }); + + if (error) { + return NextResponse.json({ error }, { status: 401 }); + } + + return NextResponse.json( + { message: "Successfully signed in." }, + { status: 200 } + ); +} diff --git a/app/auth/logout/route.ts b/app/auth/logout/route.ts new file mode 100644 index 0000000..f6b2d17 --- /dev/null +++ b/app/auth/logout/route.ts @@ -0,0 +1,19 @@ +import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs"; +import { cookies } from "next/headers"; +import { NextResponse } from "next/server"; + +import type { Database } from "@/types/supabase"; + +export async function POST(request: Request) { + const requestUrl = new URL(request.url); + const cookieStore = cookies(); + const supabase = createRouteHandlerClient({ + cookies: () => cookieStore, + }); + + await supabase.auth.signOut(); + + return NextResponse.redirect(`${requestUrl.origin}/auth`, { + status: 301, + }); +} diff --git a/app/auth/page.tsx b/app/auth/page.tsx new file mode 100644 index 0000000..2e531c9 --- /dev/null +++ b/app/auth/page.tsx @@ -0,0 +1,223 @@ +"use client"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useRouter } from "next/navigation"; +import { useToast } from "@/components/ui/use-toast"; +import { useForm } from "react-hook-form"; +import * as z from "zod"; + +const authSchema = z.object({ + email: z.string().email("Please enter a valid email"), + password: z.string().min(8, "Password must be at least 8 characters"), +}); + +export default function Auth() { + const { push } = useRouter(); + const { toast } = useToast(); + const form = useForm>({ + resolver: zodResolver(authSchema), + defaultValues: { + email: "", + password: "", + }, + }); + + async function handleSignup(values: z.infer) { + try { + const res = await fetch("/auth/signup", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(values), + }); + + const { error, message } = await res.json(); + + if (error) { + toast({ + variant: "destructive", + title: "Uh oh! Something went wrong.", + description: error, + }); + throw new Error(error); + } else if (message) { + toast({ + title: "Check your email!", + description: message, + }); + } + } catch (error) { + console.error(error); + } + } + + async function handleLogin(values: z.infer) { + try { + const res = await fetch("/auth/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(values), + }); + + const { error } = await res.json(); + + if (error) { + toast({ + variant: "destructive", + title: "Uh oh! Something went wrong.", + description: error.message, + }); + throw new Error(error); + } + + push("/"); + } catch (error) { + console.error(error); + } + } + + return ( +
+ + + Login + Signup + + + + + Login to your account + + Enter your email and password below to login. + + + +
+ + + ( + + Email + + + + + + )} + /> + ( + + Password + + + + + + )} + /> + + + + +
+ +
+
+ + + + Create your account + + Enter your email and password below to create an account. + + +
+ + + ( + + Email + + + + + + )} + /> + ( + + Password + + + + + + )} + /> + + + + +
+ +
+
+
+
+ ); +} diff --git a/app/auth/signup/route.ts b/app/auth/signup/route.ts new file mode 100644 index 0000000..528c391 --- /dev/null +++ b/app/auth/signup/route.ts @@ -0,0 +1,34 @@ +import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs"; +import { cookies } from "next/headers"; +import { NextResponse } from "next/server"; + +export const dynamic = "force-dynamic"; + +export async function POST(request: Request) { + const requestUrl = new URL(request.url); + const formData = await request.json(); + const email = String(formData.email); + const password = String(formData.password); + const supabase = createRouteHandlerClient({ cookies }); + + const { error } = await supabase.auth.signUp({ + email, + password, + options: { + emailRedirectTo: `${requestUrl.origin}/auth/callback`, + }, + }); + + if (error) { + console.error(error); + return NextResponse.json( + { error: "Could not authenticate user" }, + { status: 401 } + ); + } + + return NextResponse.json( + { message: "Check email to continue sign in process" }, + { status: 301 } + ); +} diff --git a/app/chat/[taskID]/route.ts b/app/chat/[taskID]/route.ts new file mode 100644 index 0000000..a9cf9e7 --- /dev/null +++ b/app/chat/[taskID]/route.ts @@ -0,0 +1,46 @@ +import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs"; +import { cookies } from "next/headers"; +import { NextResponse, NextRequest } from "next/server"; +import { Database } from "@/types/supabase"; +import prisma from "@/lib/prisma"; + +async function getSession(supabase: any) { + const { + data: { session }, + } = await supabase.auth.getSession(); + return session; +} + +export async function GET( + req: NextRequest, + { params: { taskID } }: { params: { taskID: string } } +) { + const supabase = createRouteHandlerClient({ cookies }); + const session = await getSession(supabase); + + if (!session) return NextResponse.redirect("/auth"); + + try { + const messages = await prisma.message.findMany({ + where: { + task_id: BigInt(taskID), + }, + orderBy: { + created_at: "asc", + }, + }); + + console.log(messages); + + const res = messages.map((message) => ({ + ...message, + id: String(message.id), + task_id: String(message.task_id), + })); + + return NextResponse.json({ messages: res }, { status: 200 }); + } catch (err) { + console.error(err); + return NextResponse.json({ error: err }, { status: 500 }); + } +} diff --git a/app/chat/add/route.ts b/app/chat/add/route.ts new file mode 100644 index 0000000..462ce4c --- /dev/null +++ b/app/chat/add/route.ts @@ -0,0 +1,48 @@ +import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs"; +import { cookies } from "next/headers"; +import { NextResponse, NextRequest } from "next/server"; +import { Database } from "@/types/supabase"; +import prisma from "@/lib/prisma"; + +async function getSession(supabase: any) { + const { + data: { session }, + } = await supabase.auth.getSession(); + return session; +} + +export async function POST(req: NextRequest) { + const supabase = createRouteHandlerClient({ cookies }); + const session = await getSession(supabase); + + if (!session) return NextResponse.redirect("/auth"); + + try { + const formData = await req.json(); + const role = String(formData.role); + const message = String(formData.content); + const task_id = String(formData.task_id); + console.log("TASK_ID IN CHATADD ===> ", task_id); + + console.log(role, message); + + const res = await prisma.message.create({ + data: { + content: message, + role: role, + task_id: BigInt(task_id), + }, + }); + + const res_data = { + ...res, + id: String(res.id), + task_id: String(res.task_id), + }; + + return NextResponse.json({ message: res_data }, { status: 200 }); + } catch (err) { + console.error(err); + return NextResponse.json({ error: err }, { status: 500 }); + } +} diff --git a/app/chat/route.ts b/app/chat/route.ts new file mode 100644 index 0000000..aebfff4 --- /dev/null +++ b/app/chat/route.ts @@ -0,0 +1,61 @@ +// app/api/chat/route.ts + +import OpenAI from "openai"; +import { OpenAIStream, StreamingTextResponse } from "ai"; +import { NextResponse } from "next/server"; + +// Optional, but recommended: run on the edge runtime. +// See https://vercel.com/docs/concepts/functions/edge-functions +export const runtime = "edge"; + +const openai = new OpenAI({ + apiKey: process.env.OPENAI_API_KEY!, +}); + +export async function POST(req: Request) { + // Extract the `messages` from the body of the request + try { + const { messages, ...body } = await req.json(); + + // console.log("BODY===>", body); + console.log("HERE ARE THE MESSAGES ===>", messages); + + const prompt = `You are a chatbot that helps users with questions specific to project tasks. +- Project Details: + - Name: ${body.projectInfo.name} + - Description: ${body.projectInfo.description} + - Tech Stack: ${body.projectInfo.stack.join(", ")} + +- Feature Context: + - Name: ${body.featureInfo.name} + - Description: ${body.featureInfo.description} + +- Task Context: + - Name: ${body.taskInfo.task_name} + - Description: ${body.taskInfo.task_description} + +OPERATION GUIDELINES: + +1. Provide information and answer questions specifically related to the project, feature, or task context provided. +2. Do not give generic answers; tailor responses based on the given context.`; + + messages.unshift({ role: "system", content: prompt }); + console.log("MESSAGES ===>", messages); + + // Request the OpenAI API for the response based on the prompt + const response = await openai.chat.completions.create({ + model: "gpt-3.5-turbo-16k", + stream: true, + messages: messages, + }); + + // Convert the response into a friendly text-stream + const stream = OpenAIStream(response); + + // Respond with the stream + return new StreamingTextResponse(stream); + } catch (err) { + console.error(err); + return NextResponse.json({ err }, { status: 500 }); + } +} diff --git a/app/dashboard/layout.tsx b/app/dashboard/layout.tsx new file mode 100644 index 0000000..b64f593 --- /dev/null +++ b/app/dashboard/layout.tsx @@ -0,0 +1,14 @@ +import { WorkspaceSelector } from "@/components/workspace-selector"; + +export default function DashboardLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+ + {children} +
+ ); +} diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx new file mode 100644 index 0000000..97210bc --- /dev/null +++ b/app/dashboard/page.tsx @@ -0,0 +1,7 @@ +export default function Dashboard() { + return ( +
+

Dashboard

+
+ ); +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..773bdf9 --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,44 @@ +import type { Metadata } from "next"; +import { Inter as FontSans } from "next/font/google"; +import "@/styles/globals.css"; +import { cn } from "@/lib/utils"; +import { ThemeProvider } from "@/components/theme-provider"; +import { Toaster } from "@/components/ui/toaster"; + +export const fontSans = FontSans({ + subsets: ["latin"], + variable: "--font-sans", +}); + +export const metadata: Metadata = { + title: "Skalara", + description: "Automated project management for indie developers.", +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + + + + {children} + + + + + ); +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..757159c --- /dev/null +++ b/app/page.tsx @@ -0,0 +1,105 @@ +import Logout from "@/components/logout"; +import GridPattern from "@/components/magicui/grid-pattern"; +import { ThemeToggle } from "@/components/theme-toggle"; +import { Button, buttonVariants } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { cn } from "@/lib/utils"; +import { createServerComponentClient } from "@supabase/auth-helpers-nextjs"; +import { cookies } from "next/headers"; +import Link from "next/link"; + +export const dynamic = "force-dynamic"; + +export default async function Home() { + const supabase = createServerComponentClient({ cookies }); + + const { + data: { user }, + } = await supabase.auth.getUser(); + + if (!user) { + console.log("BOO no user"); + } + + return ( +
+
+ + +
+
+

+ Simple. Intelligent. Automated. +

+

+ AI-powered project management for indie developers. +

+
+ + +
+
+ +
+
+
+ ); +} diff --git a/app/w/[workspaceID]/layout.tsx b/app/w/[workspaceID]/layout.tsx new file mode 100644 index 0000000..c8c112d --- /dev/null +++ b/app/w/[workspaceID]/layout.tsx @@ -0,0 +1,15 @@ +import { WorkspaceSelector } from "@/components/workspace-selector"; +export default function WorkspaceLayout({ + children, + params: { workspaceID }, +}: { + children: React.ReactNode; + params: { workspaceID: string }; +}) { + return ( +
+ + {children} +
+ ); +} diff --git a/app/w/[workspaceID]/p/[projectID]/features/add/route.ts b/app/w/[workspaceID]/p/[projectID]/features/add/route.ts new file mode 100644 index 0000000..8039e5b --- /dev/null +++ b/app/w/[workspaceID]/p/[projectID]/features/add/route.ts @@ -0,0 +1,39 @@ +import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs"; +import { cookies } from "next/headers"; +import { NextResponse, NextRequest } from "next/server"; +import { Database } from "@/types/supabase"; +import prisma from "@/lib/prisma"; + +async function getSession(supabase: any) { + const { + data: { session }, + } = await supabase.auth.getSession(); + return session; +} + +// create feature +export async function POST( + req: NextRequest, + { params: { projectID } }: { params: { projectID: string } } +) { + const supabase = createRouteHandlerClient({ cookies }); + const session = await getSession(supabase); + + if (!session) return NextResponse.redirect("/auth"); + + try { + // TODO: add feature + const req_data = await req.json(); + const features = req_data.features; + console.log("FEATURES ===>", features); + + const added_features = await prisma.feature.createMany({ + data: features, + }); + + return NextResponse.json({ message: "added_features" }, { status: 200 }); + } catch (err) { + console.error(err); + return NextResponse.json({ error: err }, { status: 500 }); + } +} diff --git a/app/w/[workspaceID]/p/[projectID]/features/gen/deps/route.ts b/app/w/[workspaceID]/p/[projectID]/features/gen/deps/route.ts new file mode 100644 index 0000000..958d963 --- /dev/null +++ b/app/w/[workspaceID]/p/[projectID]/features/gen/deps/route.ts @@ -0,0 +1,109 @@ +import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs"; +import { cookies } from "next/headers"; +import { NextResponse, NextRequest } from "next/server"; +import { Database } from "@/types/supabase"; +import prisma from "@/lib/prisma"; +import { generateFeatureDeps, generateFeatures } from "@/lib/prompts"; +import openai from "@/lib/openai"; +import { randomUUID } from "crypto"; +import { Feature } from "@/types"; + +import { completion } from "zod-gpt"; +import * as z from "zod"; + +async function getSession(supabase: any) { + const { + data: { session }, + } = await supabase.auth.getSession(); + return session; +} + +// generate feature dependencies +export async function POST(req: NextRequest) { + const supabase = createRouteHandlerClient({ cookies }); + const session = await getSession(supabase); + + if (!session) return NextResponse.redirect("/auth"); + + try { + // TODO: generate features for project + const req_data = await req.json(); + const project_name = String(req_data.project_name); + const project_description = String(req_data.project_description); + const project_stack = req_data.project_stack; + const features: any = req_data.features; + + const featureUids: readonly [string, ...string[]] = features.map( + (feature: any) => feature.id.toString() + ) as [string, ...string[]]; + + console.log("FEATURE UIDS ===>", featureUids); + + const feature_dep_prompt = generateFeatureDeps( + project_name, + project_description, + project_stack, + features + ); + + const res = await completion(openai, feature_dep_prompt, { + schema: z.object({ + dependencies: z + .array( + z.object({ + uid: z.enum(featureUids).describe("The ID of this feature"), + dependencies: z + .array( + z.object({ + uid: z + .enum(featureUids) + .describe( + "The ID of the feature this feature depends on" + ), + }) + ) + .describe("The ID of the dependencies of the feature"), + }) + ) + .describe("The dependencies of the features"), + }), + }); + + // add dependencies to feature_dependencies table + const convertDependencies = (dependencies: any) => { + let newDependencies: any[] = []; + + dependencies.forEach((dep: any) => { + if (dep.dependencies.length === 0) { + return; // Skip if no dependencies + } + + dep.dependencies.forEach((subDep: any) => { + newDependencies.push({ + feature_id: BigInt(dep.uid), + dependency_id: BigInt(subDep.uid), + }); + }); + }); + + return newDependencies; + }; + + const featureDependencies = convertDependencies(res.data.dependencies); + + // add dependencies to feature_dependencies table + const deps = await prisma.feature_dependencies.createMany({ + data: featureDependencies, + }); + + console.log(deps); + + return NextResponse.json( + { dependencies: res.data.dependencies }, + { status: 200 } + ); + } catch (err) { + console.error(err); + return NextResponse.json({ error: err }, { status: 500 }); + } +} diff --git a/app/w/[workspaceID]/p/[projectID]/features/gen/route.ts b/app/w/[workspaceID]/p/[projectID]/features/gen/route.ts new file mode 100644 index 0000000..8b99961 --- /dev/null +++ b/app/w/[workspaceID]/p/[projectID]/features/gen/route.ts @@ -0,0 +1,76 @@ +import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs"; +import { cookies } from "next/headers"; +import { NextResponse, NextRequest } from "next/server"; +import { Database } from "@/types/supabase"; +import prisma from "@/lib/prisma"; +import { generateFeatures } from "@/lib/prompts"; +import openai from "@/lib/openai"; +import { randomUUID } from "crypto"; + +import { completion } from "zod-gpt"; +import * as z from "zod"; + +const MIN_FEATURES_PER_PROJECT = 6; +const MAX_FEATURES_PER_PROJECT = 12; + +async function getSession(supabase: any) { + const { + data: { session }, + } = await supabase.auth.getSession(); + return session; +} + +// generate features for project +export async function POST(req: NextRequest) { + const supabase = createRouteHandlerClient({ cookies }); + const session = await getSession(supabase); + + if (!session) return NextResponse.redirect("/auth"); + + try { + // TODO: generate features for project + const req_data = await req.json(); + const project_name = String(req_data.project_name); + const project_description = String(req_data.project_description); + const project_stack = req_data.project_stack; + const qa = req_data.qa; + + console.log(project_name, project_description, project_stack, qa); + + const feature_gen_prompt = generateFeatures( + project_name, + project_description, + project_stack, + qa + ); + + const res = await completion(openai, feature_gen_prompt, { + schema: z.object({ + features: z + .array( + z.object({ + name: z.string().describe("The name of the feature"), + description: z + .string() + .describe("The description of the feature"), + }) + ) + .min(MIN_FEATURES_PER_PROJECT) + .max(MAX_FEATURES_PER_PROJECT), + }), + }); + + const features = res.data.features.map((feature) => ({ + ...feature, + uid: String(randomUUID()), + project_id: String(req_data.project_id), + })); + + console.log(features); + + return NextResponse.json({ features }, { status: 200 }); + } catch (err) { + console.error(err); + return NextResponse.json({ error: err }, { status: 500 }); + } +} diff --git a/app/w/[workspaceID]/p/[projectID]/features/gen/tasks/route.ts b/app/w/[workspaceID]/p/[projectID]/features/gen/tasks/route.ts new file mode 100644 index 0000000..2e88228 --- /dev/null +++ b/app/w/[workspaceID]/p/[projectID]/features/gen/tasks/route.ts @@ -0,0 +1,113 @@ +import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs"; +import { cookies } from "next/headers"; +import { NextResponse, NextRequest } from "next/server"; +import { Database } from "@/types/supabase"; +import prisma from "@/lib/prisma"; +import { generateTasks } from "@/lib/prompts"; +import openai from "@/lib/openai"; + +import { completion } from "zod-gpt"; +import * as z from "zod"; + +const MIN_TASKS_PER_FEATURE = 6; +const MAX_TASKS_PER_FEATURE = 12; + +async function getSession(supabase: any) { + const { + data: { session }, + } = await supabase.auth.getSession(); + return session; +} + +// generate tasks for feature +export async function POST( + req: NextRequest, + { params: { projectID } }: { params: { projectID: string } } +) { + const supabase = createRouteHandlerClient({ cookies }); + const session = await getSession(supabase); + + if (!session) return NextResponse.redirect("/auth"); + + try { + const req_data = await req.json(); + const project_name = String(req_data.project_name); + const project_description = String(req_data.project_description); + const project_stack = req_data.project_stack; + + const features = await prisma.feature.findMany({ + where: { + project_id: BigInt(projectID), + }, + include: { + feature_dependencies_feature_dependencies_feature_idTofeature: true, + }, + }); + if (features.length === 0) { + console.error("none"); + return NextResponse.json({ error: "No features found" }, { status: 404 }); + } + const cumulative_tasks: any[] = []; + + for (const feature of features) { + // get all feature dependencies + const feature_deps = + feature.feature_dependencies_feature_dependencies_feature_idTofeature.map( + (dep) => { + const dependencies_of_feature = features.find( + (f) => f.id === dep.dependency_id + ); + + return dependencies_of_feature; + } + ); + + console.log("CURRENT FEATURE ===>", feature); + console.log("FEATURE DEPS ===>", feature_deps); + + const task_gen_prompt = generateTasks( + project_name, + project_description, + project_stack, + feature, + feature_deps + ); + + const res = await completion(openai, task_gen_prompt, { + schema: z.object({ + tasks: z + .array( + z.object({ + name: z.string().describe("The task name"), + description: z.string().describe("The task description"), + priority: z + .enum(["low", "medium", "high"]) + .describe("The task priority"), + order: z + .number() + .describe( + "The order in which the task should be implemented in relation to other tasks" + ), + }) + ) + .min(MIN_TASKS_PER_FEATURE) + .max(MAX_TASKS_PER_FEATURE), + }), + }); + + const tasks = res.data.tasks; + console.log("TASKS ===>", tasks); + // add feature_id to tasks + const tasks_with_feature_id = tasks.map((task: any) => { + return { ...task, feature_id: String(feature.id), status: "backlog" }; + }); + + cumulative_tasks.push(...tasks_with_feature_id); + } + + return NextResponse.json({ tasks: cumulative_tasks }, { status: 200 }); + } catch (err) { + console.error(err); + return NextResponse.json({ error: err }, { status: 500 }); + } +} diff --git a/app/w/[workspaceID]/p/[projectID]/features/route.ts b/app/w/[workspaceID]/p/[projectID]/features/route.ts new file mode 100644 index 0000000..ee4a46c --- /dev/null +++ b/app/w/[workspaceID]/p/[projectID]/features/route.ts @@ -0,0 +1,50 @@ +import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs"; +import { cookies } from "next/headers"; +import { NextResponse, NextRequest } from "next/server"; +import { Database } from "@/types/supabase"; +import prisma from "@/lib/prisma"; + +async function getSession(supabase: any) { + const { + data: { session }, + } = await supabase.auth.getSession(); + return session; +} + +// get all features for a project +export async function GET( + req: NextRequest, + { + params: { projectID }, + }: { + params: { projectID: string }; + } +) { + const supabase = createRouteHandlerClient({ cookies }); + const session = await getSession(supabase); + + if (!session) return NextResponse.redirect("/auth"); + + try { + const features = await prisma.feature.findMany({ + where: { + project_id: BigInt(projectID), + }, + }); + + if (features.length === 0) { + console.log("none"); + return NextResponse.json({ error: "No features found" }, { status: 404 }); + } + const res = features.map((feature) => ({ + ...feature, + id: String(feature.id), + project_id: String(feature.project_id), + })); + + return NextResponse.json({ features: res }, { status: 200 }); + } catch (err) { + console.error(err); + return NextResponse.json({ error: err }, { status: 500 }); + } +} diff --git a/app/w/[workspaceID]/p/[projectID]/gen/route.ts b/app/w/[workspaceID]/p/[projectID]/gen/route.ts new file mode 100644 index 0000000..947aabd --- /dev/null +++ b/app/w/[workspaceID]/p/[projectID]/gen/route.ts @@ -0,0 +1,113 @@ +import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs"; +import { cookies } from "next/headers"; +import { NextResponse, NextRequest } from "next/server"; +import { Database } from "@/types/supabase"; +import prisma from "@/lib/prisma"; + +import * as z from "zod"; +import openai from "@/lib/openai"; +import { completion } from "zod-gpt"; +import { generateProjectQuestions } from "@/lib/prompts"; + +const MIN_QUESTIONS = 1; +const MAX_QUESTIONS = 3; + +type Question = { + question: string; + answer: string; +}; + +async function getSession(supabase: any) { + const { + data: { session }, + } = await supabase.auth.getSession(); + return session; +} + +// generate questions for user about their project +export async function GET( + req: NextRequest, + { params: { projectID } }: { params: { projectID: string } } +) { + const supabase = createRouteHandlerClient({ cookies }); + const session = await getSession(supabase); + + if (!session) return NextResponse.redirect("/auth"); + + try { + // TODO: generate questions for user about their project + // 1. get project name, description, and stack + console.log("PROJECT ID", projectID); + const project = await prisma.project.findUnique({ + where: { + id: BigInt(projectID), + }, + }); + + if (!project) { + return NextResponse.json({ error: "Project not found" }, { status: 404 }); + } + + const { name, description, stack } = project; + + // 2. generate questions + const question_prompt = generateProjectQuestions( + name, + description ? description : "", + stack + ); + + const res = await completion(openai, question_prompt, { + schema: z.object({ + questions: z + .array( + z.string().describe("A question to ask the user about the project") + ) + .min(MIN_QUESTIONS) + .max(MAX_QUESTIONS), + }), + }); + + console.log(res.data.questions); + + return NextResponse.json( + { questions: res.data.questions }, + { status: 200 } + ); + } catch (err) { + console.error(err); + return NextResponse.json({ error: err }, { status: 500 }); + } +} + +export async function PUT( + req: NextRequest, + { params: { projectID } }: { params: { projectID: string } } +) { + const supabase = createRouteHandlerClient({ cookies }); + const session = await getSession(supabase); + + if (!session) return NextResponse.redirect("/auth"); + + try { + const req_data = await req.json(); + const questions: Question[] = req_data; + + const res = await prisma.project.update({ + where: { + id: BigInt(projectID), + }, + data: { + questions, + }, + }); + + return NextResponse.json( + { message: "Completed successfully" }, + { status: 200 } + ); + } catch (err) { + console.error(err); + return NextResponse.json({ error: err }, { status: 500 }); + } +} diff --git a/app/w/[workspaceID]/p/[projectID]/layout.tsx b/app/w/[workspaceID]/p/[projectID]/layout.tsx new file mode 100644 index 0000000..b1d0323 --- /dev/null +++ b/app/w/[workspaceID]/p/[projectID]/layout.tsx @@ -0,0 +1,15 @@ +import { WorkspaceSidebar } from "@/components/workspace-sidebar"; +export default function WorkspaceLayout({ + children, + params: { workspaceID, projectID }, +}: { + children: React.ReactNode; + params: { workspaceID: string; projectID: string }; +}) { + return ( +
+ + {children} +
+ ); +} diff --git a/app/w/[workspaceID]/p/[projectID]/page.tsx b/app/w/[workspaceID]/p/[projectID]/page.tsx new file mode 100644 index 0000000..9981f67 --- /dev/null +++ b/app/w/[workspaceID]/p/[projectID]/page.tsx @@ -0,0 +1,124 @@ +import { createServerComponentClient } from "@supabase/auth-helpers-nextjs"; +import { Database } from "@/types/supabase"; +import { cookies } from "next/headers"; +import { redirect } from "next/navigation"; +import { NextResponse, NextRequest } from "next/server"; +import prisma from "@/lib/prisma"; +import { WorkspaceSidebar } from "@/components/workspace-sidebar"; +import { ThemeToggle } from "@/components/theme-toggle"; +import KanbanBoard from "@/components/kanban/board"; +import { Button } from "@/components/ui/button"; +import { GenerateProject } from "@/components/generate-project"; +import Tasks from "@/components/tasks"; + +async function getSession(supabase: any) { + const { + data: { session }, + } = await supabase.auth.getSession(); + return session; +} + +async function getProject(projectID: string) { + const supabase = createServerComponentClient({ cookies }); + const session = await getSession(supabase); + + if (!session) redirect("/auth"); + + try { + // prisma function to get project by projectID if user is part of the project + const project = await prisma.project.findFirst({ + where: { + id: BigInt(projectID), + profile_project: { + some: { + profile_id: session.user.id, + }, + }, + }, + include: { + task: true, + }, + }); + + if (!project) { + return NextResponse.json({ error: "not_found" }, { status: 404 }); + } + + const serialized_tasks = project.task.map((task) => ({ + ...task, + id: task.id.toString(), + project_id: task.project_id?.toString(), + feature_id: task.feature_id?.toString(), + })); + + const convertTaskBigIntsToString = (task: any) => { + return { + ...task, + id: task.id.toString(), + project_id: task.project_id.toString(), + feature_id: task.feature_id.toString(), + order: task.order.toString(), + }; + }; + + const res = { + ...project, + id: project.id.toString(), + workspace_id: project.workspace_id.toString(), + task: serialized_tasks.map(convertTaskBigIntsToString), + }; + + console.log("RES", project.task); + + return NextResponse.json(res, { status: 200 }); + } catch (err) { + console.error(err); + } +} + +export default async function Project({ + params: { workspaceID, projectID }, +}: { + params: { workspaceID: string; projectID: string }; +}) { + const raw_project = await getProject(projectID); + console.log("RAW PROJECT", raw_project); + if (raw_project?.status == 404) { + return ( +
+

Project not found.

+
+ ); + } + + if (!raw_project) { + return ( +
+

Something went wrong.

+
+ ); + } + + const project = await raw_project.json(); + + return ( +
+
+

{project.name}

+
+ + +
+
+ + {/* */} +
+ +
+
+ ); +} diff --git a/app/w/[workspaceID]/p/[projectID]/tasks/add/route.ts b/app/w/[workspaceID]/p/[projectID]/tasks/add/route.ts new file mode 100644 index 0000000..c01c94f --- /dev/null +++ b/app/w/[workspaceID]/p/[projectID]/tasks/add/route.ts @@ -0,0 +1,49 @@ +import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs"; +import { cookies } from "next/headers"; +import { NextResponse, NextRequest } from "next/server"; +import { Database } from "@/types/supabase"; +import prisma from "@/lib/prisma"; + +async function getSession(supabase: any) { + const { + data: { session }, + } = await supabase.auth.getSession(); + return session; +} + +// create task +export async function POST( + req: NextRequest, + { params: { projectID } }: { params: { projectID: string } } +) { + const supabase = createRouteHandlerClient({ cookies }); + const session = await getSession(supabase); + + if (!session) return NextResponse.redirect("/auth"); + + try { + // TODO: add task + const req_data = await req.json(); + const tasks = req_data.tasks; + + console.log("TASKS IN ADD TASKS ROUTE ===>", tasks); + + const tasks_with_project_id = tasks.map((task: any) => { + return { + ...task, + project_id: BigInt(projectID), + feature_id: BigInt(task.feature_id), + }; + }); + + await prisma.task.createMany({ + data: tasks_with_project_id, + skipDuplicates: true, + }); + + return NextResponse.json({ success: true }, { status: 200 }); + } catch (err) { + console.error(err); + return NextResponse.json({ error: err }, { status: 500 }); + } +} diff --git a/app/w/[workspaceID]/p/[projectID]/tasks/gen/route.ts b/app/w/[workspaceID]/p/[projectID]/tasks/gen/route.ts new file mode 100644 index 0000000..e69de29 diff --git a/app/w/[workspaceID]/p/route.ts b/app/w/[workspaceID]/p/route.ts new file mode 100644 index 0000000..3d43a57 --- /dev/null +++ b/app/w/[workspaceID]/p/route.ts @@ -0,0 +1,58 @@ +import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs"; +import { cookies } from "next/headers"; +import { NextResponse, NextRequest } from "next/server"; +import { Database } from "@/types/supabase"; +import prisma from "@/lib/prisma"; +import { ProjectResponse } from "@/types"; + +async function getSession(supabase: any) { + const { + data: { session }, + } = await supabase.auth.getSession(); + return session; +} + +// create project +export async function POST( + req: NextRequest, + { params: { workspaceID } }: { params: { workspaceID: string } } +) { + const supabase = createRouteHandlerClient({ cookies }); + const session = await getSession(supabase); + + if (!session) return NextResponse.redirect("/auth"); + + try { + const req_data = await req.json(); + const name = String(req_data.name); + const description = String(req_data.description); + const stack: string[] = req_data.stack; + + console.log(name, description, stack, workspaceID, session.user.id); + + const project = await prisma.project.create({ + data: { + name, + description, + stack, + workspace_id: BigInt(workspaceID), + profile_project: { + create: { + profile_id: session.user.id, + }, + }, + }, + }); + + const res: ProjectResponse = { + ...project, + id: String(project.id), + workspace_id: String(project.workspace_id), + }; + + return NextResponse.json({ project: res }, { status: 200 }); + } catch (err) { + console.error(err); + return NextResponse.json({ error: err }, { status: 500 }); + } +} diff --git a/app/w/[workspaceID]/page.tsx b/app/w/[workspaceID]/page.tsx new file mode 100644 index 0000000..0d9f1c0 --- /dev/null +++ b/app/w/[workspaceID]/page.tsx @@ -0,0 +1,68 @@ +import { createServerComponentClient } from "@supabase/auth-helpers-nextjs"; +import { Database } from "@/types/supabase"; +import { cookies } from "next/headers"; +import { redirect } from "next/navigation"; +import { NextResponse, NextRequest } from "next/server"; +import prisma from "@/lib/prisma"; +import { WorkspaceSidebar } from "@/components/workspace-sidebar"; + +async function getSession(supabase: any) { + const { + data: { session }, + } = await supabase.auth.getSession(); + return session; +} + +async function getWorkspace(workspaceID: string) { + const supabase = createServerComponentClient({ cookies }); + const session = await getSession(supabase); + + if (!session) redirect("/auth"); + + try { + // TODO: get workspace by workspaceID if user is part of the workspace + return NextResponse.json({}, { status: 200 }); + } catch (err: any) { + console.error(err); + return NextResponse.json( + { error: "Something went wrong." }, + { status: 500 } + ); + } +} + +export default async function Workspace({ + params: { workspaceID }, +}: { + params: { workspaceID: string }; +}) { + const raw_workspace = await getWorkspace(workspaceID); + const workspace = await raw_workspace.json(); + + if (workspace.error == "not_found") { + return ( +
+

Workspace not found.

+
+ ); + } else if (workspace.error == "unauthorized") { + return ( +
+

You are not a member of this workspace.

+
+ ); + } else if (workspace.error) { + return ( +
+

Something went wrong.

+
+ ); + } + + return ( +
+ +

Workspace Page

+
+ ); +} diff --git a/app/w/route.ts b/app/w/route.ts new file mode 100644 index 0000000..8dbec24 --- /dev/null +++ b/app/w/route.ts @@ -0,0 +1,49 @@ +import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs"; +import { cookies } from "next/headers"; +import { NextResponse, NextRequest } from "next/server"; +import { Database } from "@/types/supabase"; +import { WorkspaceResponse } from "@/types"; +import prisma from "@/lib/prisma"; + +async function getSession(supabase: any) { + const { + data: { session }, + } = await supabase.auth.getSession(); + return session; +} + +// create workspace +export async function POST(req: NextRequest) { + const supabase = createRouteHandlerClient({ cookies }); + const session = await getSession(supabase); + + if (!session) return NextResponse.redirect("/auth"); + + try { + const req_data = await req.json(); + const name = String(req_data.name); + const description = String(req_data.description); + + const workspace = await prisma.workspace.create({ + data: { + name, + description, + profile_workspace: { + create: { + profile_id: session.user.id, + }, + }, + }, + }); + + const res: WorkspaceResponse = { + ...workspace, + id: String(workspace.id), + }; + + return NextResponse.json({ workspace: res }, { status: 200 }); + } catch (err) { + console.error(err); + return NextResponse.json({ error: err }, { status: 500 }); + } +} diff --git a/components.json b/components.json new file mode 100644 index 0000000..7681c2f --- /dev/null +++ b/components.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "tailwind.config.js", + "css": "app/globals.css", + "baseColor": "slate", + "cssVariables": true + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils" + } +} \ No newline at end of file diff --git a/components/chat.tsx b/components/chat.tsx new file mode 100644 index 0000000..45271fe --- /dev/null +++ b/components/chat.tsx @@ -0,0 +1,133 @@ +"use client"; +import { useEffect, useState } from "react"; +import { Send } from "lucide-react"; + +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardFooter, + CardHeader, +} from "@/components/ui/card"; +import { ScrollArea } from "@/components/ui/scroll-area"; + +import { Input } from "@/components/ui/input"; + +import { useChat } from "ai/react"; + +export default function Chat({ projectInfo, featureInfo, taskInfo }: any) { + // console.log("projectInfo", projectInfo); + // console.log("featureInfo", featureInfo); + // console.log("taskInfo", taskInfo); + const { messages, input, handleInputChange, handleSubmit, setMessages } = + useChat({ + body: { + projectInfo, + featureInfo, + taskInfo, + }, + api: "/chat", + onResponse: async (res) => { + try { + console.log("RESPONSE ===>", res); + const task_id = taskInfo.task_id; + const data = await fetch(`/chat/add`, { + method: "POST", + body: JSON.stringify({ + content: input, + role: "user", + task_id: task_id, + }), + }); + } catch (err) { + console.error(err); + } + }, + onFinish: async (message) => { + try { + console.log("FINISH ===>", message); + const data = await fetch(`/chat/add`, { + method: "POST", + body: JSON.stringify({ + content: message.content, + role: "assistant", + task_id: taskInfo.task_id, + }), + }); + } catch (err) { + console.error(err); + } + }, + onError: (err) => { + console.error("CHAT HOOK ERROR ===>", err); + }, + }); + + useEffect(() => { + async function getMessages() { + try { + const res = await fetch(`/chat/${taskInfo.task_id}`); + const data = await res.json(); + console.log("CLIENT RAW RES ===>", data); + setMessages(data.messages); + return data; + } catch (err) { + console.error(err); + } + } + getMessages(); + }, [setMessages, taskInfo.task_id]); + + const inputLength = input.trim().length; + + return ( + <> + + +

Chat with this task.

+
+ + + +
+ {messages.map((message, index) => ( +
+ {message.content} +
+ ))} +
+
+
+ + +
+ + +
+
+
+ + ); +} diff --git a/components/create-feature.tsx b/components/create-feature.tsx new file mode 100644 index 0000000..3f5992c --- /dev/null +++ b/components/create-feature.tsx @@ -0,0 +1,60 @@ +"use client"; +import { useToast } from "@/components/ui/use-toast"; +import { useRouter } from "next/navigation"; + +import * as z from "zod"; +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 { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; + +const createFeatureSchema = z.object({ + name: z.string().min(2, { + message: "Feature name must be at least 2 characters.", + }), + description: z.string().min(2, { + message: "Feature description must be at least 2 characters.", + }), +}); + +export function CreateFeature() { + const { toast } = useToast(); + const router = useRouter(); + + const form = useForm>({ + resolver: zodResolver(createFeatureSchema), + defaultValues: { + name: "", + description: "", + }, + }); + + async function createFeature(values: z.infer) { + try { + // TODO: create feature + } catch (err: any) { + console.error(err); + toast({ + variant: "destructive", + title: "Uh oh! There was an error creating the feature.", + description: err.error, + }); + } + } + + return ( +
+

Create Feature

+
+ ); +} diff --git a/components/create-project.tsx b/components/create-project.tsx new file mode 100644 index 0000000..72286e5 --- /dev/null +++ b/components/create-project.tsx @@ -0,0 +1,203 @@ +"use client"; +import { useToast } from "@/components/ui/use-toast"; +import { useRouter, useParams } from "next/navigation"; +import { useState } from "react"; + +import * as z from "zod"; +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 { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { Cross2Icon, PlusIcon } from "@radix-ui/react-icons"; +import { cn } from "@/lib/utils"; + +const createProjectSchema = z.object({ + name: z.string().min(2, { + message: "Name must be at least 2 characters.", + }), + description: z.string().min(2, { + message: "Description must be at least 2 characters.", + }), + stack: z.array(z.string()).min(1, { + message: "Project tech stack must have at least one item.", + }), +}); + +export function CreateProject({ className }: { className?: string }) { + const { toast } = useToast(); + const router = useRouter(); + const params = useParams(); + + const [stackInput, setStackInput] = useState(""); + const [isDialogOpen, setIsDialogOpen] = useState(false); + const [formStep, setFormStep] = useState(0); + + const form = useForm>({ + resolver: zodResolver(createProjectSchema), + defaultValues: { + name: "", + description: "", + stack: [], + }, + }); + + const { setValue } = form; + + async function createProject(values: z.infer) { + try { + const res = await fetch(`/w/${params.workspaceID}/p`, { + method: "POST", + body: JSON.stringify(values), + }); + + if (!res.ok) { + const error = await res.json(); + throw new Error(error.message); + } + + const { project } = await res.json(); + + toast({ + variant: "default", + title: "Project created successfully!", + description: `Project ${project.name} was created successfully.`, + }); + router.push(`/w/${params.workspaceID}/p/${project.id}`); + router.refresh(); + } catch (err: any) { + console.error(err); + toast({ + variant: "destructive", + title: "Uh oh! There was an error creating your project.", + description: err.error, + }); + } + } + + const keyHandler = (e: React.KeyboardEvent) => { + if ((e.key === "Enter" || e.key === "Tab") && stackInput !== "") { + e.preventDefault(); + setValue("stack", [...form.getValues("stack"), stackInput]); + setStackInput(""); + } + }; + + return ( + + + + + + + Create Project + +
+ + ( + + Name + + + + This is your project name. + + + )} + /> + ( + + Description + + + + + This is your project description. + + + + )} + /> + ( + + Tech Stack + + setStackInput(e.target.value)} + onKeyDown={keyHandler} + /> + + + This is your project tech stack. + +
+ {form.getValues("stack").map((technology) => ( + + {technology} + + setValue( + "stack", + form + .getValues("stack") + .filter((s) => s !== technology) + ) + } + /> + + ))} +
+ +
+ )} + /> + + + + + +
+
+ ); +} diff --git a/components/create-task.tsx b/components/create-task.tsx new file mode 100644 index 0000000..fe1ac7f --- /dev/null +++ b/components/create-task.tsx @@ -0,0 +1,63 @@ +"use client"; +import { useToast } from "@/components/ui/use-toast"; +import { useRouter } from "next/navigation"; + +import * as z from "zod"; +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 { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; + +const createTaskSchema = z.object({ + name: z.string().min(2, { + message: "Task name must be at least 2 characters.", + }), + description: z + .string() + .min(2, { + message: "Task description must be at least 2 characters.", + }) + .optional(), +}); + +export function CreateTask() { + const { toast } = useToast(); + const router = useRouter(); + + const form = useForm>({ + resolver: zodResolver(createTaskSchema), + defaultValues: { + name: "", + description: "", + }, + }); + + async function createTask(values: z.infer) { + try { + // TODO: create task + } catch (err: any) { + console.error(err); + toast({ + variant: "destructive", + title: "Uh oh! There was an error creating the task.", + description: err.error, + }); + } + } + + return ( +
+

Create Task

+
+ ); +} diff --git a/components/create-workspace.tsx b/components/create-workspace.tsx new file mode 100644 index 0000000..43e6023 --- /dev/null +++ b/components/create-workspace.tsx @@ -0,0 +1,146 @@ +"use client"; +import { useToast } from "@/components/ui/use-toast"; +import { useRouter } from "next/navigation"; + +import * as z from "zod"; +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 { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { PlusIcon } from "@radix-ui/react-icons"; + +const createWorkspaceSchema = z.object({ + name: z.string().min(2, { + message: "Workspace name must be at least 2 characters.", + }), + description: z + .string() + .min(2, { + message: "Workspace description must be at least 2 characters.", + }) + .optional(), +}); + +export function CreateWorkspace() { + const { toast } = useToast(); + const router = useRouter(); + + const form = useForm>({ + resolver: zodResolver(createWorkspaceSchema), + defaultValues: { + name: "", + description: "", + }, + }); + + async function createWorkspace( + values: z.infer + ) { + try { + const res = await fetch("/w", { + method: "POST", + body: JSON.stringify(values), + }); + + if (!res.ok) { + const error = await res.json(); + throw new Error(error.message); + } + + const { workspace } = await res.json(); + + toast({ + variant: "default", + title: "Workspace created successfully!", + description: `Workspace ${workspace.name} was created successfully.`, + }); + router.push(`/w/${workspace.id}`); + router.refresh(); + } catch (err: any) { + console.error(err); + toast({ + variant: "destructive", + title: "Uh oh! There was an error creating the workspace.", + description: err.error, + }); + } + } + + return ( + + + + + + + Create Workspace + +
+ + ( + + Name + + + + + This is your workspace name. + + + + )} + /> + ( + + Description + + + + + This is your workspace description. + + + + )} + /> + + + + + +
+
+ ); +} diff --git a/components/feature-card.tsx b/components/feature-card.tsx new file mode 100644 index 0000000..64258b4 --- /dev/null +++ b/components/feature-card.tsx @@ -0,0 +1,104 @@ +import { useState } from "react"; + +import { + Card, + CardContent, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; + +import { Pencil2Icon, TrashIcon } from "@radix-ui/react-icons"; + +type Feature = { uid: string; name: string; description: string }; +export function FeatureCard({ + feature, + features, + setFeatures, +}: { + feature: Feature; + features: Feature[]; + setFeatures: React.Dispatch>; +}) { + const [isEditing, setIsEditing] = useState(false); + const [name, setName] = useState(feature.name); + const [description, setDescription] = useState(feature.description); + + const toggleEdit = () => { + setIsEditing(!isEditing); + }; + + const saveChanges = () => { + setFeatures( + features.map((_feature) => { + if (_feature.uid === feature.uid) { + return { ..._feature, name: name, description: description }; + } + return _feature; + }) + ); + toggleEdit(); + }; + + return ( + + {isEditing ? ( + <> + + + setName(e.target.value)} + /> + + + +