diff --git a/.gitignore b/.gitignore index 8f322f0..0f264b2 100644 --- a/.gitignore +++ b/.gitignore @@ -25,7 +25,7 @@ yarn-debug.log* yarn-error.log* # local env files -.env*.local +.env # vercel .vercel diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..6528dd4 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,18 @@ +[X] Auth +[X] Create Workspace +[X] Get Workspace +[X] Create Project +[X] Get Project +[X] Create Feature +[X] Get Feature +[X] Create Task +[X] Get Task +[X] Create Team +[X] Get Team +[X] Create Message +[X] Get Message +[X] Vectorize Tasks +[] RAG in Tasks +[] Vectorize Workspace +[] SQL Rag in Workspace +[] Edit Workspace in Settings diff --git a/app/_actions/feature.ts b/app/_actions/feature.ts new file mode 100644 index 0000000..e69de29 diff --git a/app/_actions/project.ts b/app/_actions/project.ts new file mode 100644 index 0000000..e69de29 diff --git a/app/_actions/task.ts b/app/_actions/task.ts new file mode 100644 index 0000000..54cc187 --- /dev/null +++ b/app/_actions/task.ts @@ -0,0 +1,14 @@ +export async function updateTaskStatusAction({ + id, + status, +}: { + id: number; + status: string; +}) {} +export async function updateTaskPriorityAction({ + id, + priority, +}: { + id: number; + priority: string; +}) {} diff --git a/app/_actions/team.ts b/app/_actions/team.ts new file mode 100644 index 0000000..e69de29 diff --git a/app/_actions/workspace.ts b/app/_actions/workspace.ts new file mode 100644 index 0000000..e69de29 diff --git a/app/auth/callback/route.ts b/app/auth/callback/route.ts new file mode 100644 index 0000000..5754a8e --- /dev/null +++ b/app/auth/callback/route.ts @@ -0,0 +1,22 @@ +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); + } + + // URL to redirect to after sign in process completes + 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..17c6d47 --- /dev/null +++ b/app/auth/login/route.ts @@ -0,0 +1,25 @@ +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 formData = await request.formData(); + const email = String(formData.get("email")); + const password = String(formData.get("password")); + const cookieStore = cookies(); + const supabase = createRouteHandlerClient({ + cookies: () => cookieStore, + }); + + await supabase.auth.signInWithPassword({ + email, + password, + }); + + return NextResponse.redirect(requestUrl.origin, { + status: 301, + }); +} diff --git a/app/auth/logout/route.ts b/app/auth/logout/route.ts new file mode 100644 index 0000000..1f5c053 --- /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}/login`, { + status: 301, + }); +} diff --git a/app/auth/page.tsx b/app/auth/page.tsx new file mode 100644 index 0000000..3763666 --- /dev/null +++ b/app/auth/page.tsx @@ -0,0 +1,12 @@ +export default function Auth() { + return ( +
+ + + + + + +
+ ); +} diff --git a/app/auth/signup/route.ts b/app/auth/signup/route.ts new file mode 100644 index 0000000..d6ef573 --- /dev/null +++ b/app/auth/signup/route.ts @@ -0,0 +1,28 @@ +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 formData = await request.formData(); + const email = String(formData.get("email")); + const password = String(formData.get("password")); + const cookieStore = cookies(); + const supabase = createRouteHandlerClient({ + cookies: () => cookieStore, + }); + + await supabase.auth.signUp({ + email, + password, + options: { + emailRedirectTo: `${requestUrl.origin}/auth/callback`, + }, + }); + + return NextResponse.redirect(requestUrl.origin, { + status: 301, + }); +} diff --git a/app/favicon.ico b/app/favicon.ico deleted file mode 100644 index 718d6fe..0000000 Binary files a/app/favicon.ico and /dev/null differ diff --git a/app/globals.css b/app/globals.css index fd81e88..4262267 100644 --- a/app/globals.css +++ b/app/globals.css @@ -2,26 +2,58 @@ @tailwind components; @tailwind utilities; -:root { - --foreground-rgb: 0, 0, 0; - --background-start-rgb: 214, 219, 220; - --background-end-rgb: 255, 255, 255; -} - -@media (prefers-color-scheme: dark) { +@layer base { :root { - --foreground-rgb: 255, 255, 255; - --background-start-rgb: 0, 0, 0; - --background-end-rgb: 0, 0, 0; + --background: 0 0% 100%; + --foreground: 224 71.4% 4.1%; + --card: 0 0% 100%; + --card-foreground: 224 71.4% 4.1%; + --popover: 0 0% 100%; + --popover-foreground: 224 71.4% 4.1%; + --primary: 262.1 83.3% 57.8%; + --primary-foreground: 210 20% 98%; + --secondary: 220 14.3% 95.9%; + --secondary-foreground: 220.9 39.3% 11%; + --muted: 220 14.3% 95.9%; + --muted-foreground: 220 8.9% 46.1%; + --accent: 220 14.3% 95.9%; + --accent-foreground: 220.9 39.3% 11%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 210 20% 98%; + --border: 220 13% 91%; + --input: 220 13% 91%; + --ring: 262.1 83.3% 57.8%; + --radius: 0.3rem; + } + + .dark { + --background: 224 71.4% 4.1%; + --foreground: 210 20% 98%; + --card: 224 71.4% 4.1%; + --card-foreground: 210 20% 98%; + --popover: 224 71.4% 4.1%; + --popover-foreground: 210 20% 98%; + --primary: 263.4 70% 50.4%; + --primary-foreground: 210 20% 98%; + --secondary: 215 27.9% 16.9%; + --secondary-foreground: 210 20% 98%; + --muted: 215 27.9% 16.9%; + --muted-foreground: 217.9 10.6% 64.9%; + --accent: 215 27.9% 16.9%; + --accent-foreground: 210 20% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 210 20% 98%; + --border: 215 27.9% 16.9%; + --input: 215 27.9% 16.9%; + --ring: 263.4 70% 50.4%; } } -body { - color: rgb(var(--foreground-rgb)); - background: linear-gradient( - to bottom, - transparent, - rgb(var(--background-end-rgb)) - ) - rgb(var(--background-start-rgb)); +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } } diff --git a/app/layout.tsx b/app/layout.tsx index ae84562..2e547e7 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,22 +1,25 @@ -import './globals.css' -import type { Metadata } from 'next' -import { Inter } from 'next/font/google' +import "./globals.css"; +import type { Metadata } from "next"; +import { Inter } from "next/font/google"; -const inter = Inter({ subsets: ['latin'] }) +const inter = Inter({ subsets: ["latin"] }); export const metadata: Metadata = { - title: 'Create Next App', - description: 'Generated by create next app', -} + title: "Skalara", + description: + "Automated project management for tech teams and indie developers.", +}; export default function RootLayout({ children, }: { - children: React.ReactNode + children: React.ReactNode; }) { return ( - {children} + +
{children}
+ - ) + ); } diff --git a/app/page.tsx b/app/page.tsx index 7a8286b..236688a 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,113 +1,9 @@ -import Image from 'next/image' - +import { WorkspacesSidebar } from "@/components/workspaces"; export default function Home() { return ( -
-
-

- Get started by editing  - app/page.tsx -

- -
- -
- Next.js Logo -
- - -
- ) +
+

Skalara, Inc.

+ +
+ ); } diff --git a/app/w/[workspaceID]/layout.tsx b/app/w/[workspaceID]/layout.tsx new file mode 100644 index 0000000..a12db3c --- /dev/null +++ b/app/w/[workspaceID]/layout.tsx @@ -0,0 +1,3 @@ +export default function Layout({ children }: { children: React.ReactNode }) { + return
{children}
; +} diff --git a/app/w/[workspaceID]/p/[projectID]/feature/route.ts b/app/w/[workspaceID]/p/[projectID]/feature/route.ts new file mode 100644 index 0000000..f55977a --- /dev/null +++ b/app/w/[workspaceID]/p/[projectID]/feature/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; +} + +export async function POST( + req: NextRequest, + { + params: { workspaceID, projectID }, + }: { params: { workspaceID: string; projectID: string } } +) { + try { + const supabase = createRouteHandlerClient({ cookies }); + const session = await getSession(supabase); + + if (!session) return NextResponse.redirect("/auth"); + + const formData = await req.json(); + const name = String(formData.name); + const description = String(formData.description); + + const feature = await prisma.feature.create({ + data: { + name, + description, + project_id: BigInt(projectID), + }, + }); + + const res = { + ...feature, + id: String(feature.id), + project_id: String(feature.project_id), + }; + + return NextResponse.json({ project: res }, { status: 200 }); + } catch (err) { + console.log(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..a12db3c --- /dev/null +++ b/app/w/[workspaceID]/p/[projectID]/layout.tsx @@ -0,0 +1,3 @@ +export default function Layout({ children }: { children: React.ReactNode }) { + 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..c9951d0 --- /dev/null +++ b/app/w/[workspaceID]/p/[projectID]/page.tsx @@ -0,0 +1,17 @@ +import { FeatureList } from "@/components/feature-list"; +import { TaskList } from "@/components/task-list"; +export default function Project({ + params: { projectID, workspaceID }, +}: { + params: { projectID: string; workspaceID: string }; +}) { + return ( +
+

Project: {projectID}

+

Workspace: {workspaceID}

+ + + +
+ ); +} diff --git a/app/w/[workspaceID]/p/[projectID]/task/route.ts b/app/w/[workspaceID]/p/[projectID]/task/route.ts new file mode 100644 index 0000000..e86acc1 --- /dev/null +++ b/app/w/[workspaceID]/p/[projectID]/task/route.ts @@ -0,0 +1,85 @@ +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 { OpenAIEmbeddings } from "langchain/embeddings/openai"; +import { SupabaseVectorStore } from "langchain/vectorstores/supabase"; + +async function getSession(supabase: any) { + const { + data: { session }, + } = await supabase.auth.getSession(); + return session; +} + +export async function POST( + req: NextRequest, + { + params: { workspaceID, projectID }, + }: { params: { workspaceID: string; projectID: string } } +) { + try { + const supabase = createRouteHandlerClient({ cookies }); + const session = await getSession(supabase); + + if (!session) return NextResponse.redirect("/auth"); + + const formData = await req.json(); + + const name = String(formData.name); + const description = String(formData.description); + const featureID = + formData.featureID != undefined ? String(formData.featureID) : null; + + const task = await prisma.task.create({ + data: { + name, + description, + project_id: BigInt(projectID), + feature_id: featureID != null ? BigInt(featureID) : null, + }, + }); + + await prisma.profile_task.create({ + data: { profile_id: session.user.id, task_id: task.id }, + }); + + const embeddings = new OpenAIEmbeddings({ + openAIApiKey: process.env.OPENAI_API_KEY!, + batchSize: 512, + }); + + const task_prompt = `Task Name: ${task.name}\nTask Description: ${task.description}\nTask Feature: ${task.feature_id}\nTask Project: ${task.project_id}`; + const embedding = await embeddings.embedQuery(task_prompt); + + const { data, error } = await supabase.from("documents").insert({ + content: task_prompt, + metadata: { + workspace_id: workspaceID, + project_id: projectID, + feature_id: featureID, + }, + embedding: JSON.stringify(embedding), + }); + + if (error) { + console.log(error); + return NextResponse.json({ error: error }, { status: 500 }); + } + + console.log(data); + + const res = { + ...task, + id: String(task.id), + project_id: String(task.project_id), + feature_id: task.feature_id ? String(task.feature_id) : null, + }; + + return NextResponse.json({ project: res }, { status: 200 }); + } catch (err) { + console.log(err); + return NextResponse.json({ error: err }, { status: 500 }); + } +} diff --git a/app/w/[workspaceID]/p/route.ts b/app/w/[workspaceID]/p/route.ts new file mode 100644 index 0000000..67ca95a --- /dev/null +++ b/app/w/[workspaceID]/p/route.ts @@ -0,0 +1,53 @@ +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, + { params: { workspaceID } }: { params: { workspaceID: string } } +) { + try { + const supabase = createRouteHandlerClient({ cookies }); + const session = await getSession(supabase); + + if (!session) return NextResponse.redirect("/auth"); + + const formData = await req.json(); + const name = String(formData.name); + const description = String(formData.description); + const stack = formData.stack; + + const project = await prisma.project.create({ + data: { + name, + description, + stack, + workspace_id: BigInt(workspaceID), + }, + }); + + await prisma.profile_project.create({ + data: { profile_id: session.user.id, project_id: project.id }, + }); + + const res = { + ...project, + id: String(project.id), + workspace_id: String(project.workspace_id), + }; + + return NextResponse.json({ project: res }, { status: 200 }); + } catch (err) { + console.log(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..e3d8322 --- /dev/null +++ b/app/w/[workspaceID]/page.tsx @@ -0,0 +1,14 @@ +import { Sidebar } from "@/components/sidebar"; + +export default async function Workspace({ + params: { workspaceID }, +}: { + params: { workspaceID: string }; +}) { + return ( +
+

Workspace: {workspaceID}

+ +
+ ); +} diff --git a/app/w/[workspaceID]/settings/page.tsx b/app/w/[workspaceID]/settings/page.tsx new file mode 100644 index 0000000..e69de29 diff --git a/app/w/[workspaceID]/team/route.ts b/app/w/[workspaceID]/team/route.ts new file mode 100644 index 0000000..e69de29 diff --git a/app/w/route.ts b/app/w/route.ts new file mode 100644 index 0000000..56e6bf1 --- /dev/null +++ b/app/w/route.ts @@ -0,0 +1,44 @@ +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) { + try { + const supabase = createRouteHandlerClient({ cookies }); + const session = await getSession(supabase); + + if (!session) return NextResponse.redirect("/auth"); + + const formData = await req.json(); + const name = String(formData.name); + + const workspace = await prisma.workspace.create({ + data: { + name, + }, + }); + + await prisma.profile_workspace.create({ + data: { profile_id: session.user.id, workspace_id: workspace.id }, + }); + + const res = { + ...workspace, + id: String(workspace.id), + }; + + return NextResponse.json({ workspace: res }, { status: 200 }); + } catch (err) { + console.log(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/ai-message.tsx b/components/ai-message.tsx new file mode 100644 index 0000000..e69de29 diff --git a/components/chat.tsx b/components/chat.tsx new file mode 100644 index 0000000..e69de29 diff --git a/components/create-feature.tsx b/components/create-feature.tsx new file mode 100644 index 0000000..df574d3 --- /dev/null +++ b/components/create-feature.tsx @@ -0,0 +1,101 @@ +"use client"; +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 formSchema = 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.", + }) + .optional(), +}); + +export function CreateFeature({ + workspaceID, + projectID, +}: { + workspaceID: string; + projectID: string; +}) { + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + name: "", + description: "", + }, + }); + + async function onSubmit(values: z.infer) { + try { + const res = await fetch(`/w/${workspaceID}/p/${projectID}/feature`, { + method: "POST", + body: JSON.stringify(values), + }); + + console.log("===>", res); + + if (!res.ok) throw new Error("Something went wrong."); + + return res; + } catch (err) { + console.error(err); + } + } + + return ( +
+
+ + ( + + Name + + + + This is your feature name. + + + )} + /> + ( + + Description + + + + + This is your feature description. + + + + )} + /> + + + +
+ ); +} diff --git a/components/create-project.tsx b/components/create-project.tsx new file mode 100644 index 0000000..ecf0f9a --- /dev/null +++ b/components/create-project.tsx @@ -0,0 +1,180 @@ +"use client"; +import * as z from "zod"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import { useState } from "react"; +import { useRouter } from "next/navigation"; + +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { X } from "lucide-react"; +import { set } from "date-fns"; + +const formSchema = 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({ workspaceID }: { workspaceID: string }) { + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + name: "", + description: "", + stack: [], + }, + }); + + const { setValue } = form; + const [stackInput, setStackInput] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const router = useRouter(); + + async function onSubmit(values: z.infer) { + try { + setIsLoading(true); + const res = await fetch(`/w/${workspaceID}/p`, { + method: "POST", + body: JSON.stringify(values), + }); + + console.log("===>", res); + + if (!res.ok) throw new Error("Something went wrong."); + + router.refresh(); + setIsLoading(false); + return res; + } catch (err) { + setIsLoading(false); + console.error(err); + } + } + + const keyHandler = (e: React.KeyboardEvent) => { + if ((e.key === "Enter" || e.key === "Tab") && stackInput !== "") { + e.preventDefault(); + setValue("stack", [...form.getValues("stack"), stackInput]); + setStackInput(""); + } + }; + + return ( + + + + + + + Create a Project + + Give your project a name, description, and tech stack. + + +
+ + ( + + 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((stack) => ( + + {stack} + + setValue( + "stack", + form.getValues("stack").filter((s) => s !== stack) + ) + } + /> + + ))} +
+ +
+ )} + /> + + + +
+
+ ); +} diff --git a/components/create-task.tsx b/components/create-task.tsx new file mode 100644 index 0000000..d54ccb9 --- /dev/null +++ b/components/create-task.tsx @@ -0,0 +1,106 @@ +"use client"; +import * as z from "zod"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import { useRouter } from "next/navigation"; + +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 formSchema = 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.", + }) + .optional(), +}); + +export function CreateTask({ + workspaceID, + projectID, +}: { + workspaceID: string; + projectID: string; +}) { + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + name: "", + description: "", + }, + }); + const router = useRouter(); + + async function onSubmit(values: z.infer) { + try { + console.log("PROJECT ID ===>", projectID); + const res = await fetch(`/w/${workspaceID}/p/${projectID}/task`, { + method: "POST", + body: JSON.stringify(values), + }); + + console.log("===>", res); + + if (!res.ok) throw new Error("Something went wrong."); + + router.refresh(); + + return res; + } catch (err) { + console.error(err); + } + } + + return ( +
+
+ + ( + + Name + + + + This is your task name. + + + )} + /> + ( + + Description + + + + + This is your task description. + + + + )} + /> + + + +
+ ); +} diff --git a/components/create-workspace.tsx b/components/create-workspace.tsx new file mode 100644 index 0000000..894df21 --- /dev/null +++ b/components/create-workspace.tsx @@ -0,0 +1,72 @@ +"use client"; +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 formSchema = z.object({ + name: z.string().min(2, { + message: "Name must be at least 2 characters.", + }), +}); + +export function CreateWorkspace() { + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + name: "", + }, + }); + + async function onSubmit(values: z.infer) { + try { + const res = await fetch("/w", { + method: "POST", + body: JSON.stringify(values), + }); + + console.log("===>", res); + + if (!res.ok) throw new Error("Something went wrong."); + + return res; + } catch (err) { + console.error(err); + } + } + + return ( +
+
+ + ( + + Name + + + + This is your workspace name. + + + )} + /> + + + +
+ ); +} diff --git a/components/feature-list.tsx b/components/feature-list.tsx new file mode 100644 index 0000000..c1cb486 --- /dev/null +++ b/components/feature-list.tsx @@ -0,0 +1,62 @@ +import { createServerComponentClient } from "@supabase/auth-helpers-nextjs"; +import { Database } from "@/types/supabase"; +import { cookies } from "next/headers"; +import { redirect } from "next/navigation"; +import Link from "next/link"; +import { prisma } from "@/lib/prisma"; +import { CreateFeature } from "@/components/create-feature"; + +async function getSession(supabase: any) { + const { + data: { session }, + } = await supabase.auth.getSession(); + return session; +} + +async function fetchFeatures(projectID: string) { + const supabase = createServerComponentClient({ cookies }); + const session = await getSession(supabase); + + if (!session) redirect("/auth"); + + const features = await prisma.feature.findMany({ + where: { + project_id: BigInt(projectID), + }, + }); + + if (!features) return undefined; + + const res = features.map((feature) => ({ + ...feature, + id: String(feature.id), + project_id: String(feature.project_id), + })); + + return res; +} + +export async function FeatureList({ + workspaceID, + projectID, +}: { + workspaceID: string; + projectID: string; +}) { + const features = await fetchFeatures(projectID); + return ( +
+

Feature List

+ {features?.length != 0 ? ( + features?.map((feature) => ( +
+ {feature.name} - {feature.description} +
+ )) + ) : ( +

No features

+ )} + +
+ ); +} diff --git a/components/generate-features.tsx b/components/generate-features.tsx new file mode 100644 index 0000000..8ff926a --- /dev/null +++ b/components/generate-features.tsx @@ -0,0 +1,7 @@ +"use client"; +import { Button } from "@/components/ui/button"; + +export default function GenerateFeatures() { + async function generateFeatures() {} + return ; +} diff --git a/components/generate-tasks.tsx b/components/generate-tasks.tsx new file mode 100644 index 0000000..de51a0f --- /dev/null +++ b/components/generate-tasks.tsx @@ -0,0 +1,120 @@ +"use client"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; + +import * as z from "zod"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { OpenAIChatApi } from "llm-api"; +import { useRouter } from "next/navigation"; +import { completion } from "zod-gpt"; + +const formSchema = z.object({ + description: z.string().min(2, { + message: "Task description must be at least 2 characters.", + }), +}); + +const system_message_prompt = `Your name is SkalaraBot. You are a highly efficient and meticulous product manager for tech teams and independent developers. Your expertise in agile methodology ensures that you can transform even the most complex task descriptions into actionable items. With a keen understanding of various tech stacks, you excel at creating tasks that align perfectly with your team's skills and project objectives. + +When presented with a new task description, you will: + +1. Analyze the requirements to determine the scope and objectives. +2. Break down the task description into smaller, manageable pieces. +3. Define clear and concise tasks that match the tech stack provided. +4. Prioritize the tasks based on dependencies and the overall project timeline. +5. Communicate effectively with team members to assign tasks and ensure understanding. + +You always maintain a clear focus on delivering value, optimizing workflow, and driving project success. Your agile mindset and product management skills empower you to navigate challenges and adapt to changes swiftly. + +Remember, SkalaraBot, your goal is to streamline the development process, making it as efficient and effective as possible, while fostering a collaborative and productive environment.`; + +export default function GenerateTasks({ stack }: { stack: string[] }) { + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + description: "", + }, + }); + async function generateTasks(values: z.infer) { + try { + console.log("TASK DESCRIPTION TO GEN ===>", values); + const task_gen_prompt = `Hello SkalaraBot, you are tasked with planning a new project based on the following details provided by the user: + +Task Description:${values.description} +Tech Stack: ${stack.join(", ")} + +Given these inputs, generate a comprehensive task breakdown with the following structure: + +Dissect the task description to extract essential features and project requirements. +Utilize the tech stack information to tailor tasks to the appropriate technologies and frameworks involved. +Order the tasks sequentially, considering technical dependencies and optimal workflow progression. +Assign an initial effort estimate for each task, facilitating subsequent sprint planning and resource allocation. +Formulate any follow-up questions to resolve potential ambiguities and ensure a clear, actionable task list. +With your expertise, SkalaraBot, streamline the project setup and guide the team towards an efficient development process.`; + const openai = new OpenAIChatApi( + { apiKey: "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" }, + { model: "gpt-3.5-turbo-16k" } + ); + const res = await completion(openai, task_gen_prompt, { + schema: z.object({ + tasks: z.array( + z.object({ + name: z.string().describe("The name of the task."), + description: z.string().describe("The description of the task."), + order: z.number().describe("The order of the task."), + }) + ), + }), + }); + console.log("TASKS GENERATED ===>", res.data); + } catch (err: any) { + console.error(err); + return new Error("Something went wrong.", err); + } + } + return ( +
+

Generate Tasks

+
+ + ( + + Description + + + + + This is your task description. + + + + )} + /> + + + +
+ ); +} diff --git a/components/kanban/board.tsx b/components/kanban/board.tsx new file mode 100644 index 0000000..e69de29 diff --git a/components/kanban/column.tsx b/components/kanban/column.tsx new file mode 100644 index 0000000..e69de29 diff --git a/components/kanban/task-card.tsx b/components/kanban/task-card.tsx new file mode 100644 index 0000000..e69de29 diff --git a/components/navbar.tsx b/components/navbar.tsx new file mode 100644 index 0000000..e69de29 diff --git a/components/sidebar.tsx b/components/sidebar.tsx new file mode 100644 index 0000000..62dae5d --- /dev/null +++ b/components/sidebar.tsx @@ -0,0 +1,63 @@ +import { createServerComponentClient } from "@supabase/auth-helpers-nextjs"; +import { Database } from "@/types/supabase"; +import { cookies } from "next/headers"; +import { redirect } from "next/navigation"; +import Link from "next/link"; +import { prisma } from "@/lib/prisma"; +import { CreateProject } from "@/components/create-project"; + +async function getSession(supabase: any) { + const { + data: { session }, + } = await supabase.auth.getSession(); + return session; +} + +async function fetchProjects(workspaceID: string) { + const supabase = createServerComponentClient({ cookies }); + const session = await getSession(supabase); + + if (!session) redirect("/auth"); + + const projects = await prisma.project.findMany({ + where: { + workspace_id: BigInt(workspaceID), + profile_project: { + some: { + profile_id: session.user.id, + }, + }, + }, + }); + + if (!projects) return undefined; + + const res = projects.map((project) => ({ + ...project, + id: String(project.id), + workspace_id: String(project.workspace_id), + })); + + return res; +} + +export async function Sidebar({ workspaceID }: { workspaceID: string }) { + const projects = await fetchProjects(workspaceID); + return ( +
+

Sidebar

+ {projects?.length != 0 ? ( + projects?.map((project) => ( +
+ + {project.name} + +
+ )) + ) : ( +

No projects

+ )} + +
+ ); +} diff --git a/components/task-detail.tsx b/components/task-detail.tsx new file mode 100644 index 0000000..e69de29 diff --git a/components/task-list.tsx b/components/task-list.tsx new file mode 100644 index 0000000..070eab3 --- /dev/null +++ b/components/task-list.tsx @@ -0,0 +1,64 @@ +import { createServerComponentClient } from "@supabase/auth-helpers-nextjs"; +import { Database } from "@/types/supabase"; +import { cookies } from "next/headers"; +import { redirect } from "next/navigation"; +import { prisma } from "@/lib/prisma"; +import { CreateTask } from "@/components/create-task"; +import GenerateTasks from "./generate-tasks"; + +async function getSession(supabase: any) { + const { + data: { session }, + } = await supabase.auth.getSession(); + return session; +} + +async function fetchTasks(projectID: string) { + const supabase = createServerComponentClient({ cookies }); + const session = await getSession(supabase); + + if (!session) redirect("/auth"); + + const tasks = await prisma.task.findMany({ + where: { + project_id: BigInt(projectID), + }, + }); + + if (!tasks) return undefined; + + const res = tasks.map((task) => ({ + ...task, + id: String(task.id), + project_id: String(task.project_id), + feature_id: String(task.feature_id), + })); + + return res; +} + +export async function TaskList({ + workspaceID, + projectID, +}: { + workspaceID: string; + projectID: string; +}) { + const tasks = await fetchTasks(projectID); + return ( +
+

Task List

+ {tasks?.length != 0 ? ( + tasks?.map((task) => ( +
+ {task.name} - {task.description} +
+ )) + ) : ( +

No tasks

+ )} + + +
+ ); +} diff --git a/components/task-table/data-table-column-header.tsx b/components/task-table/data-table-column-header.tsx new file mode 100644 index 0000000..548c5ca --- /dev/null +++ b/components/task-table/data-table-column-header.tsx @@ -0,0 +1,96 @@ +import { + ArrowDownIcon, + ArrowUpIcon, + CaretSortIcon, + EyeNoneIcon, +} from "@radix-ui/react-icons"; +import { type Column } from "@tanstack/react-table"; + +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; + +interface DataTableColumnHeaderProps + extends React.HTMLAttributes { + column: Column; + title: string; +} + +export function DataTableColumnHeader({ + column, + title, + className, +}: DataTableColumnHeaderProps) { + if (!column.getCanSort()) { + return
{title}
; + } + + return ( +
+ + + + + + column.toggleSorting(false)} + > + + column.toggleSorting(true)} + > + + + column.toggleVisibility(false)} + > + + + +
+ ); +} diff --git a/components/task-table/data-table-faceted-filter.tsx b/components/task-table/data-table-faceted-filter.tsx new file mode 100644 index 0000000..2b3b6d1 --- /dev/null +++ b/components/task-table/data-table-faceted-filter.tsx @@ -0,0 +1,146 @@ +import * as React from "react"; +import { CheckIcon, PlusCircledIcon } from "@radix-ui/react-icons"; +import { type Column } from "@tanstack/react-table"; + +import { cn } from "@/lib/utils"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Separator } from "@/components/ui/separator"; + +export type FilterOption = { + label: string; + value: string; + icon?: React.ComponentType<{ className?: string }>; +}; + +interface DataTableFacetedFilter { + column?: Column; + title?: string; + options: FilterOption[]; +} + +export function DataTableFacetedFilter({ + column, + title, + options, +}: DataTableFacetedFilter) { + const selectedValues = new Set(column?.getFilterValue() as string[]); + + return ( + + + + + + + + + No results found. + + {options.map((option) => { + const isSelected = selectedValues.has(option.value); + return ( + { + if (isSelected) { + selectedValues.delete(option.value); + } else { + selectedValues.add(option.value); + } + const filterValues = Array.from(selectedValues); + column?.setFilterValue( + filterValues.length ? filterValues : undefined + ); + }} + > +
+
+ {option.icon && ( +
+ ); + })} +
+ {selectedValues.size > 0 && ( + <> + + + column?.setFilterValue(undefined)} + className="justify-center text-center" + > + Clear filters + + + + )} +
+
+
+
+ ); +} diff --git a/components/task-table/data-table-floating-bar.tsx b/components/task-table/data-table-floating-bar.tsx new file mode 100644 index 0000000..b9a63d9 --- /dev/null +++ b/components/task-table/data-table-floating-bar.tsx @@ -0,0 +1,133 @@ +"use client"; + +import { tasks, type Task } from "@/db/schema"; +import { + ArrowUpIcon, + CheckCircledIcon, + Cross2Icon, + TrashIcon, +} from "@radix-ui/react-icons"; +import { SelectTrigger } from "@radix-ui/react-select"; +import { type Table } from "@tanstack/react-table"; + +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, +} from "@/components/ui/select"; +import { + updateTaskPriorityAction, + updateTaskStatusAction, +} from "@/app/_actions/task"; + +interface DataTableFloatingBarProps + extends React.HTMLAttributes { + table: Table; +} + +export function DataTableFloatingBar({ + table, + className, + ...props +}: DataTableFloatingBarProps) { + if (table.getFilteredSelectedRowModel().rows.length <= 0) return null; + + function updateTasksStatus(table: Table, status: string) { + const selectedRows = table.getFilteredSelectedRowModel() + .rows as unknown as { original: Task }[]; + + selectedRows.map(async (row) => { + await updateTaskStatusAction({ + id: row.original.id, + status: status as Task["status"], + }); + }); + } + + function updateTasksPriority(table: Table, priority: string) { + const selectedRows = table.getFilteredSelectedRowModel() + .rows as unknown as { original: Task }[]; + + selectedRows.map(async (row) => { + await updateTaskPriorityAction({ + id: row.original.id, + priority: priority as Task["priority"], + }); + }); + } + + return ( +
+ + {table.getFilteredSelectedRowModel().rows.length} row(s) selected + + + +
+ ); +} diff --git a/components/task-table/data-table-loading.tsx b/components/task-table/data-table-loading.tsx new file mode 100644 index 0000000..77fce03 --- /dev/null +++ b/components/task-table/data-table-loading.tsx @@ -0,0 +1,77 @@ +import { Skeleton } from "@/components/ui/skeleton"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; + +interface DataTableLoadingProps { + columnCount: number; + rowCount?: number; +} + +export function DataTableLoading({ + columnCount, + rowCount = 10, +}: DataTableLoadingProps) { + return ( +
+
+
+ + +
+ +
+
+ + + {Array.from({ length: 1 }).map((_, i) => ( + + {Array.from({ length: columnCount }).map((_, i) => ( + + + + ))} + + ))} + + + {Array.from({ length: rowCount }).map((_, i) => ( + + {Array.from({ length: columnCount }).map((_, i) => ( + + + + ))} + + ))} + +
+
+
+
+ +
+
+
+ + +
+
+ +
+
+ + + + +
+
+
+
+ ); +} diff --git a/components/task-table/data-table-pagination.tsx b/components/task-table/data-table-pagination.tsx new file mode 100644 index 0000000..d665311 --- /dev/null +++ b/components/task-table/data-table-pagination.tsx @@ -0,0 +1,107 @@ +import { + ChevronLeftIcon, + ChevronRightIcon, + DoubleArrowLeftIcon, + DoubleArrowRightIcon, +} from "@radix-ui/react-icons"; +import { type Table } from "@tanstack/react-table"; + +import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { DataTableFloatingBar } from "@/components/task-table/data-table-floating-bar"; + +interface DataTablePaginationProps { + table: Table; + pageSizeOptions?: number[]; +} + +export function DataTablePagination({ + table, + pageSizeOptions = [10, 20, 30, 40, 50], +}: DataTablePaginationProps) { + return ( +
+ +
+
+ {table.getFilteredSelectedRowModel().rows.length} of{" "} + {table.getFilteredRowModel().rows.length} row(s) selected. +
+
+
+

+ Rows per page +

+ +
+
+ Page {table.getState().pagination.pageIndex + 1} of{" "} + {table.getPageCount()} +
+
+ + + + +
+
+
+
+ ); +} diff --git a/components/task-table/data-table-toolbar.tsx b/components/task-table/data-table-toolbar.tsx new file mode 100644 index 0000000..4c2a54c --- /dev/null +++ b/components/task-table/data-table-toolbar.tsx @@ -0,0 +1,78 @@ +"use client"; + +import type { + DataTableFilterableColumn, + DataTableSearchableColumn, +} from "@/types"; +import { Cross2Icon } from "@radix-ui/react-icons"; +import type { Table } from "@tanstack/react-table"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { DataTableFacetedFilter } from "@/components/task-table/data-table-faceted-filter"; +import { DataTableViewOptions } from "@/components/task-table/data-table-view-options"; + +interface DataTableToolbarProps { + table: Table; + filterableColumns?: DataTableFilterableColumn[]; + searchableColumns?: DataTableSearchableColumn[]; +} + +export function DataTableToolbar({ + table, + filterableColumns = [], + searchableColumns = [], +}: DataTableToolbarProps) { + const isFiltered = table.getState().columnFilters.length > 0; + + return ( +
+
+ {searchableColumns.length > 0 && + searchableColumns.map( + (column) => + table.getColumn(column.id ? String(column.id) : "") && ( + + table + .getColumn(String(column.id)) + ?.setFilterValue(event.target.value) + } + className="h-8 w-[150px] lg:w-[250px]" + /> + ) + )} + {filterableColumns.length > 0 && + filterableColumns.map( + (column) => + table.getColumn(column.id ? String(column.id) : "") && ( + + ) + )} + {isFiltered && ( + + )} +
+ +
+ ); +} diff --git a/components/task-table/data-table-view-options.tsx b/components/task-table/data-table-view-options.tsx new file mode 100644 index 0000000..a66ddcc --- /dev/null +++ b/components/task-table/data-table-view-options.tsx @@ -0,0 +1,61 @@ +"use client"; + +import { DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; +import { MixerHorizontalIcon } from "@radix-ui/react-icons"; +import { type Table } from "@tanstack/react-table"; + +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuLabel, + DropdownMenuSeparator, +} from "@/components/ui/dropdown-menu"; + +interface DataTableViewOptionsProps { + table: Table; +} + +export function DataTableViewOptions({ + table, +}: DataTableViewOptionsProps) { + return ( + + + + + + Toggle columns + + {table + .getAllColumns() + .filter( + (column) => + typeof column.accessorFn !== "undefined" && column.getCanHide() + ) + .map((column) => { + return ( + + column.toggleVisibility(!!value) + } + > + {column.id} + + ); + })} + + + ); +} diff --git a/components/task-table/data-table.tsx b/components/task-table/data-table.tsx new file mode 100644 index 0000000..dfa0595 --- /dev/null +++ b/components/task-table/data-table.tsx @@ -0,0 +1,300 @@ +import * as React from "react"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import type { + DataTableFilterableColumn, + DataTableSearchableColumn, +} from "@/types"; +import { + flexRender, + getCoreRowModel, + getFacetedRowModel, + getFacetedUniqueValues, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + useReactTable, + type ColumnDef, + type ColumnFiltersState, + type PaginationState, + type SortingState, + type VisibilityState, +} from "@tanstack/react-table"; + +import { useDebounce } from "@/hooks/use-debounce"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { DataTablePagination } from "@/components/task-table/data-table-pagination"; +import { DataTableToolbar } from "@/components/task-table/data-table-toolbar"; + +interface DataTableProps { + columns: ColumnDef[]; + data: TData[]; + pageCount: number; + filterableColumns?: DataTableFilterableColumn[]; + searchableColumns?: DataTableSearchableColumn[]; +} + +export function DataTable({ + columns, + data, + pageCount, + filterableColumns = [], + searchableColumns = [], +}: DataTableProps) { + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + + // Search params + const page = searchParams?.get("page") ?? "1"; + const per_page = searchParams?.get("per_page") ?? "10"; + const sort = searchParams?.get("sort"); + const [column, order] = sort?.split(".") ?? []; + + // Create query string + const createQueryString = React.useCallback( + (params: Record) => { + const newSearchParams = new URLSearchParams(searchParams?.toString()); + + for (const [key, value] of Object.entries(params)) { + if (value === null) { + newSearchParams.delete(key); + } else { + newSearchParams.set(key, String(value)); + } + } + + return newSearchParams.toString(); + }, + [searchParams] + ); + + // Table states + const [rowSelection, setRowSelection] = React.useState({}); + const [columnVisibility, setColumnVisibility] = + React.useState({}); + const [columnFilters, setColumnFilters] = React.useState( + [] + ); + + // Handle server-side pagination + const [{ pageIndex, pageSize }, setPagination] = + React.useState({ + pageIndex: Number(page) - 1, + pageSize: Number(per_page), + }); + + const pagination = React.useMemo( + () => ({ + pageIndex, + pageSize, + }), + [pageIndex, pageSize] + ); + + React.useEffect(() => { + setPagination({ + pageIndex: Number(page) - 1, + pageSize: Number(per_page), + }); + }, [page, per_page]); + + React.useEffect(() => { + router.push( + `${pathname}?${createQueryString({ + page: pageIndex + 1, + per_page: pageSize, + })}` + ); + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [pageIndex, pageSize]); + + // Handle server-side sorting + const [sorting, setSorting] = React.useState([ + { + id: column ?? "", + desc: order === "desc", + }, + ]); + + React.useEffect(() => { + router.push( + `${pathname}?${createQueryString({ + page, + sort: sorting[0]?.id + ? `${sorting[0]?.id}.${sorting[0]?.desc ? "desc" : "asc"}` + : null, + })}` + ); + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [sorting]); + + // Handle server-side filtering + const debouncedSearchableColumnFilters = JSON.parse( + useDebounce( + JSON.stringify( + columnFilters.filter((filter) => { + return searchableColumns.find((column) => column.id === filter.id); + }) + ), + 500 + ) + ) as ColumnFiltersState; + + const filterableColumnFilters = columnFilters.filter((filter) => { + return filterableColumns.find((column) => column.id === filter.id); + }); + + React.useEffect(() => { + for (const column of debouncedSearchableColumnFilters) { + if (typeof column.value === "string") { + router.push( + `${pathname}?${createQueryString({ + page: 1, + [column.id]: typeof column.value === "string" ? column.value : null, + })}` + ); + } + } + + for (const key of searchParams.keys()) { + if ( + searchableColumns.find((column) => column.id === key) && + !debouncedSearchableColumnFilters.find((column) => column.id === key) + ) { + router.push( + `${pathname}?${createQueryString({ + page: 1, + [key]: null, + })}` + ); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [JSON.stringify(debouncedSearchableColumnFilters)]); + + React.useEffect(() => { + for (const column of filterableColumnFilters) { + if (typeof column.value === "object" && Array.isArray(column.value)) { + router.push( + `${pathname}?${createQueryString({ + page: 1, + [column.id]: column.value.join("."), + })}` + ); + } + } + + for (const key of searchParams.keys()) { + if ( + filterableColumns.find((column) => column.id === key) && + !filterableColumnFilters.find((column) => column.id === key) + ) { + router.push( + `${pathname}?${createQueryString({ + page: 1, + [key]: null, + })}` + ); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [JSON.stringify(filterableColumnFilters)]); + + const table = useReactTable({ + data, + columns, + pageCount: pageCount ?? -1, + state: { + pagination, + sorting, + columnVisibility, + rowSelection, + columnFilters, + }, + enableRowSelection: true, + onRowSelectionChange: setRowSelection, + onPaginationChange: setPagination, + onSortingChange: setSorting, + onColumnFiltersChange: setColumnFilters, + onColumnVisibilityChange: setColumnVisibility, + getCoreRowModel: getCoreRowModel(), + getFilteredRowModel: getFilteredRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getSortedRowModel: getSortedRowModel(), + getFacetedRowModel: getFacetedRowModel(), + getFacetedUniqueValues: getFacetedUniqueValues(), + manualPagination: true, + manualSorting: true, + manualFiltering: true, + }); + + return ( +
+ +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ); + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + ))} + + )) + ) : ( + + + No results. + + + )} + +
+
+ +
+ ); +} diff --git a/components/ui/accordion.tsx b/components/ui/accordion.tsx new file mode 100644 index 0000000..66f095a --- /dev/null +++ b/components/ui/accordion.tsx @@ -0,0 +1,60 @@ +"use client" + +import * as React from "react" +import * as AccordionPrimitive from "@radix-ui/react-accordion" +import { ChevronDownIcon } from "@radix-ui/react-icons" + +import { cn } from "@/lib/utils" + +const Accordion = AccordionPrimitive.Root + +const AccordionItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AccordionItem.displayName = "AccordionItem" + +const AccordionTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + svg]:rotate-180", + className + )} + {...props} + > + {children} + + + +)) +AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName + +const AccordionContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + +
{children}
+
+)) +AccordionContent.displayName = AccordionPrimitive.Content.displayName + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } diff --git a/components/ui/alert-dialog.tsx b/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..82cf04d --- /dev/null +++ b/components/ui/alert-dialog.tsx @@ -0,0 +1,141 @@ +"use client" + +import * as React from "react" +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" + +import { cn } from "@/lib/utils" +import { buttonVariants } from "@/components/ui/button" + +const AlertDialog = AlertDialogPrimitive.Root + +const AlertDialogTrigger = AlertDialogPrimitive.Trigger + +const AlertDialogPortal = AlertDialogPrimitive.Portal + +const AlertDialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName + +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + +)) +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName + +const AlertDialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogHeader.displayName = "AlertDialogHeader" + +const AlertDialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogFooter.displayName = "AlertDialogFooter" + +const AlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName + +const AlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogDescription.displayName = + AlertDialogPrimitive.Description.displayName + +const AlertDialogAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName + +const AlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +} diff --git a/components/ui/alert.tsx b/components/ui/alert.tsx new file mode 100644 index 0000000..5afd41d --- /dev/null +++ b/components/ui/alert.tsx @@ -0,0 +1,59 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const alertVariants = cva( + "relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7", + { + variants: { + variant: { + default: "bg-background text-foreground", + destructive: + "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +const Alert = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & VariantProps +>(({ className, variant, ...props }, ref) => ( +
+)) +Alert.displayName = "Alert" + +const AlertTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertTitle.displayName = "AlertTitle" + +const AlertDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertDescription.displayName = "AlertDescription" + +export { Alert, AlertTitle, AlertDescription } diff --git a/components/ui/aspect-ratio.tsx b/components/ui/aspect-ratio.tsx new file mode 100644 index 0000000..d6a5226 --- /dev/null +++ b/components/ui/aspect-ratio.tsx @@ -0,0 +1,7 @@ +"use client" + +import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio" + +const AspectRatio = AspectRatioPrimitive.Root + +export { AspectRatio } diff --git a/components/ui/avatar.tsx b/components/ui/avatar.tsx new file mode 100644 index 0000000..51e507b --- /dev/null +++ b/components/ui/avatar.tsx @@ -0,0 +1,50 @@ +"use client" + +import * as React from "react" +import * as AvatarPrimitive from "@radix-ui/react-avatar" + +import { cn } from "@/lib/utils" + +const Avatar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +Avatar.displayName = AvatarPrimitive.Root.displayName + +const AvatarImage = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarImage.displayName = AvatarPrimitive.Image.displayName + +const AvatarFallback = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName + +export { Avatar, AvatarImage, AvatarFallback } diff --git a/components/ui/badge.tsx b/components/ui/badge.tsx new file mode 100644 index 0000000..e87d62b --- /dev/null +++ b/components/ui/badge.tsx @@ -0,0 +1,36 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80", + secondary: + "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", + destructive: + "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80", + outline: "text-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ) +} + +export { Badge, badgeVariants } diff --git a/components/ui/button.tsx b/components/ui/button.tsx new file mode 100644 index 0000000..4ecf369 --- /dev/null +++ b/components/ui/button.tsx @@ -0,0 +1,57 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const buttonVariants = cva( + "inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50", + { + variants: { + variant: { + default: + "bg-primary text-primary-foreground shadow hover:bg-primary/90", + destructive: + "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", + outline: + "border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground", + secondary: + "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-9 px-4 py-2", + sm: "h-8 rounded-md px-3 text-xs", + lg: "h-10 rounded-md px-8", + icon: "h-9 w-9", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button" + return ( + + ) + } +) +Button.displayName = "Button" + +export { Button, buttonVariants } diff --git a/components/ui/calendar.tsx b/components/ui/calendar.tsx new file mode 100644 index 0000000..6c08e3a --- /dev/null +++ b/components/ui/calendar.tsx @@ -0,0 +1,71 @@ +"use client" + +import * as React from "react" +import { ChevronLeftIcon, ChevronRightIcon } from "@radix-ui/react-icons" +import { DayPicker } from "react-day-picker" + +import { cn } from "@/lib/utils" +import { buttonVariants } from "@/components/ui/button" + +export type CalendarProps = React.ComponentProps + +function Calendar({ + className, + classNames, + showOutsideDays = true, + ...props +}: CalendarProps) { + return ( + .day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md" + : "[&:has([aria-selected])]:rounded-md" + ), + day: cn( + buttonVariants({ variant: "ghost" }), + "h-8 w-8 p-0 font-normal aria-selected:opacity-100" + ), + day_range_start: "day-range-start", + day_range_end: "day-range-end", + day_selected: + "bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground", + day_today: "bg-accent text-accent-foreground", + day_outside: "text-muted-foreground opacity-50", + day_disabled: "text-muted-foreground opacity-50", + day_range_middle: + "aria-selected:bg-accent aria-selected:text-accent-foreground", + day_hidden: "invisible", + ...classNames, + }} + components={{ + IconLeft: ({ ...props }) => , + IconRight: ({ ...props }) => , + }} + {...props} + /> + ) +} +Calendar.displayName = "Calendar" + +export { Calendar } diff --git a/components/ui/card.tsx b/components/ui/card.tsx new file mode 100644 index 0000000..77e9fb7 --- /dev/null +++ b/components/ui/card.tsx @@ -0,0 +1,76 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +const Card = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +Card.displayName = "Card" + +const CardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardHeader.displayName = "CardHeader" + +const CardTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardTitle.displayName = "CardTitle" + +const CardDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardDescription.displayName = "CardDescription" + +const CardContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardContent.displayName = "CardContent" + +const CardFooter = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardFooter.displayName = "CardFooter" + +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } diff --git a/components/ui/checkbox.tsx b/components/ui/checkbox.tsx new file mode 100644 index 0000000..7d2b3c3 --- /dev/null +++ b/components/ui/checkbox.tsx @@ -0,0 +1,30 @@ +"use client" + +import * as React from "react" +import * as CheckboxPrimitive from "@radix-ui/react-checkbox" +import { CheckIcon } from "@radix-ui/react-icons" + +import { cn } from "@/lib/utils" + +const Checkbox = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + +)) +Checkbox.displayName = CheckboxPrimitive.Root.displayName + +export { Checkbox } diff --git a/components/ui/collapsible.tsx b/components/ui/collapsible.tsx new file mode 100644 index 0000000..9fa4894 --- /dev/null +++ b/components/ui/collapsible.tsx @@ -0,0 +1,11 @@ +"use client" + +import * as CollapsiblePrimitive from "@radix-ui/react-collapsible" + +const Collapsible = CollapsiblePrimitive.Root + +const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger + +const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent + +export { Collapsible, CollapsibleTrigger, CollapsibleContent } diff --git a/components/ui/command.tsx b/components/ui/command.tsx new file mode 100644 index 0000000..6f4a5eb --- /dev/null +++ b/components/ui/command.tsx @@ -0,0 +1,155 @@ +"use client" + +import * as React from "react" +import { DialogProps } from "@radix-ui/react-dialog" +import { MagnifyingGlassIcon } from "@radix-ui/react-icons" +import { Command as CommandPrimitive } from "cmdk" + +import { cn } from "@/lib/utils" +import { Dialog, DialogContent } from "@/components/ui/dialog" + +const Command = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +Command.displayName = CommandPrimitive.displayName + +interface CommandDialogProps extends DialogProps {} + +const CommandDialog = ({ children, ...props }: CommandDialogProps) => { + return ( + + + + {children} + + + + ) +} + +const CommandInput = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( +
+ + +
+)) + +CommandInput.displayName = CommandPrimitive.Input.displayName + +const CommandList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) + +CommandList.displayName = CommandPrimitive.List.displayName + +const CommandEmpty = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>((props, ref) => ( + +)) + +CommandEmpty.displayName = CommandPrimitive.Empty.displayName + +const CommandGroup = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) + +CommandGroup.displayName = CommandPrimitive.Group.displayName + +const CommandSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +CommandSeparator.displayName = CommandPrimitive.Separator.displayName + +const CommandItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) + +CommandItem.displayName = CommandPrimitive.Item.displayName + +const CommandShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ) +} +CommandShortcut.displayName = "CommandShortcut" + +export { + Command, + CommandDialog, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, + CommandShortcut, + CommandSeparator, +} diff --git a/components/ui/context-menu.tsx b/components/ui/context-menu.tsx new file mode 100644 index 0000000..654810a --- /dev/null +++ b/components/ui/context-menu.tsx @@ -0,0 +1,204 @@ +"use client" + +import * as React from "react" +import * as ContextMenuPrimitive from "@radix-ui/react-context-menu" +import { + CheckIcon, + ChevronRightIcon, + DotFilledIcon, +} from "@radix-ui/react-icons" + +import { cn } from "@/lib/utils" + +const ContextMenu = ContextMenuPrimitive.Root + +const ContextMenuTrigger = ContextMenuPrimitive.Trigger + +const ContextMenuGroup = ContextMenuPrimitive.Group + +const ContextMenuPortal = ContextMenuPrimitive.Portal + +const ContextMenuSub = ContextMenuPrimitive.Sub + +const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup + +const ContextMenuSubTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)) +ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName + +const ContextMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName + +const ContextMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName + +const ContextMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName + +const ContextMenuCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)) +ContextMenuCheckboxItem.displayName = + ContextMenuPrimitive.CheckboxItem.displayName + +const ContextMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)) +ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName + +const ContextMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName + +const ContextMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName + +const ContextMenuShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ) +} +ContextMenuShortcut.displayName = "ContextMenuShortcut" + +export { + ContextMenu, + ContextMenuTrigger, + ContextMenuContent, + ContextMenuItem, + ContextMenuCheckboxItem, + ContextMenuRadioItem, + ContextMenuLabel, + ContextMenuSeparator, + ContextMenuShortcut, + ContextMenuGroup, + ContextMenuPortal, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, + ContextMenuRadioGroup, +} diff --git a/components/ui/dialog.tsx b/components/ui/dialog.tsx new file mode 100644 index 0000000..cf284e9 --- /dev/null +++ b/components/ui/dialog.tsx @@ -0,0 +1,119 @@ +"use client" + +import * as React from "react" +import * as DialogPrimitive from "@radix-ui/react-dialog" +import { Cross2Icon } from "@radix-ui/react-icons" + +import { cn } from "@/lib/utils" + +const Dialog = DialogPrimitive.Root + +const DialogTrigger = DialogPrimitive.Trigger + +const DialogPortal = DialogPrimitive.Portal + +const DialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)) +DialogContent.displayName = DialogPrimitive.Content.displayName + +const DialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DialogHeader.displayName = "DialogHeader" + +const DialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DialogFooter.displayName = "DialogFooter" + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogTitle.displayName = DialogPrimitive.Title.displayName + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogDescription.displayName = DialogPrimitive.Description.displayName + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogTrigger, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +} diff --git a/components/ui/dropdown-menu.tsx b/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..242b07a --- /dev/null +++ b/components/ui/dropdown-menu.tsx @@ -0,0 +1,205 @@ +"use client" + +import * as React from "react" +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" +import { + CheckIcon, + ChevronRightIcon, + DotFilledIcon, +} from "@radix-ui/react-icons" + +import { cn } from "@/lib/utils" + +const DropdownMenu = DropdownMenuPrimitive.Root + +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger + +const DropdownMenuGroup = DropdownMenuPrimitive.Group + +const DropdownMenuPortal = DropdownMenuPrimitive.Portal + +const DropdownMenuSub = DropdownMenuPrimitive.Sub + +const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup + +const DropdownMenuSubTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)) +DropdownMenuSubTrigger.displayName = + DropdownMenuPrimitive.SubTrigger.displayName + +const DropdownMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DropdownMenuSubContent.displayName = + DropdownMenuPrimitive.SubContent.displayName + +const DropdownMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)) +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName + +const DropdownMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName + +const DropdownMenuCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)) +DropdownMenuCheckboxItem.displayName = + DropdownMenuPrimitive.CheckboxItem.displayName + +const DropdownMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)) +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName + +const DropdownMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName + +const DropdownMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName + +const DropdownMenuShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ) +} +DropdownMenuShortcut.displayName = "DropdownMenuShortcut" + +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuGroup, + DropdownMenuPortal, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuRadioGroup, +} diff --git a/components/ui/form.tsx b/components/ui/form.tsx new file mode 100644 index 0000000..f6afdaf --- /dev/null +++ b/components/ui/form.tsx @@ -0,0 +1,176 @@ +import * as React from "react" +import * as LabelPrimitive from "@radix-ui/react-label" +import { Slot } from "@radix-ui/react-slot" +import { + Controller, + ControllerProps, + FieldPath, + FieldValues, + FormProvider, + useFormContext, +} from "react-hook-form" + +import { cn } from "@/lib/utils" +import { Label } from "@/components/ui/label" + +const Form = FormProvider + +type FormFieldContextValue< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath +> = { + name: TName +} + +const FormFieldContext = React.createContext( + {} as FormFieldContextValue +) + +const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath +>({ + ...props +}: ControllerProps) => { + return ( + + + + ) +} + +const useFormField = () => { + const fieldContext = React.useContext(FormFieldContext) + const itemContext = React.useContext(FormItemContext) + const { getFieldState, formState } = useFormContext() + + const fieldState = getFieldState(fieldContext.name, formState) + + if (!fieldContext) { + throw new Error("useFormField should be used within ") + } + + const { id } = itemContext + + return { + id, + name: fieldContext.name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + ...fieldState, + } +} + +type FormItemContextValue = { + id: string +} + +const FormItemContext = React.createContext( + {} as FormItemContextValue +) + +const FormItem = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + const id = React.useId() + + return ( + +
+ + ) +}) +FormItem.displayName = "FormItem" + +const FormLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + const { error, formItemId } = useFormField() + + return ( +