mirror of
https://github.com/SkalaraAI/skbeta.git
synced 2025-04-09 15:00:18 -04:00
initial commit
This commit is contained in:
commit
c55ab3d49e
3
.eslintrc.json
Normal file
3
.eslintrc.json
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"extends": "next/core-web-vitals"
|
||||||
|
}
|
36
.gitignore
vendored
Normal file
36
.gitignore
vendored
Normal file
|
@ -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
|
21
app/auth/callback/route.ts
Normal file
21
app/auth/callback/route.ts
Normal file
|
@ -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<Database>({
|
||||||
|
cookies: () => cookieStore,
|
||||||
|
});
|
||||||
|
await supabase.auth.exchangeCodeForSession(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.redirect(requestUrl.origin);
|
||||||
|
}
|
26
app/auth/login/route.ts
Normal file
26
app/auth/login/route.ts
Normal file
|
@ -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 }
|
||||||
|
);
|
||||||
|
}
|
19
app/auth/logout/route.ts
Normal file
19
app/auth/logout/route.ts
Normal file
|
@ -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<Database>({
|
||||||
|
cookies: () => cookieStore,
|
||||||
|
});
|
||||||
|
|
||||||
|
await supabase.auth.signOut();
|
||||||
|
|
||||||
|
return NextResponse.redirect(`${requestUrl.origin}/auth`, {
|
||||||
|
status: 301,
|
||||||
|
});
|
||||||
|
}
|
223
app/auth/page.tsx
Normal file
223
app/auth/page.tsx
Normal file
|
@ -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<z.infer<typeof authSchema>>({
|
||||||
|
resolver: zodResolver(authSchema),
|
||||||
|
defaultValues: {
|
||||||
|
email: "",
|
||||||
|
password: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
async function handleSignup(values: z.infer<typeof authSchema>) {
|
||||||
|
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<typeof authSchema>) {
|
||||||
|
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 (
|
||||||
|
<div className="flex flex-col min-w-screen min-h-screen px-8 justify-center items-center gap-2">
|
||||||
|
<Tabs defaultValue="login" className="w-[400px]">
|
||||||
|
<TabsList className="grid w-full grid-cols-2">
|
||||||
|
<TabsTrigger value="login">Login</TabsTrigger>
|
||||||
|
<TabsTrigger value="signup">Signup</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="login">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Login to your account</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Enter your email and password below to login.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
className="space-y-2"
|
||||||
|
onSubmit={form.handleSubmit(handleLogin)}
|
||||||
|
>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="email"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Email</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="password"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Password</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
placeholder="••••••••"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter>
|
||||||
|
<Button type="submit">Login</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="signup">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Create your account</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Enter your email and password below to create an account.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
className="space-y-2"
|
||||||
|
onSubmit={form.handleSubmit(handleSignup)}
|
||||||
|
>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="email"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Email</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="password"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Password</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
placeholder="••••••••"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter>
|
||||||
|
<Button type="submit">Signup</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
52
app/auth/signup/route.ts
Normal file
52
app/auth/signup/route.ts
Normal file
|
@ -0,0 +1,52 @@
|
||||||
|
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";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
// add check on beta_testers table to see if user is a beta tester. if they aren't, then don't allow them to sign up
|
||||||
|
|
||||||
|
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 checkBeta = await prisma.beta_testers.findFirst({
|
||||||
|
where: {
|
||||||
|
email: email,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!checkBeta) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "You are not a beta tester" },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 }
|
||||||
|
);
|
||||||
|
}
|
46
app/chat/[taskID]/route.ts
Normal file
46
app/chat/[taskID]/route.ts
Normal file
|
@ -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<Database>({ 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 });
|
||||||
|
}
|
||||||
|
}
|
48
app/chat/add/route.ts
Normal file
48
app/chat/add/route.ts
Normal file
|
@ -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<Database>({ 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 });
|
||||||
|
}
|
||||||
|
}
|
40
app/chat/getTask/route.ts
Normal file
40
app/chat/getTask/route.ts
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
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 task by task id as param
|
||||||
|
export async function POST(req: NextRequest, res: NextResponse) {
|
||||||
|
const supabase = createRouteHandlerClient<Database>({ cookies });
|
||||||
|
const session = await getSession(supabase);
|
||||||
|
|
||||||
|
if (!session) return NextResponse.redirect("/auth");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const req_data = await req.json();
|
||||||
|
const task_id = String(req_data.task_id);
|
||||||
|
|
||||||
|
const task = await prisma.task.findUnique({
|
||||||
|
where: {
|
||||||
|
id: BigInt(task_id),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!task) {
|
||||||
|
return NextResponse.json({ error: "No task found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ task }, { status: 200 });
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
return NextResponse.json({ error: err }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
63
app/chat/route.ts
Normal file
63
app/chat/route.ts
Normal file
|
@ -0,0 +1,63 @@
|
||||||
|
// 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.taskInfo);
|
||||||
|
|
||||||
|
// 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.name}
|
||||||
|
- Description: ${body.taskInfo.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 });
|
||||||
|
}
|
||||||
|
}
|
14
app/dashboard/layout.tsx
Normal file
14
app/dashboard/layout.tsx
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
import { WorkspaceSelector } from "@/components/workspace-selector";
|
||||||
|
|
||||||
|
export default function DashboardLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<main className="w-screen max-w-screen flex flex-row">
|
||||||
|
<WorkspaceSelector />
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
7
app/dashboard/page.tsx
Normal file
7
app/dashboard/page.tsx
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
export default function Dashboard() {
|
||||||
|
return (
|
||||||
|
<div className="p-4">
|
||||||
|
<h1 className="font-semibold">Dashboard capabilities coming soon...</h1>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
44
app/layout.tsx
Normal file
44
app/layout.tsx
Normal file
|
@ -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 (
|
||||||
|
<html lang="en" suppressHydrationWarning>
|
||||||
|
<head />
|
||||||
|
<body
|
||||||
|
className={cn(
|
||||||
|
"min-h-screen bg-background font-sans antialiased",
|
||||||
|
fontSans.variable
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ThemeProvider
|
||||||
|
attribute="class"
|
||||||
|
defaultTheme="system"
|
||||||
|
enableSystem
|
||||||
|
disableTransitionOnChange
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<Toaster />
|
||||||
|
</ThemeProvider>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
105
app/page.tsx
Normal file
105
app/page.tsx
Normal file
|
@ -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 (
|
||||||
|
<main className="flex flex-col w-screen justify-center items-center">
|
||||||
|
<div className="w-full flex flex-col items-center h-screen">
|
||||||
|
<nav className="w-full flex justify-center h-16">
|
||||||
|
<div className="w-3/5 flex justify-between items-center p-4 text-sm text-foreground">
|
||||||
|
<div className="flex flex-row space-x-2 justify-center items-center">
|
||||||
|
<svg
|
||||||
|
fill="#7C3AED"
|
||||||
|
width="32px"
|
||||||
|
height="32px"
|
||||||
|
viewBox="0 0 15 15"
|
||||||
|
version="1.1"
|
||||||
|
id="circle"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path d="M14,7.5c0,3.5899-2.9101,6.5-6.5,6.5S1,11.0899,1,7.5S3.9101,1,7.5,1S14,3.9101,14,7.5z" />
|
||||||
|
</svg>
|
||||||
|
<h1 className="text-lg font-extrabold text-secondary-foreground">
|
||||||
|
SKALARA
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{!user ? (
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Link
|
||||||
|
href="/auth"
|
||||||
|
className={buttonVariants({ variant: "default" })}
|
||||||
|
>
|
||||||
|
Login
|
||||||
|
</Link>
|
||||||
|
<ThemeToggle />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Link
|
||||||
|
className={cn(buttonVariants({ variant: "default" }))}
|
||||||
|
href="/dashboard"
|
||||||
|
>
|
||||||
|
Dashboard
|
||||||
|
</Link>
|
||||||
|
<Logout />
|
||||||
|
<ThemeToggle />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="flex flex-col text-foreground bg-cover w-full h-full justify-center items-center">
|
||||||
|
<div className="flex flex-col items-center space-y-6 text-center z-10">
|
||||||
|
<h1 className="text-8xl font-bold">
|
||||||
|
Simple. Intelligent. Automated.
|
||||||
|
</h1>
|
||||||
|
<p className="text-3xl tracking-wide">
|
||||||
|
AI-powered project management for indie developers.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-col w-1/2 space-y-4 md:flex-row md:space-x-4 md:space-y-0">
|
||||||
|
<Input
|
||||||
|
className="bg-secondary shadow-inner"
|
||||||
|
placeholder="example@company.com"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
className="shadow-lg shadow-primary/30 w-full md:w-2/3"
|
||||||
|
>
|
||||||
|
Join Waitlist
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<GridPattern
|
||||||
|
width={60}
|
||||||
|
height={60}
|
||||||
|
x={-1}
|
||||||
|
y={-1}
|
||||||
|
strokeDasharray={"4 2"}
|
||||||
|
className="[mask-image:linear-gradient(to_top,transparent,white_45%,transparent_90%,transparent)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
35
app/w/[workspaceID]/delete/route.ts
Normal file
35
app/w/[workspaceID]/delete/route.ts
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
export async function DELETE(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params: { workspaceID } }: { params: { workspaceID: string } }
|
||||||
|
) {
|
||||||
|
const supabase = createRouteHandlerClient<Database>({ cookies });
|
||||||
|
const session = await getSession(supabase);
|
||||||
|
|
||||||
|
if (!session) return NextResponse.redirect("/auth");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed_workspace_id = BigInt(workspaceID);
|
||||||
|
|
||||||
|
await prisma.workspace.delete({
|
||||||
|
where: { id: parsed_workspace_id },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true }, { status: 200 });
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
return NextResponse.json({ error: err }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
21
app/w/[workspaceID]/layout.tsx
Normal file
21
app/w/[workspaceID]/layout.tsx
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
import { WorkspaceSelector } from "@/components/workspace-selector";
|
||||||
|
import { WorkspaceSidebar } from "@/components/workspace-sidebar";
|
||||||
|
export default function WorkspaceLayout({
|
||||||
|
children,
|
||||||
|
params: { workspaceID },
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
params: { workspaceID: string };
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-row">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<WorkspaceSelector workspaceID={workspaceID} />
|
||||||
|
</div>
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<WorkspaceSidebar workspaceID={workspaceID} />
|
||||||
|
</div>
|
||||||
|
<div className="flex-grow min-w-0">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
39
app/w/[workspaceID]/p/[projectID]/features/add/route.ts
Normal file
39
app/w/[workspaceID]/p/[projectID]/features/add/route.ts
Normal file
|
@ -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<Database>({ 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 });
|
||||||
|
}
|
||||||
|
}
|
109
app/w/[workspaceID]/p/[projectID]/features/gen/deps/route.ts
Normal file
109
app/w/[workspaceID]/p/[projectID]/features/gen/deps/route.ts
Normal file
|
@ -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<Database>({ 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 });
|
||||||
|
}
|
||||||
|
}
|
76
app/w/[workspaceID]/p/[projectID]/features/gen/route.ts
Normal file
76
app/w/[workspaceID]/p/[projectID]/features/gen/route.ts
Normal file
|
@ -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<Database>({ 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 });
|
||||||
|
}
|
||||||
|
}
|
113
app/w/[workspaceID]/p/[projectID]/features/gen/tasks/route.ts
Normal file
113
app/w/[workspaceID]/p/[projectID]/features/gen/tasks/route.ts
Normal file
|
@ -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<Database>({ 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 });
|
||||||
|
}
|
||||||
|
}
|
50
app/w/[workspaceID]/p/[projectID]/features/route.ts
Normal file
50
app/w/[workspaceID]/p/[projectID]/features/route.ts
Normal file
|
@ -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<Database>({ 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 });
|
||||||
|
}
|
||||||
|
}
|
113
app/w/[workspaceID]/p/[projectID]/gen/route.ts
Normal file
113
app/w/[workspaceID]/p/[projectID]/gen/route.ts
Normal file
|
@ -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<Database>({ 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<Database>({ 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 });
|
||||||
|
}
|
||||||
|
}
|
10
app/w/[workspaceID]/p/[projectID]/layout.tsx
Normal file
10
app/w/[workspaceID]/p/[projectID]/layout.tsx
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
import { WorkspaceSidebar } from "@/components/workspace-sidebar";
|
||||||
|
export default function WorkspaceLayout({
|
||||||
|
children,
|
||||||
|
params: { workspaceID, projectID },
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
params: { workspaceID: string; projectID: string };
|
||||||
|
}) {
|
||||||
|
return <div className="flex flex-row w-full">{children}</div>;
|
||||||
|
}
|
254
app/w/[workspaceID]/p/[projectID]/page.tsx
Normal file
254
app/w/[workspaceID]/p/[projectID]/page.tsx
Normal file
|
@ -0,0 +1,254 @@
|
||||||
|
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";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { FeatureList } from "@/components/feature-list";
|
||||||
|
|
||||||
|
async function getSession(supabase: any) {
|
||||||
|
const {
|
||||||
|
data: { session },
|
||||||
|
} = await supabase.auth.getSession();
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
// async function getProject(projectID: string) {
|
||||||
|
// const supabase = createServerComponentClient<Database>({ 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);
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
async function getProject(projectID: string) {
|
||||||
|
const supabase = createServerComponentClient<Database>({ cookies });
|
||||||
|
const session = await getSession(supabase);
|
||||||
|
|
||||||
|
if (!session) redirect("/auth");
|
||||||
|
|
||||||
|
try {
|
||||||
|
// const project = await prisma.project.findFirst({
|
||||||
|
// where: {
|
||||||
|
// id: BigInt(projectID),
|
||||||
|
// profile_project: {
|
||||||
|
// some: {
|
||||||
|
// profile_id: session.user.id,
|
||||||
|
// },
|
||||||
|
// },
|
||||||
|
// },
|
||||||
|
// include: {
|
||||||
|
// task: {
|
||||||
|
// include: {
|
||||||
|
// feature: true, // Assuming 'feature' is correctly named relation
|
||||||
|
// },
|
||||||
|
// },
|
||||||
|
// },
|
||||||
|
// });
|
||||||
|
|
||||||
|
// if (!project) {
|
||||||
|
// return NextResponse.json({ error: "not_found" }, { status: 404 });
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // Serialize BigInts and include project and feature information
|
||||||
|
// const serializedProject = {
|
||||||
|
// id: project.id.toString(),
|
||||||
|
// workspace_id: project.workspace_id.toString(),
|
||||||
|
// created_at: project.created_at,
|
||||||
|
// name: project.name,
|
||||||
|
// description: project.description,
|
||||||
|
// stack: project.stack,
|
||||||
|
// questions: project.questions,
|
||||||
|
// tasks: project.task.map(({ feature, ...task }) => ({
|
||||||
|
// ...task,
|
||||||
|
// id: task.id.toString(),
|
||||||
|
// project_id: task.project_id?.toString(),
|
||||||
|
// feature_id: feature?.id.toString(),
|
||||||
|
// feature_name: feature?.name, // Assume feature is nullable
|
||||||
|
// feature_description: feature?.description, // Assume feature is nullable
|
||||||
|
// // ...include other task fields that may be BigInt or require serialization
|
||||||
|
// })),
|
||||||
|
// };
|
||||||
|
|
||||||
|
// console.log("Serialized Project", serializedProject);
|
||||||
|
|
||||||
|
// return NextResponse.json(serializedProject, { status: 200 });
|
||||||
|
const project = await prisma.project.findFirst({
|
||||||
|
where: {
|
||||||
|
id: BigInt(projectID),
|
||||||
|
// ... other conditions
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
task: {
|
||||||
|
include: {
|
||||||
|
feature: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
return NextResponse.json({ error: "not_found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map tasks to conform to the taskSchema
|
||||||
|
const tasksWithDetails = project.task.map((task) => {
|
||||||
|
return {
|
||||||
|
id: task.id.toString(),
|
||||||
|
name: task.name,
|
||||||
|
description: task.description,
|
||||||
|
project_id: project.id.toString(),
|
||||||
|
project_name: project.name,
|
||||||
|
project_description: project.description,
|
||||||
|
project_stack: project.stack,
|
||||||
|
feature_id: task.feature_id?.toString() || null,
|
||||||
|
feature_name: task.feature?.name || null,
|
||||||
|
feature_description: task.feature?.description || null,
|
||||||
|
status: task.status,
|
||||||
|
priority: task.priority || null,
|
||||||
|
order: task.order, // Convert order to number if it exists
|
||||||
|
due_date: task.due_date ? task.due_date.toISOString() : null, // Convert to ISO string if due_date exists
|
||||||
|
assignee: task.assignee || null,
|
||||||
|
// Add serialization for any other fields that are required by the task schema
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = {
|
||||||
|
id: project.id.toString(),
|
||||||
|
name: project.name,
|
||||||
|
description: project.description,
|
||||||
|
stack: project.stack,
|
||||||
|
workspace_id: project.workspace_id.toString(),
|
||||||
|
// ...include other project-level details as needed
|
||||||
|
tasks: tasksWithDetails, // Include the detailed tasks list
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("Project with Tasks", response);
|
||||||
|
|
||||||
|
return NextResponse.json(response, { status: 200 });
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
return NextResponse.json({ error: "server_error" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div>
|
||||||
|
<h1>Project not found.</h1>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!raw_project) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>Something went wrong.</h1>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const project = await raw_project.json();
|
||||||
|
console.log("AWAITED PROJECT", project);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full h-full flex flex-col">
|
||||||
|
<div className="w-full p-4 border-b flex flex-row justify-between items-center">
|
||||||
|
<h1 className="text-lg font-semibold">{project.name}</h1>
|
||||||
|
<div className="flex flex-row justify-center items-center space-x-2">
|
||||||
|
<Dialog>
|
||||||
|
<Button asChild variant="outline">
|
||||||
|
<DialogTrigger>View Features</DialogTrigger>
|
||||||
|
</Button>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Features</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<FeatureList projectID={projectID} />
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
<ThemeToggle />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<GenerateProject
|
||||||
|
project_name={project.name}
|
||||||
|
project_description={project.description}
|
||||||
|
project_stack={project.stack}
|
||||||
|
/>
|
||||||
|
{/* <KanbanBoard /> */}
|
||||||
|
<div className="p-12 max-h-full">
|
||||||
|
<Tasks tasks={project.tasks ? project.tasks : []} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
49
app/w/[workspaceID]/p/[projectID]/tasks/add/route.ts
Normal file
49
app/w/[workspaceID]/p/[projectID]/tasks/add/route.ts
Normal file
|
@ -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<Database>({ 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 });
|
||||||
|
}
|
||||||
|
}
|
44
app/w/[workspaceID]/p/[projectID]/tasks/assign/route.ts
Normal file
44
app/w/[workspaceID]/p/[projectID]/tasks/assign/route.ts
Normal file
|
@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// assign task to user using profile_task junction table
|
||||||
|
export async function POST(
|
||||||
|
req: NextRequest,
|
||||||
|
{
|
||||||
|
params: { workspaceID, projectID },
|
||||||
|
}: { params: { workspaceID: string; projectID: string } }
|
||||||
|
) {
|
||||||
|
const supabase = createRouteHandlerClient<Database>({ cookies });
|
||||||
|
const session = await getSession(supabase);
|
||||||
|
|
||||||
|
if (!session) return NextResponse.redirect("/auth");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const req_data = await req.json();
|
||||||
|
const { task_id, user_id } = req_data;
|
||||||
|
|
||||||
|
const parsed_task_id = BigInt(task_id);
|
||||||
|
|
||||||
|
await prisma.profile_task.create({
|
||||||
|
data: {
|
||||||
|
profile_id: user_id,
|
||||||
|
task_id: parsed_task_id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true }, { status: 200 });
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
return NextResponse.json({ error: err }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
38
app/w/[workspaceID]/p/[projectID]/tasks/delete/route.ts
Normal file
38
app/w/[workspaceID]/p/[projectID]/tasks/delete/route.ts
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
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 DELETE(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params: { projectID } }: { params: { projectID: string } }
|
||||||
|
) {
|
||||||
|
const supabase = createRouteHandlerClient<Database>({ cookies });
|
||||||
|
const session = await getSession(supabase);
|
||||||
|
|
||||||
|
if (!session) return NextResponse.redirect("/auth");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const req_data = await req.json();
|
||||||
|
const { id } = req_data;
|
||||||
|
|
||||||
|
const parsed_id = BigInt(id);
|
||||||
|
|
||||||
|
await prisma.task.delete({
|
||||||
|
where: { id: parsed_id },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true }, { status: 200 });
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
return NextResponse.json({ error: err }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
42
app/w/[workspaceID]/p/[projectID]/tasks/mutate/route.ts
Normal file
42
app/w/[workspaceID]/p/[projectID]/tasks/mutate/route.ts
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// update task based on whatever is passed in
|
||||||
|
export async function PUT(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params: { projectID } }: { params: { projectID: string } }
|
||||||
|
) {
|
||||||
|
const supabase = createRouteHandlerClient<Database>({ cookies });
|
||||||
|
const session = await getSession(supabase);
|
||||||
|
|
||||||
|
if (!session) return NextResponse.redirect("/auth");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const req_data = await req.json();
|
||||||
|
const { id, ...task_updates } = req_data;
|
||||||
|
|
||||||
|
console.log("TASKS IN MUTATE TASKS ROUTE ===>", task_updates);
|
||||||
|
|
||||||
|
const parsed_id = BigInt(id);
|
||||||
|
|
||||||
|
await prisma.task.update({
|
||||||
|
where: { id: parsed_id },
|
||||||
|
data: task_updates,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true }, { status: 200 });
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
return NextResponse.json({ error: err }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
58
app/w/[workspaceID]/p/route.ts
Normal file
58
app/w/[workspaceID]/p/route.ts
Normal file
|
@ -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<Database>({ 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 });
|
||||||
|
}
|
||||||
|
}
|
73
app/w/[workspaceID]/page.tsx
Normal file
73
app/w/[workspaceID]/page.tsx
Normal file
|
@ -0,0 +1,73 @@
|
||||||
|
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 { Button } from "@/components/ui/button";
|
||||||
|
import { CreateProject } from "@/components/create-project";
|
||||||
|
|
||||||
|
async function getSession(supabase: any) {
|
||||||
|
const {
|
||||||
|
data: { session },
|
||||||
|
} = await supabase.auth.getSession();
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getWorkspace(workspaceID: string) {
|
||||||
|
const supabase = createServerComponentClient<Database>({ 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 (
|
||||||
|
<div>
|
||||||
|
<h1>Workspace not found.</h1>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else if (workspace.error == "unauthorized") {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>You are not a member of this workspace.</h1>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else if (workspace.error) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>Something went wrong.</h1>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-row w-full h-screen justify-center items-center">
|
||||||
|
{/* <WorkspaceSidebar workspaceID={workspaceID} />
|
||||||
|
<h1>Workspace Page</h1> */}
|
||||||
|
<div className="border-2 border-dashed h-[400px] w-[600px] rounded-md flex flex-col justify-center items-center">
|
||||||
|
<CreateProject />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
70
app/w/[workspaceID]/user/add/route.ts
Normal file
70
app/w/[workspaceID]/user/add/route.ts
Normal file
|
@ -0,0 +1,70 @@
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// function that takes in email, checks if there is a user associated. if there is, get the user id and add it to the profile_workspace junction table.
|
||||||
|
export async function POST(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params: { workspaceID } }: { params: { workspaceID: string } }
|
||||||
|
) {
|
||||||
|
const supabase = createRouteHandlerClient<Database>({ cookies });
|
||||||
|
const session = await getSession(supabase);
|
||||||
|
|
||||||
|
if (!session) return NextResponse.redirect("/auth");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const req_data = await req.json();
|
||||||
|
const email = String(req_data.email);
|
||||||
|
|
||||||
|
const user = await prisma.profile.findFirst({
|
||||||
|
where: {
|
||||||
|
email,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "User does not exist" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.profile_workspace.create({
|
||||||
|
data: {
|
||||||
|
profile_id: user.id,
|
||||||
|
workspace_id: BigInt(workspaceID),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const projects = await prisma.project.findMany({
|
||||||
|
where: {
|
||||||
|
workspace_id: BigInt(workspaceID),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add the user to all projects within the workspace
|
||||||
|
for (const project of projects) {
|
||||||
|
await prisma.profile_project.create({
|
||||||
|
data: {
|
||||||
|
profile_id: user.id,
|
||||||
|
project_id: project.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ message: "Success!" }, { status: 200 });
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
return NextResponse.json({ error: err }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
57
app/w/[workspaceID]/user/fetch/route.ts
Normal file
57
app/w/[workspaceID]/user/fetch/route.ts
Normal file
|
@ -0,0 +1,57 @@
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// function that takes in workspaceID and gets all profile ids associated with that workspace id in the profile_workspace junction table. then, get all the profile emails associated with those profile ids. return the emails as an array.
|
||||||
|
export async function GET(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params: { workspaceID } }: { params: { workspaceID: string } }
|
||||||
|
) {
|
||||||
|
const supabase = createRouteHandlerClient<Database>({ cookies });
|
||||||
|
const session = await getSession(supabase);
|
||||||
|
|
||||||
|
if (!session) return NextResponse.redirect("/auth");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const profile_workspaces = await prisma.profile_workspace.findMany({
|
||||||
|
where: {
|
||||||
|
workspace_id: BigInt(workspaceID),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const profile_ids = profile_workspaces.map((profile_workspace) => {
|
||||||
|
return profile_workspace.profile_id;
|
||||||
|
});
|
||||||
|
|
||||||
|
const profiles = await prisma.profile.findMany({
|
||||||
|
where: {
|
||||||
|
id: {
|
||||||
|
in: profile_ids,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// get all emails and profile ids
|
||||||
|
const emails = profiles.map((profile) => {
|
||||||
|
return {
|
||||||
|
email: profile.email,
|
||||||
|
id: profile.id,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ emails }, { status: 200 });
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
return NextResponse.json({ error: err }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
73
app/w/route.ts
Normal file
73
app/w/route.ts
Normal file
|
@ -0,0 +1,73 @@
|
||||||
|
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<Database>({ 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// delete workspace
|
||||||
|
export async function DELETE(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params: { workspaceID } }: { params: { workspaceID: string } }
|
||||||
|
) {
|
||||||
|
const supabase = createRouteHandlerClient<Database>({ cookies });
|
||||||
|
const session = await getSession(supabase);
|
||||||
|
|
||||||
|
if (!session) return NextResponse.redirect("/auth");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed_workspace_id = BigInt(workspaceID);
|
||||||
|
|
||||||
|
await prisma.workspace.delete({
|
||||||
|
where: { id: parsed_workspace_id },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true }, { status: 200 });
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
return NextResponse.json({ error: err }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
16
components.json
Normal file
16
components.json
Normal file
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
151
components/chat.tsx
Normal file
151
components/chat.tsx
Normal file
|
@ -0,0 +1,151 @@
|
||||||
|
"use client";
|
||||||
|
import { useEffect, useState, useRef } 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 scrollRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Function to scroll to the bottom of the chat
|
||||||
|
// add typing
|
||||||
|
|
||||||
|
const scrollToBottom = () => {
|
||||||
|
const scroll = scrollRef.current as any;
|
||||||
|
if (scroll) {
|
||||||
|
scroll.scrollTop = scroll.scrollHeight;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Call scrollToBottom whenever messages change
|
||||||
|
scrollToBottom();
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
const inputLength = input.trim().length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card className="rounded-md">
|
||||||
|
<CardHeader className="flex flex-row items-center">
|
||||||
|
<h1 className="font-semibold text-xl">Chat with this task.</h1>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent>
|
||||||
|
<ScrollArea ref={scrollRef} className="max-h-[20rem] overflow-y-auto">
|
||||||
|
<div className="space-y-4">
|
||||||
|
{messages.map((message, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={cn(
|
||||||
|
"flex w-fit max-w-[75%] flex-col gap-2 rounded-md px-3 py-2 text-sm",
|
||||||
|
message.role === "user"
|
||||||
|
? "ml-auto bg-primary text-primary-foreground"
|
||||||
|
: "bg-muted"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{message.content}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</CardContent>
|
||||||
|
|
||||||
|
<CardFooter>
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="flex w-full items-center space-x-2"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id="message"
|
||||||
|
placeholder="Type your message..."
|
||||||
|
className="flex-1"
|
||||||
|
autoComplete="off"
|
||||||
|
value={input}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
/>
|
||||||
|
<Button type="submit" size="icon" disabled={inputLength === 0}>
|
||||||
|
<Send className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Send</span>
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
60
components/create-feature.tsx
Normal file
60
components/create-feature.tsx
Normal file
|
@ -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<z.infer<typeof createFeatureSchema>>({
|
||||||
|
resolver: zodResolver(createFeatureSchema),
|
||||||
|
defaultValues: {
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
async function createFeature(values: z.infer<typeof createFeatureSchema>) {
|
||||||
|
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 (
|
||||||
|
<div>
|
||||||
|
<h1>Create Feature</h1>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
203
components/create-project.tsx
Normal file
203
components/create-project.tsx
Normal file
|
@ -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<z.infer<typeof createProjectSchema>>({
|
||||||
|
resolver: zodResolver(createProjectSchema),
|
||||||
|
defaultValues: {
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
stack: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { setValue } = form;
|
||||||
|
|
||||||
|
async function createProject(values: z.infer<typeof createProjectSchema>) {
|
||||||
|
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<HTMLInputElement>) => {
|
||||||
|
if ((e.key === "Enter" || e.key === "Tab") && stackInput !== "") {
|
||||||
|
e.preventDefault();
|
||||||
|
setValue("stack", [...form.getValues("stack"), stackInput]);
|
||||||
|
setStackInput("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button className={cn(className)}>
|
||||||
|
<PlusIcon className="mr-2 h-4 w-4" />
|
||||||
|
Create Project
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="sm:max-w-[425px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create Project</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
onSubmit={form.handleSubmit(createProject)}
|
||||||
|
className="space-y-4"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>This is your project name.</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="description"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Description</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
This is your project description.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="stack"
|
||||||
|
render={() => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Tech Stack</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
value={stackInput}
|
||||||
|
onChange={(e) => setStackInput(e.target.value)}
|
||||||
|
onKeyDown={keyHandler}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
This is your project tech stack.
|
||||||
|
</FormDescription>
|
||||||
|
<div>
|
||||||
|
{form.getValues("stack").map((technology) => (
|
||||||
|
<Badge
|
||||||
|
key={technology}
|
||||||
|
className="mr-2 font-normal rounded-md"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
<span className="mr-1">{technology}</span>
|
||||||
|
<Cross2Icon
|
||||||
|
className="inline font-light text-red-500"
|
||||||
|
onClick={() =>
|
||||||
|
setValue(
|
||||||
|
"stack",
|
||||||
|
form
|
||||||
|
.getValues("stack")
|
||||||
|
.filter((s) => s !== technology)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button type="submit">Submit</Button>
|
||||||
|
</DialogClose>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
63
components/create-task.tsx
Normal file
63
components/create-task.tsx
Normal file
|
@ -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<z.infer<typeof createTaskSchema>>({
|
||||||
|
resolver: zodResolver(createTaskSchema),
|
||||||
|
defaultValues: {
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
async function createTask(values: z.infer<typeof createTaskSchema>) {
|
||||||
|
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 (
|
||||||
|
<div>
|
||||||
|
<h1>Create Task</h1>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
146
components/create-workspace.tsx
Normal file
146
components/create-workspace.tsx
Normal file
|
@ -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<z.infer<typeof createWorkspaceSchema>>({
|
||||||
|
resolver: zodResolver(createWorkspaceSchema),
|
||||||
|
defaultValues: {
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
async function createWorkspace(
|
||||||
|
values: z.infer<typeof createWorkspaceSchema>
|
||||||
|
) {
|
||||||
|
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 (
|
||||||
|
<Dialog>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="outline" size="icon">
|
||||||
|
<PlusIcon className="h-4 w-4" />
|
||||||
|
{/* Create Workspace */}
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="sm:max-w-[425px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create Workspace</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
onSubmit={form.handleSubmit(createWorkspace)}
|
||||||
|
className="space-y-4"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
This is your workspace name.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="description"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Description</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
This is your workspace description.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button type="submit">Submit</Button>
|
||||||
|
</DialogClose>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
104
components/feature-card.tsx
Normal file
104
components/feature-card.tsx
Normal file
|
@ -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<React.SetStateAction<Feature[]>>;
|
||||||
|
}) {
|
||||||
|
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 (
|
||||||
|
<Card className="shadow-none">
|
||||||
|
{isEditing ? (
|
||||||
|
<>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
/>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Textarea
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter className="flex flex-row space-x-2">
|
||||||
|
<Button size="sm" variant="default" onClick={saveChanges}>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() =>
|
||||||
|
setFeatures(features.filter((f) => f.uid !== feature.uid))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<TrashIcon />
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{feature.name}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-xs">{feature.description}</p>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter className="flex flex-row space-x-2">
|
||||||
|
<Button size="icon" variant="secondary">
|
||||||
|
<Pencil2Icon onClick={toggleEdit} />
|
||||||
|
</Button>
|
||||||
|
<Button size="icon" variant="destructive">
|
||||||
|
<TrashIcon
|
||||||
|
onClick={() => {
|
||||||
|
setFeatures(features.filter((f) => f.uid !== feature.uid));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
54
components/feature-list.tsx
Normal file
54
components/feature-list.tsx
Normal file
|
@ -0,0 +1,54 @@
|
||||||
|
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 { CheckIcon } from "lucide-react";
|
||||||
|
|
||||||
|
async function getSession(supabase: any) {
|
||||||
|
const {
|
||||||
|
data: { session },
|
||||||
|
} = await supabase.auth.getSession();
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchFeatures(projectID: string) {
|
||||||
|
const supabase = createServerComponentClient<Database>({ 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 features = await prisma.feature.findMany({
|
||||||
|
where: {
|
||||||
|
project_id: BigInt(projectID),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return features;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function FeatureList({ projectID }: { projectID: string }) {
|
||||||
|
const features = await fetchFeatures(projectID);
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="grid gap-4">
|
||||||
|
{features?.map((feature) => (
|
||||||
|
<div className="flex items-start gap-4" key={feature.id}>
|
||||||
|
<CheckIcon className="w-6 h-6 text-green-500" />
|
||||||
|
<div className="grid gap-1">
|
||||||
|
<h3 className="font-semibold">{feature.name}</h3>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{feature.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
543
components/generate-project.tsx
Normal file
543
components/generate-project.tsx
Normal file
|
@ -0,0 +1,543 @@
|
||||||
|
"use client";
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useToast } from "@/components/ui/use-toast";
|
||||||
|
import { useRouter, useParams } from "next/navigation";
|
||||||
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
|
||||||
|
import * as z from "zod";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useForm, useFieldArray } from "react-hook-form";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
ReloadIcon,
|
||||||
|
TrashIcon,
|
||||||
|
Pencil2Icon,
|
||||||
|
PlusIcon,
|
||||||
|
} from "@radix-ui/react-icons";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardFooter,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { FeatureCard } from "./feature-card";
|
||||||
|
import { Input } from "./ui/input";
|
||||||
|
|
||||||
|
const DIALOG_PAGES = {
|
||||||
|
WELCOME: 0,
|
||||||
|
QUESTIONS: 1,
|
||||||
|
GENERATE_FEATURES: 2,
|
||||||
|
GENERATED_FEATURES: 3,
|
||||||
|
GENERATE_TASKS: 4,
|
||||||
|
GENERATING_TASKS: 5,
|
||||||
|
GENERATED_TASKS: 6,
|
||||||
|
};
|
||||||
|
|
||||||
|
const questionsFormSchema = z.object({
|
||||||
|
questions: z.array(
|
||||||
|
z.object({
|
||||||
|
answer: z.string().optional(),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
type Question = {
|
||||||
|
question: string;
|
||||||
|
answer: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Feature = {
|
||||||
|
uid: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function GenerateProject({
|
||||||
|
project_name,
|
||||||
|
project_description,
|
||||||
|
project_stack,
|
||||||
|
}: {
|
||||||
|
project_name: string;
|
||||||
|
project_description: string;
|
||||||
|
project_stack: string;
|
||||||
|
}) {
|
||||||
|
const [questions, setQuestions] = useState<string[]>([]);
|
||||||
|
const [features, setFeatures] = useState<Feature[]>([]);
|
||||||
|
const [isAddingFeature, setIsAddingFeature] = useState(false);
|
||||||
|
const [newFeatureName, setNewFeatureName] = useState("");
|
||||||
|
const [newFeatureDescription, setNewFeatureDescription] = useState("");
|
||||||
|
const [tasks, setTasks] = useState([]);
|
||||||
|
const [qa, setQA] = useState<Question[]>([]);
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [dialogPage, setDialogPage] = useState(DIALOG_PAGES.WELCOME);
|
||||||
|
const { toast } = useToast();
|
||||||
|
const params = useParams();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const form = useForm<z.infer<typeof questionsFormSchema>>({
|
||||||
|
resolver: zodResolver(questionsFormSchema),
|
||||||
|
mode: "all",
|
||||||
|
defaultValues: {
|
||||||
|
questions: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { fields, append, remove } = useFieldArray({
|
||||||
|
name: "questions",
|
||||||
|
control: form.control,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchFeatures() {
|
||||||
|
try {
|
||||||
|
console.log(params.projectID);
|
||||||
|
const res = await fetch(
|
||||||
|
`/w/${params.workspaceID}/p/${params.projectID}/features`
|
||||||
|
);
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
return data.features;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Failed to fetch features.",
|
||||||
|
description: `${err}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchFeatures().then((features) => {
|
||||||
|
if (features === undefined || features.length === 0) setDialogOpen(true);
|
||||||
|
setFeatures(features);
|
||||||
|
});
|
||||||
|
}, [params.projectID, params.workspaceID, toast]);
|
||||||
|
|
||||||
|
async function generateQuestions() {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/w/${params.workspaceID}/p/${params.projectID}/gen`
|
||||||
|
);
|
||||||
|
const data = await res.json();
|
||||||
|
console.log(data.questions);
|
||||||
|
append(data.questions);
|
||||||
|
setQuestions(data.questions);
|
||||||
|
setDialogPage(DIALOG_PAGES.QUESTIONS);
|
||||||
|
setLoading(false);
|
||||||
|
return data.questions;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateFeatures() {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/w/${params.workspaceID}/p/${params.projectID}/features/gen`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
project_name,
|
||||||
|
project_description,
|
||||||
|
project_stack,
|
||||||
|
qa,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const data = await res.json();
|
||||||
|
console.log(data.features);
|
||||||
|
setFeatures(data.features);
|
||||||
|
setDialogPage(DIALOG_PAGES.GENERATED_FEATURES);
|
||||||
|
setLoading(false);
|
||||||
|
return data.features;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateTasks() {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/w/${params.workspaceID}/p/${params.projectID}/features/gen/tasks`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
project_name,
|
||||||
|
project_description,
|
||||||
|
project_stack,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const data = await res.json();
|
||||||
|
console.log(data.tasks);
|
||||||
|
setTasks(data.tasks);
|
||||||
|
await addTasks(data.tasks);
|
||||||
|
setDialogPage(DIALOG_PAGES.GENERATED_TASKS);
|
||||||
|
setLoading(false);
|
||||||
|
return data.tasks;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addQuestions(values: z.infer<typeof questionsFormSchema>) {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const answer_arr = values.questions.map((q) => q.answer);
|
||||||
|
const q_data: Question[] = answer_arr.map((a, i) => ({
|
||||||
|
question: questions[i],
|
||||||
|
answer: String(a),
|
||||||
|
}));
|
||||||
|
console.log(q_data);
|
||||||
|
const res = await fetch(
|
||||||
|
`/w/${params.workspaceID}/p/${params.projectID}/gen`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(q_data),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(res);
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error(res.statusText);
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
setQA(q_data);
|
||||||
|
setDialogPage(DIALOG_PAGES.GENERATE_FEATURES);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addFeatures() {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/w/${params.workspaceID}/p/${params.projectID}/features/add`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
features: features.map((feature) => ({
|
||||||
|
name: feature.name,
|
||||||
|
description: feature.description,
|
||||||
|
project_id: params.projectID,
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error(res.statusText);
|
||||||
|
|
||||||
|
const project_features = await fetch(
|
||||||
|
`/w/${params.workspaceID}/p/${params.projectID}/features`
|
||||||
|
);
|
||||||
|
|
||||||
|
const data = await project_features.json();
|
||||||
|
|
||||||
|
const deps = await fetch(
|
||||||
|
`/w/${params.workspaceID}/p/${params.projectID}/features/gen/deps`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
project_name,
|
||||||
|
project_description,
|
||||||
|
project_stack,
|
||||||
|
features: data.features,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const deps_data = await deps.json();
|
||||||
|
console.log(deps_data);
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
setDialogPage(DIALOG_PAGES.GENERATE_TASKS);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addTasks(tasks: any[]) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/w/${params.workspaceID}/p/${params.projectID}/tasks/add`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
tasks,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error(res.statusText);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
|
{dialogPage == DIALOG_PAGES.WELCOME && (
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Welcome to your project!</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Skalara would like to learn more about your project. This will
|
||||||
|
help in the rest of the project generation process.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
{!loading ? (
|
||||||
|
<Button onClick={generateQuestions}>Generate Questions</Button>
|
||||||
|
) : (
|
||||||
|
<Button disabled>
|
||||||
|
<ReloadIcon className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Generating Questions...
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
)}
|
||||||
|
{dialogPage == DIALOG_PAGES.QUESTIONS && (
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Project Questions</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Answer any questions that you feel would help Skalara learn more
|
||||||
|
about your project goals. All questions are optional.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
onSubmit={form.handleSubmit(addQuestions)}
|
||||||
|
className="flex flex-col space-y-4"
|
||||||
|
>
|
||||||
|
{fields.map((field, index) => (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
key={field.id}
|
||||||
|
name={`questions.${index}.answer`}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Question {index + 1} </FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
<span className="text-gray-400">
|
||||||
|
{questions[index]}
|
||||||
|
</span>
|
||||||
|
</FormDescription>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<DialogFooter>
|
||||||
|
{!loading ? (
|
||||||
|
<Button type="submit">Submit</Button>
|
||||||
|
) : (
|
||||||
|
<Button disabled>
|
||||||
|
<ReloadIcon className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Loading...
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
)}
|
||||||
|
{dialogPage == DIALOG_PAGES.GENERATE_FEATURES && (
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Generate Features</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Skalara will now generate features for your project based on what
|
||||||
|
you have provided so far.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
{!loading ? (
|
||||||
|
<Button onClick={generateFeatures}>Generate Features</Button>
|
||||||
|
) : (
|
||||||
|
<Button disabled>
|
||||||
|
<ReloadIcon className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Generating Features...
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
)}
|
||||||
|
{dialogPage == DIALOG_PAGES.GENERATED_FEATURES && (
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Generated Features</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Skalara has generated the following features for your project:
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<ScrollArea className="max-h-[500px]">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
{features.map(
|
||||||
|
(
|
||||||
|
feature: { uid: string; name: string; description: string },
|
||||||
|
i
|
||||||
|
) => (
|
||||||
|
<FeatureCard
|
||||||
|
feature={feature}
|
||||||
|
features={features}
|
||||||
|
setFeatures={setFeatures}
|
||||||
|
key={feature.uid}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
{!isAddingFeature ? (
|
||||||
|
<Card className="shadow-none w-full h-[150px] flex justify-center items-center border-2 border-primary border-dashed">
|
||||||
|
<CardContent className="p-0 flex flex-col items-center gap-1">
|
||||||
|
<Button onClick={() => setIsAddingFeature(true)}>
|
||||||
|
<PlusIcon className="mr-2" />
|
||||||
|
<h1>Create Feature</h1>
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={newFeatureName}
|
||||||
|
placeholder="Feature Name"
|
||||||
|
onChange={(e) => setNewFeatureName(e.target.value)}
|
||||||
|
/>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Textarea
|
||||||
|
value={newFeatureDescription}
|
||||||
|
placeholder="Feature Description"
|
||||||
|
onChange={(e) => setNewFeatureDescription(e.target.value)}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter className="flex flex-row space-x-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
setIsAddingFeature(false);
|
||||||
|
setNewFeatureName("");
|
||||||
|
setNewFeatureDescription("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setFeatures([
|
||||||
|
...features,
|
||||||
|
{
|
||||||
|
name: newFeatureName,
|
||||||
|
description: newFeatureDescription,
|
||||||
|
uid: uuidv4(),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
setNewFeatureName("");
|
||||||
|
setNewFeatureDescription("");
|
||||||
|
setIsAddingFeature(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Create
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
<DialogFooter>
|
||||||
|
{!loading ? (
|
||||||
|
<Button onClick={addFeatures}>Add Features to Project</Button>
|
||||||
|
) : (
|
||||||
|
<Button disabled>
|
||||||
|
<ReloadIcon className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Adding Features...
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
)}
|
||||||
|
{dialogPage == DIALOG_PAGES.GENERATE_TASKS && (
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Generate Tasks</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Skalara will now generate tasks for your project based on what you
|
||||||
|
have provided so far.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
{!loading ? (
|
||||||
|
<Button onClick={generateTasks}>Generate Tasks</Button>
|
||||||
|
) : (
|
||||||
|
<Button disabled>
|
||||||
|
<ReloadIcon className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Generating Tasks...
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
)}
|
||||||
|
{dialogPage == DIALOG_PAGES.GENERATED_TASKS && (
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Tasks Generated</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Skalara has generated tasks for your project.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setDialogOpen(false);
|
||||||
|
setDialogPage(DIALOG_PAGES.WELCOME);
|
||||||
|
router.refresh();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
)}
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
347
components/kanban/board.tsx
Normal file
347
components/kanban/board.tsx
Normal file
|
@ -0,0 +1,347 @@
|
||||||
|
"use client";
|
||||||
|
import { PlusIcon } from "@radix-ui/react-icons";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { Column, TaskCard as Task } from "@/types";
|
||||||
|
import ColumnContainer from "@/components/kanban/column";
|
||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
DragEndEvent,
|
||||||
|
DragOverEvent,
|
||||||
|
DragOverlay,
|
||||||
|
DragStartEvent,
|
||||||
|
PointerSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
} from "@dnd-kit/core";
|
||||||
|
import { SortableContext, arrayMove } from "@dnd-kit/sortable";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import TaskCard from "@/components/kanban/task";
|
||||||
|
|
||||||
|
const defaultCols: Column[] = [
|
||||||
|
{
|
||||||
|
id: "todo",
|
||||||
|
title: "Todo",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "doing",
|
||||||
|
title: "Work in progress",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "done",
|
||||||
|
title: "Done",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const defaultTasks: Task[] = [
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
status: "todo",
|
||||||
|
name: "List admin APIs for dashboard",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "2",
|
||||||
|
status: "todo",
|
||||||
|
name: "Develop user registration functionality with OTP delivered on SMS after email confirmation and phone number confirmation",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "3",
|
||||||
|
status: "doing",
|
||||||
|
name: "Conduct security testing",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "4",
|
||||||
|
status: "doing",
|
||||||
|
name: "Analyze competitors",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "5",
|
||||||
|
status: "done",
|
||||||
|
name: "Create UI kit documentation",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "6",
|
||||||
|
status: "done",
|
||||||
|
name: "Dev meeting",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "7",
|
||||||
|
status: "done",
|
||||||
|
name: "Deliver dashboard prototype",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "8",
|
||||||
|
status: "todo",
|
||||||
|
name: "Optimize application performance",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "9",
|
||||||
|
status: "todo",
|
||||||
|
name: "Implement data validation",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "10",
|
||||||
|
status: "todo",
|
||||||
|
name: "Design database schema",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "11",
|
||||||
|
status: "todo",
|
||||||
|
name: "Integrate SSL web certificates into workflow",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "12",
|
||||||
|
status: "doing",
|
||||||
|
name: "Implement error logging and monitoring",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "13",
|
||||||
|
status: "doing",
|
||||||
|
name: "Design and implement responsive UI",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function KanbanBoard() {
|
||||||
|
const [columns, setColumns] = useState<Column[]>(defaultCols);
|
||||||
|
const columnsId = useMemo(() => columns.map((col) => col.id), [columns]);
|
||||||
|
|
||||||
|
const [tasks, setTasks] = useState<Task[]>(defaultTasks);
|
||||||
|
|
||||||
|
const [activeColumn, setActiveColumn] = useState<Column | null>(null);
|
||||||
|
|
||||||
|
const [activeTask, setActiveTask] = useState<Task | null>(null);
|
||||||
|
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, {
|
||||||
|
activationConstraint: {
|
||||||
|
distance: 10,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="
|
||||||
|
m-auto
|
||||||
|
flex
|
||||||
|
|
||||||
|
w-full
|
||||||
|
items-center
|
||||||
|
overflow-x-auto
|
||||||
|
overflow-y-hidden
|
||||||
|
px-[40px]
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
onDragStart={onDragStart}
|
||||||
|
onDragEnd={onDragEnd}
|
||||||
|
onDragOver={onDragOver}
|
||||||
|
>
|
||||||
|
<div className="m-auto flex gap-4">
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<SortableContext items={columnsId}>
|
||||||
|
{columns.map((col) => (
|
||||||
|
<ColumnContainer
|
||||||
|
key={col.id}
|
||||||
|
column={col}
|
||||||
|
deleteColumn={deleteColumn}
|
||||||
|
updateColumn={updateColumn}
|
||||||
|
createTask={createTask}
|
||||||
|
deleteTask={deleteTask}
|
||||||
|
updateTask={updateTask}
|
||||||
|
tasks={tasks.filter((task) => task.status === col.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SortableContext>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
createNewColumn();
|
||||||
|
}}
|
||||||
|
className="
|
||||||
|
h-[60px]
|
||||||
|
w-[350px]
|
||||||
|
min-w-[350px]
|
||||||
|
cursor-pointer
|
||||||
|
rounded-lg
|
||||||
|
bg-mainBackgroundColor
|
||||||
|
border-2
|
||||||
|
border-columnBackgroundColor
|
||||||
|
p-4
|
||||||
|
ring-rose-500
|
||||||
|
hover:ring-2
|
||||||
|
flex
|
||||||
|
gap-2
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<PlusIcon />
|
||||||
|
Add Column
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{createPortal(
|
||||||
|
<DragOverlay>
|
||||||
|
{activeColumn && (
|
||||||
|
<ColumnContainer
|
||||||
|
column={activeColumn}
|
||||||
|
deleteColumn={deleteColumn}
|
||||||
|
updateColumn={updateColumn}
|
||||||
|
createTask={createTask}
|
||||||
|
deleteTask={deleteTask}
|
||||||
|
updateTask={updateTask}
|
||||||
|
tasks={tasks.filter((task) => task.status === activeColumn.id)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{activeTask && (
|
||||||
|
<TaskCard
|
||||||
|
task={activeTask}
|
||||||
|
deleteTask={deleteTask}
|
||||||
|
updateTask={updateTask}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</DragOverlay>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
</DndContext>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
function createTask(status: string) {
|
||||||
|
const newTask: Task = {
|
||||||
|
id: String(generateId()),
|
||||||
|
status,
|
||||||
|
name: `Task ${tasks.length + 1}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
setTasks([...tasks, newTask]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteTask(id: string) {
|
||||||
|
const newTasks = tasks.filter((task) => task.id !== id);
|
||||||
|
setTasks(newTasks);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTask(id: string, name: string) {
|
||||||
|
const newTasks = tasks.map((task) => {
|
||||||
|
if (task.id !== id) return task;
|
||||||
|
return { ...task, name };
|
||||||
|
});
|
||||||
|
|
||||||
|
setTasks(newTasks);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createNewColumn() {
|
||||||
|
const columnToAdd: Column = {
|
||||||
|
id: String(generateId()),
|
||||||
|
title: `Column ${columns.length + 1}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
setColumns([...columns, columnToAdd]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteColumn(id: string) {
|
||||||
|
const filteredColumns = columns.filter((col) => col.id !== id);
|
||||||
|
setColumns(filteredColumns);
|
||||||
|
|
||||||
|
const newTasks = tasks.filter((t) => t.status !== id);
|
||||||
|
setTasks(newTasks);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateColumn(id: string, title: string) {
|
||||||
|
const newColumns = columns.map((col) => {
|
||||||
|
if (col.id !== id) return col;
|
||||||
|
return { ...col, title };
|
||||||
|
});
|
||||||
|
|
||||||
|
setColumns(newColumns);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragStart(event: DragStartEvent) {
|
||||||
|
if (event.active.data.current?.type === "Column") {
|
||||||
|
setActiveColumn(event.active.data.current.column);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.active.data.current?.type === "Task") {
|
||||||
|
setActiveTask(event.active.data.current.task);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragEnd(event: DragEndEvent) {
|
||||||
|
setActiveColumn(null);
|
||||||
|
setActiveTask(null);
|
||||||
|
|
||||||
|
const { active, over } = event;
|
||||||
|
if (!over) return;
|
||||||
|
|
||||||
|
const activeId = active.id;
|
||||||
|
const overId = over.id;
|
||||||
|
|
||||||
|
if (activeId === overId) return;
|
||||||
|
|
||||||
|
const isActiveAColumn = active.data.current?.type === "Column";
|
||||||
|
if (!isActiveAColumn) return;
|
||||||
|
|
||||||
|
console.log("DRAG END");
|
||||||
|
|
||||||
|
setColumns((columns) => {
|
||||||
|
const activeColumnIndex = columns.findIndex((col) => col.id === activeId);
|
||||||
|
|
||||||
|
const overColumnIndex = columns.findIndex((col) => col.id === overId);
|
||||||
|
|
||||||
|
return arrayMove(columns, activeColumnIndex, overColumnIndex);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragOver(event: DragOverEvent) {
|
||||||
|
const { active, over } = event;
|
||||||
|
if (!over) return;
|
||||||
|
|
||||||
|
const activeId = active.id;
|
||||||
|
const overId = over.id;
|
||||||
|
|
||||||
|
if (activeId === overId) return;
|
||||||
|
|
||||||
|
const isActiveATask = active.data.current?.type === "Task";
|
||||||
|
const isOverATask = over.data.current?.type === "Task";
|
||||||
|
|
||||||
|
if (!isActiveATask) return;
|
||||||
|
|
||||||
|
// Im dropping a Task over another Task
|
||||||
|
if (isActiveATask && isOverATask) {
|
||||||
|
setTasks((tasks) => {
|
||||||
|
const activeIndex = tasks.findIndex((t) => t.id === activeId);
|
||||||
|
const overIndex = tasks.findIndex((t) => t.id === overId);
|
||||||
|
|
||||||
|
if (tasks[activeIndex].status != tasks[overIndex].status) {
|
||||||
|
// Fix introduced after video recording
|
||||||
|
tasks[activeIndex].status = tasks[overIndex].status;
|
||||||
|
return arrayMove(tasks, activeIndex, overIndex - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return arrayMove(tasks, activeIndex, overIndex);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const isOverAColumn = over.data.current?.type === "Column";
|
||||||
|
|
||||||
|
// Im dropping a Task over a column
|
||||||
|
if (isActiveATask && isOverAColumn) {
|
||||||
|
setTasks((tasks) => {
|
||||||
|
const activeIndex = tasks.findIndex((t) => t.id === activeId);
|
||||||
|
|
||||||
|
tasks[activeIndex].status = String(overId);
|
||||||
|
console.log("DROPPING TASK OVER COLUMN", { activeIndex });
|
||||||
|
return arrayMove(tasks, activeIndex, activeIndex);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateId() {
|
||||||
|
/* Generate a random number between 0 and 10000 */
|
||||||
|
return Math.floor(Math.random() * 10001);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default KanbanBoard;
|
189
components/kanban/column.tsx
Normal file
189
components/kanban/column.tsx
Normal file
|
@ -0,0 +1,189 @@
|
||||||
|
import { SortableContext, useSortable } from "@dnd-kit/sortable";
|
||||||
|
import { Column, TaskCard as Task } from "@/types";
|
||||||
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { PlusIcon, TrashIcon } from "@radix-ui/react-icons";
|
||||||
|
import TaskCard from "@/components/kanban/task";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
column: Column;
|
||||||
|
deleteColumn: (id: string) => void;
|
||||||
|
updateColumn: (id: string, title: string) => void;
|
||||||
|
|
||||||
|
createTask: (columnId: string) => void;
|
||||||
|
updateTask: (id: string, content: string) => void;
|
||||||
|
deleteTask: (id: string) => void;
|
||||||
|
tasks: Task[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function ColumnContainer({
|
||||||
|
column,
|
||||||
|
deleteColumn,
|
||||||
|
updateColumn,
|
||||||
|
createTask,
|
||||||
|
tasks,
|
||||||
|
deleteTask,
|
||||||
|
updateTask,
|
||||||
|
}: Props) {
|
||||||
|
const [editMode, setEditMode] = useState(false);
|
||||||
|
|
||||||
|
const tasksIds = useMemo(() => {
|
||||||
|
return tasks.map((task) => task.id);
|
||||||
|
}, [tasks]);
|
||||||
|
|
||||||
|
const {
|
||||||
|
setNodeRef,
|
||||||
|
attributes,
|
||||||
|
listeners,
|
||||||
|
transform,
|
||||||
|
transition,
|
||||||
|
isDragging,
|
||||||
|
} = useSortable({
|
||||||
|
id: column.id,
|
||||||
|
data: {
|
||||||
|
type: "Column",
|
||||||
|
column,
|
||||||
|
},
|
||||||
|
disabled: editMode,
|
||||||
|
});
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
transition,
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isDragging) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
className="
|
||||||
|
bg-muted
|
||||||
|
opacity-40
|
||||||
|
border-2
|
||||||
|
border-pink-500
|
||||||
|
w-[350px]
|
||||||
|
h-[500px]
|
||||||
|
max-h-[500px]
|
||||||
|
rounded-md
|
||||||
|
flex
|
||||||
|
flex-col
|
||||||
|
"
|
||||||
|
></div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
className="
|
||||||
|
bg-muted
|
||||||
|
w-[350px]
|
||||||
|
h-[500px]
|
||||||
|
max-h-[500px]
|
||||||
|
rounded-md
|
||||||
|
flex
|
||||||
|
flex-col
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{/* Column title */}
|
||||||
|
<div
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
onClick={() => {
|
||||||
|
setEditMode(true);
|
||||||
|
}}
|
||||||
|
className="
|
||||||
|
bg-mainBackgroundColor
|
||||||
|
text-md
|
||||||
|
h-[60px]
|
||||||
|
cursor-grab
|
||||||
|
rounded-md
|
||||||
|
rounded-b-none
|
||||||
|
p-3
|
||||||
|
font-bold
|
||||||
|
border-columnBackgroundColor
|
||||||
|
border-4
|
||||||
|
flex
|
||||||
|
items-center
|
||||||
|
justify-between
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div
|
||||||
|
className="
|
||||||
|
flex
|
||||||
|
justify-center
|
||||||
|
items-center
|
||||||
|
bg-columnBackgroundColor
|
||||||
|
px-2
|
||||||
|
py-1
|
||||||
|
text-sm
|
||||||
|
rounded-full
|
||||||
|
"
|
||||||
|
>
|
||||||
|
0
|
||||||
|
</div>
|
||||||
|
{!editMode && column.title}
|
||||||
|
{editMode && (
|
||||||
|
<input
|
||||||
|
className="bg-black focus:border-rose-500 border rounded outline-none px-2"
|
||||||
|
value={column.title}
|
||||||
|
onChange={(e) => updateColumn(column.id, e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
onBlur={() => {
|
||||||
|
setEditMode(false);
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key !== "Enter") return;
|
||||||
|
setEditMode(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
deleteColumn(column.id);
|
||||||
|
}}
|
||||||
|
className="
|
||||||
|
stroke-gray-500
|
||||||
|
hover:stroke-white
|
||||||
|
hover:bg-columnBackgroundColor
|
||||||
|
rounded
|
||||||
|
px-1
|
||||||
|
py-2
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<TrashIcon />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Column task container */}
|
||||||
|
<div className="flex flex-grow flex-col gap-4 p-2 overflow-x-hidden overflow-y-auto">
|
||||||
|
<SortableContext items={tasksIds}>
|
||||||
|
{tasks.map((task) => (
|
||||||
|
<TaskCard
|
||||||
|
key={task.id}
|
||||||
|
task={task}
|
||||||
|
deleteTask={deleteTask}
|
||||||
|
updateTask={updateTask}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SortableContext>
|
||||||
|
</div>
|
||||||
|
{/* Column footer */}
|
||||||
|
<button
|
||||||
|
className="flex gap-2 items-center border-columnBackgroundColor border-2 rounded-md p-4 border-x-columnBackgroundColor hover:bg-mainBackgroundColor hover:text-rose-500 active:bg-black"
|
||||||
|
onClick={() => {
|
||||||
|
createTask(column.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PlusIcon />
|
||||||
|
Add task
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ColumnContainer;
|
119
components/kanban/task.tsx
Normal file
119
components/kanban/task.tsx
Normal file
|
@ -0,0 +1,119 @@
|
||||||
|
"use client";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { TrashIcon } from "@radix-ui/react-icons";
|
||||||
|
import { TaskCard } from "@/types";
|
||||||
|
import { useSortable } from "@dnd-kit/sortable";
|
||||||
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
task: TaskCard;
|
||||||
|
deleteTask: (taskID: string) => void;
|
||||||
|
updateTask: (taskID: string, content: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TaskCard({ task, deleteTask, updateTask }: Props) {
|
||||||
|
const [mouseIsOver, setMouseIsOver] = useState(false);
|
||||||
|
const [editMode, setEditMode] = useState(true);
|
||||||
|
|
||||||
|
const {
|
||||||
|
setNodeRef,
|
||||||
|
attributes,
|
||||||
|
listeners,
|
||||||
|
transform,
|
||||||
|
transition,
|
||||||
|
isDragging,
|
||||||
|
} = useSortable({
|
||||||
|
id: task.id,
|
||||||
|
data: {
|
||||||
|
type: "Task",
|
||||||
|
task,
|
||||||
|
},
|
||||||
|
disabled: editMode,
|
||||||
|
});
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
transition,
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleEditMode = () => {
|
||||||
|
setEditMode((prev) => !prev);
|
||||||
|
setMouseIsOver(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isDragging) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
className="
|
||||||
|
opacity-30
|
||||||
|
bg-mainBackgroundColor p-2.5 h-[100px] min-h-[100px] items-center flex text-left rounded-xl border-2 border-rose-500 cursor-grab relative
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editMode) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
className="p-2.5 h-[100px] min-h-[100px] items-center flex text-left rounded-xl hover:ring-2 hover:ring-inset hover:ring-rose-500 cursor-grab relative"
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
className="
|
||||||
|
h-[90%]
|
||||||
|
w-full resize-none border-none rounded bg-transparent focus:outline-none
|
||||||
|
"
|
||||||
|
value={task.name}
|
||||||
|
autoFocus
|
||||||
|
placeholder="Task content here"
|
||||||
|
onBlur={toggleEditMode}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && e.shiftKey) {
|
||||||
|
toggleEditMode();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onChange={(e) => updateTask(task.id, e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
onClick={toggleEditMode}
|
||||||
|
className="bg-mainBackgroundColor p-2.5 h-[100px] min-h-[100px] items-center flex text-left rounded-xl hover:ring-2 hover:ring-inset hover:ring-rose-500 cursor-grab relative task"
|
||||||
|
onMouseEnter={() => {
|
||||||
|
setMouseIsOver(true);
|
||||||
|
}}
|
||||||
|
onMouseLeave={() => {
|
||||||
|
setMouseIsOver(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p className="my-auto h-[90%] w-full overflow-y-auto overflow-x-hidden whitespace-pre-wrap">
|
||||||
|
{task.name}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{mouseIsOver && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
deleteTask(task.id);
|
||||||
|
}}
|
||||||
|
className=" absolute right-4 top-1/2 -translate-y-1/2 bg-columnBackgroundColor p-2 rounded opacity-60 hover:opacity-100"
|
||||||
|
>
|
||||||
|
<TrashIcon />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TaskCard;
|
8
components/logout.tsx
Normal file
8
components/logout.tsx
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
export default function Logout() {
|
||||||
|
return (
|
||||||
|
<form action="/auth/logout" method="post">
|
||||||
|
<Button variant="outline">Logout</Button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
71
components/magicui/grid-pattern.tsx
Normal file
71
components/magicui/grid-pattern.tsx
Normal file
|
@ -0,0 +1,71 @@
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useId } from "react";
|
||||||
|
|
||||||
|
interface GridPatternProps {
|
||||||
|
width?: any;
|
||||||
|
height?: any;
|
||||||
|
x?: any;
|
||||||
|
y?: any;
|
||||||
|
squares?: Array<[x: number, y: number]>;
|
||||||
|
strokeDasharray?: any;
|
||||||
|
className?: string;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GridPattern({
|
||||||
|
width = 40,
|
||||||
|
height = 40,
|
||||||
|
x = -1,
|
||||||
|
y = -1,
|
||||||
|
strokeDasharray = 0,
|
||||||
|
squares,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: GridPatternProps) {
|
||||||
|
const id = useId();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-none absolute inset-0 h-full w-full fill-gray-400/30 stroke-gray-400/30",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<pattern
|
||||||
|
id={id}
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
patternUnits="userSpaceOnUse"
|
||||||
|
x={x}
|
||||||
|
y={y}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d={`M.5 ${height}V.5H${width}`}
|
||||||
|
fill="none"
|
||||||
|
strokeDasharray={strokeDasharray}
|
||||||
|
/>
|
||||||
|
</pattern>
|
||||||
|
</defs>
|
||||||
|
<rect width="100%" height="100%" strokeWidth={0} fill={`url(#${id})`} />
|
||||||
|
{squares && (
|
||||||
|
<svg x={x} y={y} className="overflow-visible">
|
||||||
|
{squares.map(([x, y]) => (
|
||||||
|
<rect
|
||||||
|
strokeWidth="0"
|
||||||
|
key={`${x}-${y}`}
|
||||||
|
width={width - 1}
|
||||||
|
height={height - 1}
|
||||||
|
x={x * width + 1}
|
||||||
|
y={y * height + 1}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default GridPattern;
|
80
components/magicui/shimmer-button.tsx
Normal file
80
components/magicui/shimmer-button.tsx
Normal file
|
@ -0,0 +1,80 @@
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import React, { CSSProperties } from "react";
|
||||||
|
|
||||||
|
export interface ShimmerButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
shimmerColor?: string;
|
||||||
|
shimmerSize?: string;
|
||||||
|
borderRadius?: string;
|
||||||
|
shimmerDuration?: string;
|
||||||
|
background?: string;
|
||||||
|
className?: string;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ShimmerButton = React.forwardRef<HTMLButtonElement, ShimmerButtonProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
shimmerColor = "#ffffff",
|
||||||
|
shimmerSize = "0.1em",
|
||||||
|
shimmerDuration = "1.5s",
|
||||||
|
borderRadius = "100px",
|
||||||
|
background = "rgba(0, 0, 0)",
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--spread": "90deg",
|
||||||
|
"--shimmer-color": shimmerColor,
|
||||||
|
"--radius": borderRadius,
|
||||||
|
"--speed": shimmerDuration,
|
||||||
|
"--cut": shimmerSize,
|
||||||
|
"--bg": background,
|
||||||
|
} as CSSProperties
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
"group relative z-0 flex cursor-pointer items-center justify-center overflow-hidden whitespace-nowrap border border-white/10 px-6 py-3 text-white [background:var(--bg)] [border-radius:var(--radius)] dark:text-black",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{/* spark container */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"-z-30 blur-sm",
|
||||||
|
"absolute inset-0 overflow-visible [container-type:size]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* spark */}
|
||||||
|
<div className="absolute inset-0 h-[100cqh] animate-slide [aspect-ratio:1] [border-radius:0] [mask:none]">
|
||||||
|
{/* spark before */}
|
||||||
|
<div className="absolute inset-[-100%] w-auto rotate-0 animate-spin [background:conic-gradient(from_calc(270deg-(var(--spread)*0.5)),transparent_0,hsl(0_0%_100%/1)_var(--spread),transparent_var(--spread))] [translate:0_0]" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{children}
|
||||||
|
|
||||||
|
{/* Highlight */}
|
||||||
|
<div className="absolute bottom-0 left-1/2 h-2/5 w-3/4 -translate-x-1/2 rounded-full bg-white/10 opacity-50 blur-lg transition-all duration-300 ease-in-out group-hover:h-3/5 group-hover:opacity-100" />
|
||||||
|
|
||||||
|
{/* backdrop */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"absolute -z-20 [background:var(--bg)] [border-radius:var(--radius)] [inset:var(--cut)]"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
ShimmerButton.displayName = "ShimmerButton";
|
||||||
|
|
||||||
|
export default ShimmerButton;
|
221
components/task-info.tsx
Normal file
221
components/task-info.tsx
Normal file
|
@ -0,0 +1,221 @@
|
||||||
|
"use client";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Calendar as CalendarIcon } from "lucide-react";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Calendar } from "@/components/ui/calendar";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover";
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetDescription,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
SheetTrigger,
|
||||||
|
} from "@/components/ui/sheet";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import React, { ReactNode } from "react";
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { Badge } from "./ui/badge";
|
||||||
|
|
||||||
|
export default function TaskInfo({
|
||||||
|
row,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
row: any;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
const [date, setDate] = React.useState<Date | undefined>(
|
||||||
|
row.due_date ? new Date(row.due_date) : undefined
|
||||||
|
);
|
||||||
|
const params = useParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const [isSheetOpen, setIsSheetOpen] = React.useState(false);
|
||||||
|
|
||||||
|
async function updateTask(info: any) {
|
||||||
|
try {
|
||||||
|
const req = {
|
||||||
|
id: row.id,
|
||||||
|
...info,
|
||||||
|
};
|
||||||
|
const data = await fetch(
|
||||||
|
`/w/${params.workspaceID}/p/${params.projectID}/tasks/mutate`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(req),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchWorkspaceUsers() {
|
||||||
|
try {
|
||||||
|
const data = await fetch(`/w/${params.workspaceID}/user/fetch`);
|
||||||
|
const res = await data.json();
|
||||||
|
console.log("FETCH WORKSPACE USERS RES ===>", res);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: add a function that takes in the task id and user id and then assigns the user to the task using the /w/:workspaceID/p/:projectID/tasks/assign API route.
|
||||||
|
async function assignUserToTask() {
|
||||||
|
try {
|
||||||
|
const data = await fetch(
|
||||||
|
`/w/${params.workspaceID}/p/${params.projectID}/tasks/assign`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
task_id: row.id,
|
||||||
|
user_id: 1,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const res = await data.json();
|
||||||
|
console.log("ASSIGN USER TO TASK RES ===>", res);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDateChange = (newDate: Date | undefined) => {
|
||||||
|
setDate(newDate); // Update the local state
|
||||||
|
if (newDate) {
|
||||||
|
updateTask({ due_date: newDate.toISOString() }); // Persist the change
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSheetOpenChange = (open: boolean) => {
|
||||||
|
setIsSheetOpen(open);
|
||||||
|
|
||||||
|
// If the sheet is being closed, refresh the router
|
||||||
|
if (!open) {
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet open={isSheetOpen} onOpenChange={handleSheetOpenChange}>
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
{row.feature_id && (
|
||||||
|
<Badge variant="outline">FEAT-{row.feature_id}</Badge>
|
||||||
|
)}
|
||||||
|
<SheetTrigger className="max-w-[500px] truncate font-medium">
|
||||||
|
{row.name}
|
||||||
|
</SheetTrigger>
|
||||||
|
</div>
|
||||||
|
<SheetContent className="w-[800px]">
|
||||||
|
<SheetHeader>
|
||||||
|
<SheetTitle>{row.name}</SheetTitle>
|
||||||
|
<SheetDescription>{row.description}</SheetDescription>
|
||||||
|
</SheetHeader>
|
||||||
|
|
||||||
|
<div className="flex flex-col space-y-2 py-4">
|
||||||
|
{/* <div className="flex flex-row space-x-24 items-center">
|
||||||
|
<h5 className="font-medium text-muted-foreground text-sm">
|
||||||
|
Assignee
|
||||||
|
</h5>
|
||||||
|
<div className="w-1/3">
|
||||||
|
<Select onValueChange={handleUserSelect}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a user" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{users.map((user) => (
|
||||||
|
<SelectItem key={user.id} value={user.id}>
|
||||||
|
{user.email}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div> */}
|
||||||
|
<div className="flex flex-row space-x-24 items-center">
|
||||||
|
<h5 className="font-medium text-muted-foreground text-sm">
|
||||||
|
Status
|
||||||
|
</h5>
|
||||||
|
<div className="w-1/3">
|
||||||
|
<Select
|
||||||
|
defaultValue={row.status}
|
||||||
|
onValueChange={(value) => updateTask({ status: value })}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select status" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="backlog">Backlog</SelectItem>
|
||||||
|
<SelectItem value="todo">Todo</SelectItem>
|
||||||
|
<SelectItem value="in_progress">In Progress</SelectItem>
|
||||||
|
<SelectItem value="done">Done</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-row space-x-24 items-center">
|
||||||
|
<h5 className="font-medium text-muted-foreground text-sm">
|
||||||
|
Priority
|
||||||
|
</h5>
|
||||||
|
<div className="w-1/3">
|
||||||
|
<Select
|
||||||
|
defaultValue={row.priority}
|
||||||
|
onValueChange={(value) => updateTask({ priority: value })}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select priority" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="low">Low</SelectItem>
|
||||||
|
<SelectItem value="medium">Medium</SelectItem>
|
||||||
|
<SelectItem value="high">High</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-row space-x-24 items-center">
|
||||||
|
<h5 className="font-medium text-muted-foreground text-sm">
|
||||||
|
Due Date
|
||||||
|
</h5>
|
||||||
|
<div className="w-1/3">
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant={"outline"}
|
||||||
|
className={cn(
|
||||||
|
"w-[280px] justify-start text-left font-normal",
|
||||||
|
!date && "text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||||
|
{date ? format(date, "PPP") : <span>Pick a date</span>}
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-auto p-0">
|
||||||
|
<Calendar
|
||||||
|
mode="single"
|
||||||
|
selected={date}
|
||||||
|
onSelect={handleDateChange}
|
||||||
|
initialFocus
|
||||||
|
/>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
}
|
137
components/task-table/columns.tsx
Normal file
137
components/task-table/columns.tsx
Normal file
|
@ -0,0 +1,137 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ColumnDef } from "@tanstack/react-table";
|
||||||
|
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import Link from "next/link";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
|
||||||
|
import Chat from "@/components/chat";
|
||||||
|
|
||||||
|
import {
|
||||||
|
// labels,
|
||||||
|
priorities,
|
||||||
|
statuses,
|
||||||
|
} from "@/components/task-table/data";
|
||||||
|
import { Task } from "@/components/task-table/schema";
|
||||||
|
import { DataTableColumnHeader } from "./data-table-column-header";
|
||||||
|
import { DataTableRowActions } from "./data-table-row-actions";
|
||||||
|
import TaskInfo from "../task-info";
|
||||||
|
|
||||||
|
export const columns: ColumnDef<Task>[] = [
|
||||||
|
{
|
||||||
|
accessorKey: "id",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataTableColumnHeader column={column} title="Task ID" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Badge variant="outline">TASK-{row.getValue("id")}</Badge>
|
||||||
|
),
|
||||||
|
enableSorting: false,
|
||||||
|
enableHiding: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "name",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataTableColumnHeader column={column} title="Name" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const projectInfo = {
|
||||||
|
project_id: row.original.project_id,
|
||||||
|
name: row.original.project_name,
|
||||||
|
description: row.original.project_description,
|
||||||
|
stack: row.original.project_stack,
|
||||||
|
};
|
||||||
|
const taskInfo = {
|
||||||
|
task_id: row.original.id,
|
||||||
|
name: row.original.name,
|
||||||
|
description: row.original.description,
|
||||||
|
};
|
||||||
|
const featureInfo = {
|
||||||
|
feature_id: row.original.feature_id,
|
||||||
|
name: row.original.feature_name,
|
||||||
|
description: row.original.feature_description,
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<TaskInfo row={row.original}>
|
||||||
|
<Chat
|
||||||
|
taskInfo={taskInfo}
|
||||||
|
featureInfo={featureInfo}
|
||||||
|
projectInfo={projectInfo}
|
||||||
|
/>
|
||||||
|
</TaskInfo>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "status",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataTableColumnHeader column={column} title="Status" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const status = statuses.find(
|
||||||
|
(status) => status.value === row.getValue("status")
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!status) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex w-[100px] items-center">
|
||||||
|
{status.icon && (
|
||||||
|
<status.icon className="mr-2 h-4 w-4 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
<span>{status.label}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
filterFn: (row, id, value) => {
|
||||||
|
return value.includes(row.getValue(id));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "priority",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataTableColumnHeader column={column} title="Priority" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const priority = priorities.find(
|
||||||
|
(priority) => priority.value === row.getValue("priority")
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!priority) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
Set Priority
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center">
|
||||||
|
{priority.icon && (
|
||||||
|
<priority.icon className="mr-2 h-4 w-4 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
<span>{priority.label}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
filterFn: (row, id, value) => {
|
||||||
|
return value.includes(row.getValue(id));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
cell: ({ row }) => <DataTableRowActions row={row} />,
|
||||||
|
},
|
||||||
|
];
|
71
components/task-table/data-table-column-header.tsx
Normal file
71
components/task-table/data-table-column-header.tsx
Normal file
|
@ -0,0 +1,71 @@
|
||||||
|
import {
|
||||||
|
ArrowDownIcon,
|
||||||
|
ArrowUpIcon,
|
||||||
|
CaretSortIcon,
|
||||||
|
EyeNoneIcon,
|
||||||
|
} from "@radix-ui/react-icons";
|
||||||
|
import { 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<TData, TValue>
|
||||||
|
extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
column: Column<TData, TValue>;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DataTableColumnHeader<TData, TValue>({
|
||||||
|
column,
|
||||||
|
title,
|
||||||
|
className,
|
||||||
|
}: DataTableColumnHeaderProps<TData, TValue>) {
|
||||||
|
if (!column.getCanSort()) {
|
||||||
|
return <div className={cn(className)}>{title}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("flex items-center space-x-2", className)}>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="-ml-3 h-8 data-[state=open]:bg-accent"
|
||||||
|
>
|
||||||
|
<span>{title}</span>
|
||||||
|
{column.getIsSorted() === "desc" ? (
|
||||||
|
<ArrowDownIcon className="ml-2 h-4 w-4" />
|
||||||
|
) : column.getIsSorted() === "asc" ? (
|
||||||
|
<ArrowUpIcon className="ml-2 h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<CaretSortIcon className="ml-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start">
|
||||||
|
<DropdownMenuItem onClick={() => column.toggleSorting(false)}>
|
||||||
|
<ArrowUpIcon className="mr-2 h-3.5 w-3.5 text-muted-foreground/70" />
|
||||||
|
Asc
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => column.toggleSorting(true)}>
|
||||||
|
<ArrowDownIcon className="mr-2 h-3.5 w-3.5 text-muted-foreground/70" />
|
||||||
|
Desc
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem onClick={() => column.toggleVisibility(false)}>
|
||||||
|
<EyeNoneIcon className="mr-2 h-3.5 w-3.5 text-muted-foreground/70" />
|
||||||
|
Hide
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
147
components/task-table/data-table-faceted-filter.tsx
Normal file
147
components/task-table/data-table-faceted-filter.tsx
Normal file
|
@ -0,0 +1,147 @@
|
||||||
|
import * as React from "react";
|
||||||
|
import { CheckIcon, PlusCircledIcon } from "@radix-ui/react-icons";
|
||||||
|
import { 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";
|
||||||
|
|
||||||
|
interface DataTableFacetedFilter<TData, TValue> {
|
||||||
|
column?: Column<TData, TValue>;
|
||||||
|
title?: string;
|
||||||
|
options: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
icon?: React.ComponentType<{ className?: string }>;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DataTableFacetedFilter<TData, TValue>({
|
||||||
|
column,
|
||||||
|
title,
|
||||||
|
options,
|
||||||
|
}: DataTableFacetedFilter<TData, TValue>) {
|
||||||
|
const facets = column?.getFacetedUniqueValues();
|
||||||
|
const selectedValues = new Set(column?.getFilterValue() as string[]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm" className="h-8 border-dashed">
|
||||||
|
<PlusCircledIcon className="mr-2 h-4 w-4" />
|
||||||
|
{title}
|
||||||
|
{selectedValues?.size > 0 && (
|
||||||
|
<>
|
||||||
|
<Separator orientation="vertical" className="mx-2 h-4" />
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className="rounded-sm px-1 font-normal lg:hidden"
|
||||||
|
>
|
||||||
|
{selectedValues.size}
|
||||||
|
</Badge>
|
||||||
|
<div className="hidden space-x-1 lg:flex">
|
||||||
|
{selectedValues.size > 2 ? (
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className="rounded-sm px-1 font-normal"
|
||||||
|
>
|
||||||
|
{selectedValues.size} selected
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
options
|
||||||
|
.filter((option) => selectedValues.has(option.value))
|
||||||
|
.map((option) => (
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
key={option.value}
|
||||||
|
className="rounded-sm px-1 font-normal"
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</Badge>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[200px] p-0" align="start">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder={title} />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>No results found.</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{options.map((option) => {
|
||||||
|
const isSelected = selectedValues.has(option.value);
|
||||||
|
return (
|
||||||
|
<CommandItem
|
||||||
|
key={option.value}
|
||||||
|
onSelect={() => {
|
||||||
|
if (isSelected) {
|
||||||
|
selectedValues.delete(option.value);
|
||||||
|
} else {
|
||||||
|
selectedValues.add(option.value);
|
||||||
|
}
|
||||||
|
const filterValues = Array.from(selectedValues);
|
||||||
|
column?.setFilterValue(
|
||||||
|
filterValues.length ? filterValues : undefined
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary",
|
||||||
|
isSelected
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "opacity-50 [&_svg]:invisible"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CheckIcon className={cn("h-4 w-4")} />
|
||||||
|
</div>
|
||||||
|
{option.icon && (
|
||||||
|
<option.icon className="mr-2 h-4 w-4 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
<span>{option.label}</span>
|
||||||
|
{facets?.get(option.value) && (
|
||||||
|
<span className="ml-auto flex h-4 w-4 items-center justify-center font-mono text-xs">
|
||||||
|
{facets.get(option.value)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</CommandItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</CommandGroup>
|
||||||
|
{selectedValues.size > 0 && (
|
||||||
|
<>
|
||||||
|
<CommandSeparator />
|
||||||
|
<CommandGroup>
|
||||||
|
<CommandItem
|
||||||
|
onSelect={() => column?.setFilterValue(undefined)}
|
||||||
|
className="justify-center text-center"
|
||||||
|
>
|
||||||
|
Clear filters
|
||||||
|
</CommandItem>
|
||||||
|
</CommandGroup>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
77
components/task-table/data-table-loading.tsx
Normal file
77
components/task-table/data-table-loading.tsx
Normal file
|
@ -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 (
|
||||||
|
<div className="w-full space-y-3 overflow-auto">
|
||||||
|
<div className="flex w-full items-center justify-between space-x-2 overflow-auto p-1">
|
||||||
|
<div className="flex flex-1 items-center space-x-2">
|
||||||
|
<Skeleton className="h-7 w-[150px] lg:w-[250px]" />
|
||||||
|
<Skeleton className="h-7 w-[70px] border-dashed" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="ml-auto hidden h-7 w-[70px] lg:flex" />
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
{Array.from({ length: 1 }).map((_, i) => (
|
||||||
|
<TableRow key={i} className="hover:bg-transparent">
|
||||||
|
{Array.from({ length: columnCount }).map((_, i) => (
|
||||||
|
<TableHead key={i}>
|
||||||
|
<Skeleton className="h-6 w-full" />
|
||||||
|
</TableHead>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{Array.from({ length: rowCount }).map((_, i) => (
|
||||||
|
<TableRow key={i} className="hover:bg-transparent">
|
||||||
|
{Array.from({ length: columnCount }).map((_, i) => (
|
||||||
|
<TableCell key={i}>
|
||||||
|
<Skeleton className="h-6 w-full" />
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
<div className="flex w-full flex-col items-center justify-between gap-4 overflow-auto px-2 py-1 sm:flex-row sm:gap-8">
|
||||||
|
<div className="flex-1">
|
||||||
|
<Skeleton className="h-8 w-40" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center gap-4 sm:flex-row sm:gap-6 lg:gap-8">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Skeleton className="h-8 w-24" />
|
||||||
|
<Skeleton className="h-8 w-[70px]" />
|
||||||
|
</div>
|
||||||
|
<div className="flex w-[100px] items-center justify-center text-sm font-medium">
|
||||||
|
<Skeleton className="h-8 w-20" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Skeleton className="hidden h-8 w-8 lg:block" />
|
||||||
|
<Skeleton className="h-8 w-8" />
|
||||||
|
<Skeleton className="h-8 w-8" />
|
||||||
|
<Skeleton className="hidden h-8 w-8 lg:block" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
96
components/task-table/data-table-pagination.tsx
Normal file
96
components/task-table/data-table-pagination.tsx
Normal file
|
@ -0,0 +1,96 @@
|
||||||
|
import {
|
||||||
|
ChevronLeftIcon,
|
||||||
|
ChevronRightIcon,
|
||||||
|
DoubleArrowLeftIcon,
|
||||||
|
DoubleArrowRightIcon,
|
||||||
|
} from "@radix-ui/react-icons";
|
||||||
|
import { Table } from "@tanstack/react-table";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
|
||||||
|
interface DataTablePaginationProps<TData> {
|
||||||
|
table: Table<TData>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DataTablePagination<TData>({
|
||||||
|
table,
|
||||||
|
}: DataTablePaginationProps<TData>) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between px-2">
|
||||||
|
<div className="flex-1 text-sm text-muted-foreground">
|
||||||
|
{table.getFilteredRowModel().rows.length} total tasks.
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-6 lg:space-x-8">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<p className="text-sm font-medium">Rows per page</p>
|
||||||
|
<Select
|
||||||
|
value={`${table.getState().pagination.pageSize}`}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
table.setPageSize(Number(value));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 w-[70px]">
|
||||||
|
<SelectValue placeholder={table.getState().pagination.pageSize} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent side="top">
|
||||||
|
{[10, 20, 30, 40, 50].map((pageSize) => (
|
||||||
|
<SelectItem key={pageSize} value={`${pageSize}`}>
|
||||||
|
{pageSize}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex w-[100px] items-center justify-center text-sm font-medium">
|
||||||
|
Page {table.getState().pagination.pageIndex + 1} of{" "}
|
||||||
|
{table.getPageCount()}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="hidden h-8 w-8 p-0 lg:flex"
|
||||||
|
onClick={() => table.setPageIndex(0)}
|
||||||
|
disabled={!table.getCanPreviousPage()}
|
||||||
|
>
|
||||||
|
<span className="sr-only">Go to first page</span>
|
||||||
|
<DoubleArrowLeftIcon className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
onClick={() => table.previousPage()}
|
||||||
|
disabled={!table.getCanPreviousPage()}
|
||||||
|
>
|
||||||
|
<span className="sr-only">Go to previous page</span>
|
||||||
|
<ChevronLeftIcon className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
onClick={() => table.nextPage()}
|
||||||
|
disabled={!table.getCanNextPage()}
|
||||||
|
>
|
||||||
|
<span className="sr-only">Go to next page</span>
|
||||||
|
<ChevronRightIcon className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="hidden h-8 w-8 p-0 lg:flex"
|
||||||
|
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
|
||||||
|
disabled={!table.getCanNextPage()}
|
||||||
|
>
|
||||||
|
<span className="sr-only">Go to last page</span>
|
||||||
|
<DoubleArrowRightIcon className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
75
components/task-table/data-table-row-actions.tsx
Normal file
75
components/task-table/data-table-row-actions.tsx
Normal file
|
@ -0,0 +1,75 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { DotsHorizontalIcon } from "@radix-ui/react-icons";
|
||||||
|
import { Row } from "@tanstack/react-table";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuShortcut,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { Trash } from "lucide-react";
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
import { taskSchema } from "@/components/task-table/schema";
|
||||||
|
|
||||||
|
interface DataTableRowActionsProps<TData> {
|
||||||
|
row: Row<TData>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DataTableRowActions<TData>({
|
||||||
|
row,
|
||||||
|
}: DataTableRowActionsProps<TData>) {
|
||||||
|
const task = taskSchema.parse(row.original);
|
||||||
|
const params = useParams();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
async function deleteTask() {
|
||||||
|
try {
|
||||||
|
const data = await fetch(
|
||||||
|
`/w/${params.workspaceID}/p/${params.projectID}/tasks/delete`,
|
||||||
|
{
|
||||||
|
method: "DELETE",
|
||||||
|
body: JSON.stringify({
|
||||||
|
id: task.id,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const res = await data.json();
|
||||||
|
console.log("DELETE TASK RES ===>", res);
|
||||||
|
router.refresh();
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="flex h-8 w-8 p-0 data-[state=open]:bg-muted"
|
||||||
|
>
|
||||||
|
<DotsHorizontalIcon className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Open menu</span>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-[160px]">
|
||||||
|
<DropdownMenuItem onClick={deleteTask}>
|
||||||
|
<Trash size={16} className="mr-2 text-muted-foreground" />
|
||||||
|
Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
63
components/task-table/data-table-toolbar.tsx
Normal file
63
components/task-table/data-table-toolbar.tsx
Normal file
|
@ -0,0 +1,63 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Cross2Icon } from "@radix-ui/react-icons";
|
||||||
|
import { Table } from "@tanstack/react-table";
|
||||||
|
// import CreateProject from "@/components/workspace/dashboard/create-project";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { DataTableViewOptions } from "@/components/task-table/data-table-view-options";
|
||||||
|
|
||||||
|
import { priorities, statuses } from "@/components/task-table/data";
|
||||||
|
import { DataTableFacetedFilter } from "./data-table-faceted-filter";
|
||||||
|
|
||||||
|
interface DataTableToolbarProps<TData> {
|
||||||
|
table: Table<TData>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DataTableToolbar<TData>({
|
||||||
|
table,
|
||||||
|
}: DataTableToolbarProps<TData>) {
|
||||||
|
const isFiltered = table.getState().columnFilters.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex flex-1 items-center space-x-2">
|
||||||
|
<Input
|
||||||
|
placeholder="Filter project names..."
|
||||||
|
value={(table.getColumn("name")?.getFilterValue() as string) ?? ""}
|
||||||
|
onChange={(event) =>
|
||||||
|
table.getColumn("name")?.setFilterValue(event.target.value)
|
||||||
|
}
|
||||||
|
className="h-8 w-[150px] lg:w-[250px]"
|
||||||
|
/>
|
||||||
|
{table.getColumn("status") && (
|
||||||
|
<DataTableFacetedFilter
|
||||||
|
column={table.getColumn("status")}
|
||||||
|
title="Status"
|
||||||
|
options={statuses}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{table.getColumn("priority") && (
|
||||||
|
<DataTableFacetedFilter
|
||||||
|
column={table.getColumn("priority")}
|
||||||
|
title="Priority"
|
||||||
|
options={priorities}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{isFiltered && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => table.resetColumnFilters()}
|
||||||
|
className="h-8 px-2 lg:px-3"
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
<Cross2Icon className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* <CreateProject text="Create Project" /> */}
|
||||||
|
<DataTableViewOptions table={table} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
59
components/task-table/data-table-view-options.tsx
Normal file
59
components/task-table/data-table-view-options.tsx
Normal file
|
@ -0,0 +1,59 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { DropdownMenuTrigger } from "@radix-ui/react-dropdown-menu";
|
||||||
|
import { MixerHorizontalIcon } from "@radix-ui/react-icons";
|
||||||
|
import { Table } from "@tanstack/react-table";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
|
||||||
|
interface DataTableViewOptionsProps<TData> {
|
||||||
|
table: Table<TData>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DataTableViewOptions<TData>({
|
||||||
|
table,
|
||||||
|
}: DataTableViewOptionsProps<TData>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="ml-auto hidden h-8 lg:flex"
|
||||||
|
>
|
||||||
|
<MixerHorizontalIcon className="mr-2 h-4 w-4" />
|
||||||
|
View
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-[150px]">
|
||||||
|
<DropdownMenuLabel>Toggle columns</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
{table
|
||||||
|
.getAllColumns()
|
||||||
|
.filter(
|
||||||
|
(column) =>
|
||||||
|
typeof column.accessorFn !== "undefined" && column.getCanHide()
|
||||||
|
)
|
||||||
|
.map((column) => {
|
||||||
|
return (
|
||||||
|
<DropdownMenuCheckboxItem
|
||||||
|
key={column.id}
|
||||||
|
className="capitalize"
|
||||||
|
checked={column.getIsVisible()}
|
||||||
|
onCheckedChange={(value) => column.toggleVisibility(!!value)}
|
||||||
|
>
|
||||||
|
{column.id}
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
128
components/task-table/data-table.tsx
Normal file
128
components/task-table/data-table.tsx
Normal file
|
@ -0,0 +1,128 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import {
|
||||||
|
ColumnDef,
|
||||||
|
ColumnFiltersState,
|
||||||
|
SortingState,
|
||||||
|
VisibilityState,
|
||||||
|
flexRender,
|
||||||
|
getCoreRowModel,
|
||||||
|
getFacetedRowModel,
|
||||||
|
getFacetedUniqueValues,
|
||||||
|
getFilteredRowModel,
|
||||||
|
getPaginationRowModel,
|
||||||
|
getSortedRowModel,
|
||||||
|
useReactTable,
|
||||||
|
} from "@tanstack/react-table";
|
||||||
|
|
||||||
|
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<TData, TValue> {
|
||||||
|
columns: ColumnDef<TData, TValue>[];
|
||||||
|
data: TData[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DataTable<TData, TValue>({
|
||||||
|
columns,
|
||||||
|
data,
|
||||||
|
}: DataTableProps<TData, TValue>) {
|
||||||
|
const [rowSelection, setRowSelection] = React.useState({});
|
||||||
|
const [columnVisibility, setColumnVisibility] =
|
||||||
|
React.useState<VisibilityState>({});
|
||||||
|
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
const [sorting, setSorting] = React.useState<SortingState>([]);
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data,
|
||||||
|
columns,
|
||||||
|
state: {
|
||||||
|
sorting,
|
||||||
|
columnVisibility,
|
||||||
|
rowSelection,
|
||||||
|
columnFilters,
|
||||||
|
},
|
||||||
|
enableRowSelection: true,
|
||||||
|
onRowSelectionChange: setRowSelection,
|
||||||
|
onSortingChange: setSorting,
|
||||||
|
onColumnFiltersChange: setColumnFilters,
|
||||||
|
onColumnVisibilityChange: setColumnVisibility,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getFilteredRowModel: getFilteredRowModel(),
|
||||||
|
getPaginationRowModel: getPaginationRowModel(),
|
||||||
|
getSortedRowModel: getSortedRowModel(),
|
||||||
|
getFacetedRowModel: getFacetedRowModel(),
|
||||||
|
getFacetedUniqueValues: getFacetedUniqueValues(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<DataTableToolbar table={table} />
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<div style={{ height: "400px", overflow: "auto" }}>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
<TableRow key={headerGroup.id}>
|
||||||
|
{headerGroup.headers.map((header) => {
|
||||||
|
return (
|
||||||
|
<TableHead key={header.id}>
|
||||||
|
{header.isPlaceholder
|
||||||
|
? null
|
||||||
|
: flexRender(
|
||||||
|
header.column.columnDef.header,
|
||||||
|
header.getContext()
|
||||||
|
)}
|
||||||
|
</TableHead>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{table.getRowModel().rows?.length ? (
|
||||||
|
table.getRowModel().rows.map((row) => (
|
||||||
|
<TableRow
|
||||||
|
key={row.id}
|
||||||
|
data-state={row.getIsSelected() && "selected"}
|
||||||
|
>
|
||||||
|
{row.getVisibleCells().map((cell) => (
|
||||||
|
<TableCell key={cell.id}>
|
||||||
|
{flexRender(
|
||||||
|
cell.column.columnDef.cell,
|
||||||
|
cell.getContext()
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell
|
||||||
|
colSpan={columns.length}
|
||||||
|
className="h-24 text-center"
|
||||||
|
>
|
||||||
|
No results.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DataTablePagination table={table} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
51
components/task-table/data.tsx
Normal file
51
components/task-table/data.tsx
Normal file
|
@ -0,0 +1,51 @@
|
||||||
|
import {
|
||||||
|
ArrowDownIcon,
|
||||||
|
ArrowRightIcon,
|
||||||
|
ArrowUpIcon,
|
||||||
|
CheckCircledIcon,
|
||||||
|
CircleIcon,
|
||||||
|
CrossCircledIcon,
|
||||||
|
QuestionMarkCircledIcon,
|
||||||
|
StopwatchIcon,
|
||||||
|
} from "@radix-ui/react-icons";
|
||||||
|
|
||||||
|
export const statuses = [
|
||||||
|
{
|
||||||
|
value: "backlog",
|
||||||
|
label: "Backlog",
|
||||||
|
icon: QuestionMarkCircledIcon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "todo",
|
||||||
|
label: "Todo",
|
||||||
|
icon: CircleIcon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "in_progress",
|
||||||
|
label: "In Progress",
|
||||||
|
icon: StopwatchIcon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "done",
|
||||||
|
label: "Done",
|
||||||
|
icon: CheckCircledIcon,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const priorities = [
|
||||||
|
{
|
||||||
|
label: "Low",
|
||||||
|
value: "low",
|
||||||
|
icon: ArrowDownIcon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Medium",
|
||||||
|
value: "medium",
|
||||||
|
icon: ArrowRightIcon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "High",
|
||||||
|
value: "high",
|
||||||
|
icon: ArrowUpIcon,
|
||||||
|
},
|
||||||
|
];
|
22
components/task-table/schema.tsx
Normal file
22
components/task-table/schema.tsx
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const taskSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
description: z.string(),
|
||||||
|
project_id: z.string(), // Assuming project_id should also be included
|
||||||
|
project_name: z.string(),
|
||||||
|
project_description: z.string(),
|
||||||
|
project_stack: z.array(z.string()),
|
||||||
|
feature_id: z.string().nullable(),
|
||||||
|
feature_name: z.string().nullable(), // feature_name could be nullable if a task might not have a related feature
|
||||||
|
feature_description: z.string().nullable(), // same as feature_name
|
||||||
|
status: z.enum(["backlog", "todo", "in_progress", "done"]).optional(),
|
||||||
|
priority: z.string().nullable(),
|
||||||
|
order: z.number().int().optional(), // Assuming you want to keep the order field
|
||||||
|
due_date: z.string().nullable().optional(), // If due_date is used and should be in ISO string format
|
||||||
|
assignee: z.string().nullable(), // Assuming assignee is a string
|
||||||
|
// Add any other fields from the task table if necessary
|
||||||
|
});
|
||||||
|
|
||||||
|
export type Task = z.infer<typeof taskSchema>;
|
32
components/tasks.tsx
Normal file
32
components/tasks.tsx
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
import { DataTableLoading } from "@/components/task-table/data-table-loading";
|
||||||
|
import { DataTable } from "@/components/task-table/data-table";
|
||||||
|
import { Suspense } from "react";
|
||||||
|
import { columns } from "@/components/task-table/columns";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardFooter,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function Tasks({ tasks }: { tasks: any }) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Tasks</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Suspense fallback={<DataTableLoading columnCount={4} />}>
|
||||||
|
<div>
|
||||||
|
<DataTable data={tasks} columns={columns} />
|
||||||
|
</div>
|
||||||
|
</Suspense>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter></CardFooter>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
8
components/theme-provider.tsx
Normal file
8
components/theme-provider.tsx
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
||||||
|
import { type ThemeProviderProps } from "next-themes/dist/types";
|
||||||
|
|
||||||
|
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||||
|
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
||||||
|
}
|
39
components/theme-toggle.tsx
Normal file
39
components/theme-toggle.tsx
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { MoonIcon, SunIcon } from "@radix-ui/react-icons";
|
||||||
|
import { useTheme } from "next-themes";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
|
||||||
|
export function ThemeToggle() {
|
||||||
|
const { setTheme } = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="outline" size="icon">
|
||||||
|
<SunIcon className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||||
|
<MoonIcon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||||
|
<span className="sr-only">Toggle theme</span>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={() => setTheme("light")}>
|
||||||
|
Light
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => setTheme("dark")}>
|
||||||
|
Dark
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => setTheme("system")}>
|
||||||
|
System
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
60
components/ui/accordion.tsx
Normal file
60
components/ui/accordion.tsx
Normal file
|
@ -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<typeof AccordionPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AccordionPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn("border-b", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AccordionItem.displayName = "AccordionItem"
|
||||||
|
|
||||||
|
const AccordionTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AccordionPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<AccordionPrimitive.Header className="flex">
|
||||||
|
<AccordionPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex flex-1 items-center justify-between py-4 text-sm font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronDownIcon className="h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200" />
|
||||||
|
</AccordionPrimitive.Trigger>
|
||||||
|
</AccordionPrimitive.Header>
|
||||||
|
))
|
||||||
|
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
|
||||||
|
|
||||||
|
const AccordionContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AccordionPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<AccordionPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div className="pb-4 pt-0">{children}</div>
|
||||||
|
</AccordionPrimitive.Content>
|
||||||
|
))
|
||||||
|
AccordionContent.displayName = AccordionPrimitive.Content.displayName
|
||||||
|
|
||||||
|
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
|
141
components/ui/alert-dialog.tsx
Normal file
141
components/ui/alert-dialog.tsx
Normal file
|
@ -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<typeof AlertDialogPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Overlay
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 z-50 bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
|
||||||
|
|
||||||
|
const AlertDialogContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPortal>
|
||||||
|
<AlertDialogOverlay />
|
||||||
|
<AlertDialogPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg md:w-full",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</AlertDialogPortal>
|
||||||
|
))
|
||||||
|
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const AlertDialogHeader = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col space-y-2 text-center sm:text-left",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
AlertDialogHeader.displayName = "AlertDialogHeader"
|
||||||
|
|
||||||
|
const AlertDialogFooter = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
AlertDialogFooter.displayName = "AlertDialogFooter"
|
||||||
|
|
||||||
|
const AlertDialogTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-lg font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
|
||||||
|
|
||||||
|
const AlertDialogDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AlertDialogDescription.displayName =
|
||||||
|
AlertDialogPrimitive.Description.displayName
|
||||||
|
|
||||||
|
const AlertDialogAction = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Action>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Action
|
||||||
|
ref={ref}
|
||||||
|
className={cn(buttonVariants(), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
|
||||||
|
|
||||||
|
const AlertDialogCancel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Cancel
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
buttonVariants({ variant: "outline" }),
|
||||||
|
"mt-2 sm:mt-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
|
||||||
|
|
||||||
|
export {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogPortal,
|
||||||
|
AlertDialogOverlay,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
}
|
59
components/ui/alert.tsx
Normal file
59
components/ui/alert.tsx
Normal file
|
@ -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<HTMLDivElement> & VariantProps<typeof alertVariants>
|
||||||
|
>(({ className, variant, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
role="alert"
|
||||||
|
className={cn(alertVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Alert.displayName = "Alert"
|
||||||
|
|
||||||
|
const AlertTitle = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLHeadingElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<h5
|
||||||
|
ref={ref}
|
||||||
|
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AlertTitle.displayName = "AlertTitle"
|
||||||
|
|
||||||
|
const AlertDescription = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm [&_p]:leading-relaxed", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AlertDescription.displayName = "AlertDescription"
|
||||||
|
|
||||||
|
export { Alert, AlertTitle, AlertDescription }
|
7
components/ui/aspect-ratio.tsx
Normal file
7
components/ui/aspect-ratio.tsx
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"
|
||||||
|
|
||||||
|
const AspectRatio = AspectRatioPrimitive.Root
|
||||||
|
|
||||||
|
export { AspectRatio }
|
50
components/ui/avatar.tsx
Normal file
50
components/ui/avatar.tsx
Normal file
|
@ -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<typeof AvatarPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AvatarPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Avatar.displayName = AvatarPrimitive.Root.displayName
|
||||||
|
|
||||||
|
const AvatarImage = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AvatarPrimitive.Image>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AvatarPrimitive.Image
|
||||||
|
ref={ref}
|
||||||
|
className={cn("aspect-square h-full w-full", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AvatarImage.displayName = AvatarPrimitive.Image.displayName
|
||||||
|
|
||||||
|
const AvatarFallback = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AvatarPrimitive.Fallback>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AvatarPrimitive.Fallback
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex h-full w-full items-center justify-center rounded-full bg-muted",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
|
||||||
|
|
||||||
|
export { Avatar, AvatarImage, AvatarFallback }
|
36
components/ui/badge.tsx
Normal file
36
components/ui/badge.tsx
Normal file
|
@ -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<HTMLDivElement>,
|
||||||
|
VariantProps<typeof badgeVariants> {}
|
||||||
|
|
||||||
|
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants }
|
57
components/ui/button.tsx
Normal file
57
components/ui/button.tsx
Normal file
|
@ -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 whitespace-nowrap 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<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof buttonVariants> {
|
||||||
|
asChild?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : "button"
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Button.displayName = "Button"
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
71
components/ui/calendar.tsx
Normal file
71
components/ui/calendar.tsx
Normal file
|
@ -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<typeof DayPicker>
|
||||||
|
|
||||||
|
function Calendar({
|
||||||
|
className,
|
||||||
|
classNames,
|
||||||
|
showOutsideDays = true,
|
||||||
|
...props
|
||||||
|
}: CalendarProps) {
|
||||||
|
return (
|
||||||
|
<DayPicker
|
||||||
|
showOutsideDays={showOutsideDays}
|
||||||
|
className={cn("p-3", className)}
|
||||||
|
classNames={{
|
||||||
|
months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
|
||||||
|
month: "space-y-4",
|
||||||
|
caption: "flex justify-center pt-1 relative items-center",
|
||||||
|
caption_label: "text-sm font-medium",
|
||||||
|
nav: "space-x-1 flex items-center",
|
||||||
|
nav_button: cn(
|
||||||
|
buttonVariants({ variant: "outline" }),
|
||||||
|
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100"
|
||||||
|
),
|
||||||
|
nav_button_previous: "absolute left-1",
|
||||||
|
nav_button_next: "absolute right-1",
|
||||||
|
table: "w-full border-collapse space-y-1",
|
||||||
|
head_row: "flex",
|
||||||
|
head_cell:
|
||||||
|
"text-muted-foreground rounded-md w-8 font-normal text-[0.8rem]",
|
||||||
|
row: "flex w-full mt-2",
|
||||||
|
cell: cn(
|
||||||
|
"relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-accent",
|
||||||
|
props.mode === "range"
|
||||||
|
? "[&:has(>.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 }) => <ChevronLeftIcon className="h-4 w-4" />,
|
||||||
|
IconRight: ({ ...props }) => <ChevronRightIcon className="h-4 w-4" />,
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Calendar.displayName = "Calendar"
|
||||||
|
|
||||||
|
export { Calendar }
|
76
components/ui/card.tsx
Normal file
76
components/ui/card.tsx
Normal file
|
@ -0,0 +1,76 @@
|
||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Card = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"rounded-xl border bg-card text-card-foreground shadow",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Card.displayName = "Card"
|
||||||
|
|
||||||
|
const CardHeader = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardHeader.displayName = "CardHeader"
|
||||||
|
|
||||||
|
const CardTitle = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLHeadingElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<h3
|
||||||
|
ref={ref}
|
||||||
|
className={cn("font-semibold leading-none tracking-tight", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardTitle.displayName = "CardTitle"
|
||||||
|
|
||||||
|
const CardDescription = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<p
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardDescription.displayName = "CardDescription"
|
||||||
|
|
||||||
|
const CardContent = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||||
|
))
|
||||||
|
CardContent.displayName = "CardContent"
|
||||||
|
|
||||||
|
const CardFooter = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex items-center p-6 pt-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardFooter.displayName = "CardFooter"
|
||||||
|
|
||||||
|
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
30
components/ui/checkbox.tsx
Normal file
30
components/ui/checkbox.tsx
Normal file
|
@ -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<typeof CheckboxPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CheckboxPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<CheckboxPrimitive.Indicator
|
||||||
|
className={cn("flex items-center justify-center text-current")}
|
||||||
|
>
|
||||||
|
<CheckIcon className="h-4 w-4" />
|
||||||
|
</CheckboxPrimitive.Indicator>
|
||||||
|
</CheckboxPrimitive.Root>
|
||||||
|
))
|
||||||
|
Checkbox.displayName = CheckboxPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Checkbox }
|
11
components/ui/collapsible.tsx
Normal file
11
components/ui/collapsible.tsx
Normal file
|
@ -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 }
|
155
components/ui/command.tsx
Normal file
155
components/ui/command.tsx
Normal file
|
@ -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<typeof CommandPrimitive>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CommandPrimitive
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Command.displayName = CommandPrimitive.displayName
|
||||||
|
|
||||||
|
interface CommandDialogProps extends DialogProps {}
|
||||||
|
|
||||||
|
const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
|
||||||
|
return (
|
||||||
|
<Dialog {...props}>
|
||||||
|
<DialogContent className="overflow-hidden p-0">
|
||||||
|
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||||
|
{children}
|
||||||
|
</Command>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const CommandInput = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CommandPrimitive.Input>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
|
||||||
|
<MagnifyingGlassIcon className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
<CommandPrimitive.Input
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
|
||||||
|
CommandInput.displayName = CommandPrimitive.Input.displayName
|
||||||
|
|
||||||
|
const CommandList = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CommandPrimitive.List>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CommandPrimitive.List
|
||||||
|
ref={ref}
|
||||||
|
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
|
||||||
|
CommandList.displayName = CommandPrimitive.List.displayName
|
||||||
|
|
||||||
|
const CommandEmpty = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CommandPrimitive.Empty>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
|
||||||
|
>((props, ref) => (
|
||||||
|
<CommandPrimitive.Empty
|
||||||
|
ref={ref}
|
||||||
|
className="py-6 text-center text-sm"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
|
||||||
|
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
|
||||||
|
|
||||||
|
const CommandGroup = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CommandPrimitive.Group>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CommandPrimitive.Group
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
|
||||||
|
CommandGroup.displayName = CommandPrimitive.Group.displayName
|
||||||
|
|
||||||
|
const CommandSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CommandPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CommandPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn("-mx-1 h-px bg-border", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
|
||||||
|
|
||||||
|
const CommandItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CommandPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CommandPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
|
||||||
|
CommandItem.displayName = CommandPrimitive.Item.displayName
|
||||||
|
|
||||||
|
const CommandShortcut = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"ml-auto text-xs tracking-widest text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
CommandShortcut.displayName = "CommandShortcut"
|
||||||
|
|
||||||
|
export {
|
||||||
|
Command,
|
||||||
|
CommandDialog,
|
||||||
|
CommandInput,
|
||||||
|
CommandList,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandItem,
|
||||||
|
CommandShortcut,
|
||||||
|
CommandSeparator,
|
||||||
|
}
|
204
components/ui/context-menu.tsx
Normal file
204
components/ui/context-menu.tsx
Normal file
|
@ -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<typeof ContextMenuPrimitive.SubTrigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
|
||||||
|
inset?: boolean
|
||||||
|
}
|
||||||
|
>(({ className, inset, children, ...props }, ref) => (
|
||||||
|
<ContextMenuPrimitive.SubTrigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
|
||||||
|
inset && "pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRightIcon className="ml-auto h-4 w-4" />
|
||||||
|
</ContextMenuPrimitive.SubTrigger>
|
||||||
|
))
|
||||||
|
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName
|
||||||
|
|
||||||
|
const ContextMenuSubContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ContextMenuPrimitive.SubContent
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName
|
||||||
|
|
||||||
|
const ContextMenuContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ContextMenuPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ContextMenuPrimitive.Portal>
|
||||||
|
<ContextMenuPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</ContextMenuPrimitive.Portal>
|
||||||
|
))
|
||||||
|
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const ContextMenuItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ContextMenuPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
|
||||||
|
inset?: boolean
|
||||||
|
}
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<ContextMenuPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
inset && "pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName
|
||||||
|
|
||||||
|
const ContextMenuCheckboxItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
|
||||||
|
>(({ className, children, checked, ...props }, ref) => (
|
||||||
|
<ContextMenuPrimitive.CheckboxItem
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<ContextMenuPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon className="h-4 w-4" />
|
||||||
|
</ContextMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</ContextMenuPrimitive.CheckboxItem>
|
||||||
|
))
|
||||||
|
ContextMenuCheckboxItem.displayName =
|
||||||
|
ContextMenuPrimitive.CheckboxItem.displayName
|
||||||
|
|
||||||
|
const ContextMenuRadioItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<ContextMenuPrimitive.RadioItem
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<ContextMenuPrimitive.ItemIndicator>
|
||||||
|
<DotFilledIcon className="h-4 w-4 fill-current" />
|
||||||
|
</ContextMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</ContextMenuPrimitive.RadioItem>
|
||||||
|
))
|
||||||
|
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName
|
||||||
|
|
||||||
|
const ContextMenuLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ContextMenuPrimitive.Label>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
|
||||||
|
inset?: boolean
|
||||||
|
}
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<ContextMenuPrimitive.Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-1.5 text-sm font-semibold text-foreground",
|
||||||
|
inset && "pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName
|
||||||
|
|
||||||
|
const ContextMenuSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ContextMenuPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ContextMenuPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName
|
||||||
|
|
||||||
|
const ContextMenuShortcut = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"ml-auto text-xs tracking-widest text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
ContextMenuShortcut.displayName = "ContextMenuShortcut"
|
||||||
|
|
||||||
|
export {
|
||||||
|
ContextMenu,
|
||||||
|
ContextMenuTrigger,
|
||||||
|
ContextMenuContent,
|
||||||
|
ContextMenuItem,
|
||||||
|
ContextMenuCheckboxItem,
|
||||||
|
ContextMenuRadioItem,
|
||||||
|
ContextMenuLabel,
|
||||||
|
ContextMenuSeparator,
|
||||||
|
ContextMenuShortcut,
|
||||||
|
ContextMenuGroup,
|
||||||
|
ContextMenuPortal,
|
||||||
|
ContextMenuSub,
|
||||||
|
ContextMenuSubContent,
|
||||||
|
ContextMenuSubTrigger,
|
||||||
|
ContextMenuRadioGroup,
|
||||||
|
}
|
122
components/ui/dialog.tsx
Normal file
122
components/ui/dialog.tsx
Normal file
|
@ -0,0 +1,122 @@
|
||||||
|
"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 DialogClose = DialogPrimitive.Close
|
||||||
|
|
||||||
|
const DialogOverlay = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 z-50 bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||||
|
|
||||||
|
const DialogContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg md:w-full",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||||
|
<Cross2Icon className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
))
|
||||||
|
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const DialogHeader = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
DialogHeader.displayName = "DialogHeader"
|
||||||
|
|
||||||
|
const DialogFooter = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
DialogFooter.displayName = "DialogFooter"
|
||||||
|
|
||||||
|
const DialogTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"text-lg font-semibold leading-none tracking-tight",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||||
|
|
||||||
|
const DialogDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogPortal,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogTrigger,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogFooter,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
}
|
205
components/ui/dropdown-menu.tsx
Normal file
205
components/ui/dropdown-menu.tsx
Normal file
|
@ -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<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||||
|
inset?: boolean
|
||||||
|
}
|
||||||
|
>(({ className, inset, children, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
|
||||||
|
inset && "pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRightIcon className="ml-auto h-4 w-4" />
|
||||||
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
|
))
|
||||||
|
DropdownMenuSubTrigger.displayName =
|
||||||
|
DropdownMenuPrimitive.SubTrigger.displayName
|
||||||
|
|
||||||
|
const DropdownMenuSubContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.SubContent
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DropdownMenuSubContent.displayName =
|
||||||
|
DropdownMenuPrimitive.SubContent.displayName
|
||||||
|
|
||||||
|
const DropdownMenuContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||||
|
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Portal>
|
||||||
|
<DropdownMenuPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
|
||||||
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</DropdownMenuPrimitive.Portal>
|
||||||
|
))
|
||||||
|
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const DropdownMenuItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||||
|
inset?: boolean
|
||||||
|
}
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
inset && "pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
||||||
|
|
||||||
|
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
>(({ className, children, checked, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.CheckboxItem
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon className="h-4 w-4" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
))
|
||||||
|
DropdownMenuCheckboxItem.displayName =
|
||||||
|
DropdownMenuPrimitive.CheckboxItem.displayName
|
||||||
|
|
||||||
|
const DropdownMenuRadioItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.RadioItem
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<DotFilledIcon className="h-4 w-4 fill-current" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.RadioItem>
|
||||||
|
))
|
||||||
|
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
|
||||||
|
|
||||||
|
const DropdownMenuLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||||
|
inset?: boolean
|
||||||
|
}
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-1.5 text-sm font-semibold",
|
||||||
|
inset && "pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
||||||
|
|
||||||
|
const DropdownMenuSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
|
||||||
|
|
||||||
|
const DropdownMenuShortcut = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
|
||||||
|
|
||||||
|
export {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuShortcut,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
}
|
176
components/ui/form.tsx
Normal file
176
components/ui/form.tsx
Normal file
|
@ -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<TFieldValues> = FieldPath<TFieldValues>
|
||||||
|
> = {
|
||||||
|
name: TName
|
||||||
|
}
|
||||||
|
|
||||||
|
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||||
|
{} as FormFieldContextValue
|
||||||
|
)
|
||||||
|
|
||||||
|
const FormField = <
|
||||||
|
TFieldValues extends FieldValues = FieldValues,
|
||||||
|
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||||
|
>({
|
||||||
|
...props
|
||||||
|
}: ControllerProps<TFieldValues, TName>) => {
|
||||||
|
return (
|
||||||
|
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||||
|
<Controller {...props} />
|
||||||
|
</FormFieldContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 <FormField>")
|
||||||
|
}
|
||||||
|
|
||||||
|
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<FormItemContextValue>(
|
||||||
|
{} as FormItemContextValue
|
||||||
|
)
|
||||||
|
|
||||||
|
const FormItem = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
const id = React.useId()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormItemContext.Provider value={{ id }}>
|
||||||
|
<div ref={ref} className={cn("space-y-2", className)} {...props} />
|
||||||
|
</FormItemContext.Provider>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
FormItem.displayName = "FormItem"
|
||||||
|
|
||||||
|
const FormLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
const { error, formItemId } = useFormField()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn(error && "text-destructive", className)}
|
||||||
|
htmlFor={formItemId}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
FormLabel.displayName = "FormLabel"
|
||||||
|
|
||||||
|
const FormControl = React.forwardRef<
|
||||||
|
React.ElementRef<typeof Slot>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof Slot>
|
||||||
|
>(({ ...props }, ref) => {
|
||||||
|
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Slot
|
||||||
|
ref={ref}
|
||||||
|
id={formItemId}
|
||||||
|
aria-describedby={
|
||||||
|
!error
|
||||||
|
? `${formDescriptionId}`
|
||||||
|
: `${formDescriptionId} ${formMessageId}`
|
||||||
|
}
|
||||||
|
aria-invalid={!!error}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
FormControl.displayName = "FormControl"
|
||||||
|
|
||||||
|
const FormDescription = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
const { formDescriptionId } = useFormField()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
ref={ref}
|
||||||
|
id={formDescriptionId}
|
||||||
|
className={cn("text-[0.8rem] text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
FormDescription.displayName = "FormDescription"
|
||||||
|
|
||||||
|
const FormMessage = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, children, ...props }, ref) => {
|
||||||
|
const { error, formMessageId } = useFormField()
|
||||||
|
const body = error ? String(error?.message) : children
|
||||||
|
|
||||||
|
if (!body) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
ref={ref}
|
||||||
|
id={formMessageId}
|
||||||
|
className={cn("text-[0.8rem] font-medium text-destructive", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{body}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
FormMessage.displayName = "FormMessage"
|
||||||
|
|
||||||
|
export {
|
||||||
|
useFormField,
|
||||||
|
Form,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormMessage,
|
||||||
|
FormField,
|
||||||
|
}
|
29
components/ui/hover-card.tsx
Normal file
29
components/ui/hover-card.tsx
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const HoverCard = HoverCardPrimitive.Root
|
||||||
|
|
||||||
|
const HoverCardTrigger = HoverCardPrimitive.Trigger
|
||||||
|
|
||||||
|
const HoverCardContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof HoverCardPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
|
||||||
|
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||||
|
<HoverCardPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
align={align}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName
|
||||||
|
|
||||||
|
export { HoverCard, HoverCardTrigger, HoverCardContent }
|
25
components/ui/input.tsx
Normal file
25
components/ui/input.tsx
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
export interface InputProps
|
||||||
|
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||||
|
|
||||||
|
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||||
|
({ className, type, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
className={cn(
|
||||||
|
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Input.displayName = "Input"
|
||||||
|
|
||||||
|
export { Input }
|
26
components/ui/label.tsx
Normal file
26
components/ui/label.tsx
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const labelVariants = cva(
|
||||||
|
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||||
|
)
|
||||||
|
|
||||||
|
const Label = React.forwardRef<
|
||||||
|
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||||
|
VariantProps<typeof labelVariants>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(labelVariants(), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Label.displayName = LabelPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Label }
|
240
components/ui/menubar.tsx
Normal file
240
components/ui/menubar.tsx
Normal file
|
@ -0,0 +1,240 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import {
|
||||||
|
CheckIcon,
|
||||||
|
ChevronRightIcon,
|
||||||
|
DotFilledIcon,
|
||||||
|
} from "@radix-ui/react-icons"
|
||||||
|
import * as MenubarPrimitive from "@radix-ui/react-menubar"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const MenubarMenu = MenubarPrimitive.Menu
|
||||||
|
|
||||||
|
const MenubarGroup = MenubarPrimitive.Group
|
||||||
|
|
||||||
|
const MenubarPortal = MenubarPrimitive.Portal
|
||||||
|
|
||||||
|
const MenubarSub = MenubarPrimitive.Sub
|
||||||
|
|
||||||
|
const MenubarRadioGroup = MenubarPrimitive.RadioGroup
|
||||||
|
|
||||||
|
const Menubar = React.forwardRef<
|
||||||
|
React.ElementRef<typeof MenubarPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<MenubarPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex h-9 items-center space-x-1 rounded-md border bg-background p-1 shadow-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Menubar.displayName = MenubarPrimitive.Root.displayName
|
||||||
|
|
||||||
|
const MenubarTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof MenubarPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Trigger>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<MenubarPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default select-none items-center rounded-sm px-3 py-1 text-sm font-medium outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
MenubarTrigger.displayName = MenubarPrimitive.Trigger.displayName
|
||||||
|
|
||||||
|
const MenubarSubTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof MenubarPrimitive.SubTrigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubTrigger> & {
|
||||||
|
inset?: boolean
|
||||||
|
}
|
||||||
|
>(({ className, inset, children, ...props }, ref) => (
|
||||||
|
<MenubarPrimitive.SubTrigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
|
||||||
|
inset && "pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRightIcon className="ml-auto h-4 w-4" />
|
||||||
|
</MenubarPrimitive.SubTrigger>
|
||||||
|
))
|
||||||
|
MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName
|
||||||
|
|
||||||
|
const MenubarSubContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof MenubarPrimitive.SubContent>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubContent>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<MenubarPrimitive.SubContent
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
MenubarSubContent.displayName = MenubarPrimitive.SubContent.displayName
|
||||||
|
|
||||||
|
const MenubarContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof MenubarPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Content>
|
||||||
|
>(
|
||||||
|
(
|
||||||
|
{ className, align = "start", alignOffset = -4, sideOffset = 8, ...props },
|
||||||
|
ref
|
||||||
|
) => (
|
||||||
|
<MenubarPrimitive.Portal>
|
||||||
|
<MenubarPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
align={align}
|
||||||
|
alignOffset={alignOffset}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-50 min-w-[12rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</MenubarPrimitive.Portal>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
MenubarContent.displayName = MenubarPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const MenubarItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof MenubarPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Item> & {
|
||||||
|
inset?: boolean
|
||||||
|
}
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<MenubarPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
inset && "pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
MenubarItem.displayName = MenubarPrimitive.Item.displayName
|
||||||
|
|
||||||
|
const MenubarCheckboxItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof MenubarPrimitive.CheckboxItem>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.CheckboxItem>
|
||||||
|
>(({ className, children, checked, ...props }, ref) => (
|
||||||
|
<MenubarPrimitive.CheckboxItem
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<MenubarPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon className="h-4 w-4" />
|
||||||
|
</MenubarPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</MenubarPrimitive.CheckboxItem>
|
||||||
|
))
|
||||||
|
MenubarCheckboxItem.displayName = MenubarPrimitive.CheckboxItem.displayName
|
||||||
|
|
||||||
|
const MenubarRadioItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof MenubarPrimitive.RadioItem>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.RadioItem>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<MenubarPrimitive.RadioItem
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<MenubarPrimitive.ItemIndicator>
|
||||||
|
<DotFilledIcon className="h-4 w-4 fill-current" />
|
||||||
|
</MenubarPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</MenubarPrimitive.RadioItem>
|
||||||
|
))
|
||||||
|
MenubarRadioItem.displayName = MenubarPrimitive.RadioItem.displayName
|
||||||
|
|
||||||
|
const MenubarLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof MenubarPrimitive.Label>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Label> & {
|
||||||
|
inset?: boolean
|
||||||
|
}
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<MenubarPrimitive.Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-1.5 text-sm font-semibold",
|
||||||
|
inset && "pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
MenubarLabel.displayName = MenubarPrimitive.Label.displayName
|
||||||
|
|
||||||
|
const MenubarSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof MenubarPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<MenubarPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
MenubarSeparator.displayName = MenubarPrimitive.Separator.displayName
|
||||||
|
|
||||||
|
const MenubarShortcut = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"ml-auto text-xs tracking-widest text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
MenubarShortcut.displayname = "MenubarShortcut"
|
||||||
|
|
||||||
|
export {
|
||||||
|
Menubar,
|
||||||
|
MenubarMenu,
|
||||||
|
MenubarTrigger,
|
||||||
|
MenubarContent,
|
||||||
|
MenubarItem,
|
||||||
|
MenubarSeparator,
|
||||||
|
MenubarLabel,
|
||||||
|
MenubarCheckboxItem,
|
||||||
|
MenubarRadioGroup,
|
||||||
|
MenubarRadioItem,
|
||||||
|
MenubarPortal,
|
||||||
|
MenubarSubContent,
|
||||||
|
MenubarSubTrigger,
|
||||||
|
MenubarGroup,
|
||||||
|
MenubarSub,
|
||||||
|
MenubarShortcut,
|
||||||
|
}
|
128
components/ui/navigation-menu.tsx
Normal file
128
components/ui/navigation-menu.tsx
Normal file
|
@ -0,0 +1,128 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import { ChevronDownIcon } from "@radix-ui/react-icons"
|
||||||
|
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu"
|
||||||
|
import { cva } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const NavigationMenu = React.forwardRef<
|
||||||
|
React.ElementRef<typeof NavigationMenuPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Root>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<NavigationMenuPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative z-10 flex max-w-max flex-1 items-center justify-center",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<NavigationMenuViewport />
|
||||||
|
</NavigationMenuPrimitive.Root>
|
||||||
|
))
|
||||||
|
NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName
|
||||||
|
|
||||||
|
const NavigationMenuList = React.forwardRef<
|
||||||
|
React.ElementRef<typeof NavigationMenuPrimitive.List>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.List>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<NavigationMenuPrimitive.List
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"group flex flex-1 list-none items-center justify-center space-x-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName
|
||||||
|
|
||||||
|
const NavigationMenuItem = NavigationMenuPrimitive.Item
|
||||||
|
|
||||||
|
const navigationMenuTriggerStyle = cva(
|
||||||
|
"group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[active]:bg-accent/50 data-[state=open]:bg-accent/50"
|
||||||
|
)
|
||||||
|
|
||||||
|
const NavigationMenuTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof NavigationMenuPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Trigger>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<NavigationMenuPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(navigationMenuTriggerStyle(), "group", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}{" "}
|
||||||
|
<ChevronDownIcon
|
||||||
|
className="relative top-[1px] ml-1 h-3 w-3 transition duration-300 group-data-[state=open]:rotate-180"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</NavigationMenuPrimitive.Trigger>
|
||||||
|
))
|
||||||
|
NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName
|
||||||
|
|
||||||
|
const NavigationMenuContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof NavigationMenuPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Content>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<NavigationMenuPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"left-0 top-0 w-full data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 md:absolute md:w-auto ",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const NavigationMenuLink = NavigationMenuPrimitive.Link
|
||||||
|
|
||||||
|
const NavigationMenuViewport = React.forwardRef<
|
||||||
|
React.ElementRef<typeof NavigationMenuPrimitive.Viewport>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Viewport>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div className={cn("absolute left-0 top-full flex justify-center")}>
|
||||||
|
<NavigationMenuPrimitive.Viewport
|
||||||
|
className={cn(
|
||||||
|
"origin-top-center relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border bg-popover text-popover-foreground shadow data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 md:w-[var(--radix-navigation-menu-viewport-width)]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
NavigationMenuViewport.displayName =
|
||||||
|
NavigationMenuPrimitive.Viewport.displayName
|
||||||
|
|
||||||
|
const NavigationMenuIndicator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof NavigationMenuPrimitive.Indicator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Indicator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<NavigationMenuPrimitive.Indicator
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-border shadow-md" />
|
||||||
|
</NavigationMenuPrimitive.Indicator>
|
||||||
|
))
|
||||||
|
NavigationMenuIndicator.displayName =
|
||||||
|
NavigationMenuPrimitive.Indicator.displayName
|
||||||
|
|
||||||
|
export {
|
||||||
|
navigationMenuTriggerStyle,
|
||||||
|
NavigationMenu,
|
||||||
|
NavigationMenuList,
|
||||||
|
NavigationMenuItem,
|
||||||
|
NavigationMenuContent,
|
||||||
|
NavigationMenuTrigger,
|
||||||
|
NavigationMenuLink,
|
||||||
|
NavigationMenuIndicator,
|
||||||
|
NavigationMenuViewport,
|
||||||
|
}
|
31
components/ui/popover.tsx
Normal file
31
components/ui/popover.tsx
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Popover = PopoverPrimitive.Root
|
||||||
|
|
||||||
|
const PopoverTrigger = PopoverPrimitive.Trigger
|
||||||
|
|
||||||
|
const PopoverContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||||
|
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||||
|
<PopoverPrimitive.Portal>
|
||||||
|
<PopoverPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
align={align}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</PopoverPrimitive.Portal>
|
||||||
|
))
|
||||||
|
PopoverContent.displayName = PopoverPrimitive.Content.displayName
|
||||||
|
|
||||||
|
export { Popover, PopoverTrigger, PopoverContent }
|
28
components/ui/progress.tsx
Normal file
28
components/ui/progress.tsx
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as ProgressPrimitive from "@radix-ui/react-progress"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Progress = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ProgressPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
|
||||||
|
>(({ className, value, ...props }, ref) => (
|
||||||
|
<ProgressPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative h-2 w-full overflow-hidden rounded-full bg-primary/20",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ProgressPrimitive.Indicator
|
||||||
|
className="h-full w-full flex-1 bg-primary transition-all"
|
||||||
|
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||||
|
/>
|
||||||
|
</ProgressPrimitive.Root>
|
||||||
|
))
|
||||||
|
Progress.displayName = ProgressPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Progress }
|
44
components/ui/radio-group.tsx
Normal file
44
components/ui/radio-group.tsx
Normal file
|
@ -0,0 +1,44 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { CheckIcon } from "@radix-ui/react-icons"
|
||||||
|
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const RadioGroup = React.forwardRef<
|
||||||
|
React.ElementRef<typeof RadioGroupPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<RadioGroupPrimitive.Root
|
||||||
|
className={cn("grid gap-2", className)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
|
||||||
|
|
||||||
|
const RadioGroupItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof RadioGroupPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
|
||||||
|
>(({ className, children, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<RadioGroupPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"aspect-square h-4 w-4 rounded-full border border-primary text-primary shadow focus:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
|
||||||
|
<CheckIcon className="h-3.5 w-3.5 fill-primary" />
|
||||||
|
</RadioGroupPrimitive.Indicator>
|
||||||
|
</RadioGroupPrimitive.Item>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
|
||||||
|
|
||||||
|
export { RadioGroup, RadioGroupItem }
|
53
components/ui/scroll-area.tsx
Normal file
53
components/ui/scroll-area.tsx
Normal file
|
@ -0,0 +1,53 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const ScrollArea = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<ScrollAreaPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn("relative overflow-hidden", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
||||||
|
{children}
|
||||||
|
</ScrollAreaPrimitive.Viewport>
|
||||||
|
<ScrollBar />
|
||||||
|
<ScrollAreaPrimitive.Corner />
|
||||||
|
</ScrollAreaPrimitive.Root>
|
||||||
|
))
|
||||||
|
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
|
||||||
|
|
||||||
|
const ScrollBar = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||||
|
>(({ className, orientation = "vertical", ...props }, ref) => (
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||||
|
ref={ref}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"flex touch-none select-none transition-colors",
|
||||||
|
orientation === "vertical" &&
|
||||||
|
"h-full w-2.5 border-l border-l-transparent p-[1px]",
|
||||||
|
orientation === "horizontal" &&
|
||||||
|
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaThumb
|
||||||
|
className={cn(
|
||||||
|
"relative rounded-full bg-border",
|
||||||
|
orientation === "vertical" && "flex-1"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||||
|
))
|
||||||
|
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
|
||||||
|
|
||||||
|
export { ScrollArea, ScrollBar }
|
120
components/ui/select.tsx
Normal file
120
components/ui/select.tsx
Normal file
|
@ -0,0 +1,120 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons"
|
||||||
|
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Select = SelectPrimitive.Root
|
||||||
|
|
||||||
|
const SelectGroup = SelectPrimitive.Group
|
||||||
|
|
||||||
|
const SelectValue = SelectPrimitive.Value
|
||||||
|
|
||||||
|
const SelectTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex h-9 w-full items-center justify-between rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon asChild>
|
||||||
|
<CaretSortIcon className="h-4 w-4 opacity-50" />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
))
|
||||||
|
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||||
|
|
||||||
|
const SelectContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||||
|
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
|
position === "popper" &&
|
||||||
|
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
position={position}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectPrimitive.Viewport
|
||||||
|
className={cn(
|
||||||
|
"p-1",
|
||||||
|
position === "popper" &&
|
||||||
|
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
))
|
||||||
|
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const SelectLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
||||||
|
|
||||||
|
const SelectItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<SelectPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
))
|
||||||
|
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||||
|
|
||||||
|
const SelectSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectGroup,
|
||||||
|
SelectValue,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectContent,
|
||||||
|
SelectLabel,
|
||||||
|
SelectItem,
|
||||||
|
SelectSeparator,
|
||||||
|
}
|
31
components/ui/separator.tsx
Normal file
31
components/ui/separator.tsx
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Separator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||||
|
>(
|
||||||
|
(
|
||||||
|
{ className, orientation = "horizontal", decorative = true, ...props },
|
||||||
|
ref
|
||||||
|
) => (
|
||||||
|
<SeparatorPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
decorative={decorative}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 bg-border",
|
||||||
|
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Separator.displayName = SeparatorPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Separator }
|
140
components/ui/sheet.tsx
Normal file
140
components/ui/sheet.tsx
Normal file
|
@ -0,0 +1,140 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as SheetPrimitive from "@radix-ui/react-dialog";
|
||||||
|
import { Cross2Icon } from "@radix-ui/react-icons";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Sheet = SheetPrimitive.Root;
|
||||||
|
|
||||||
|
const SheetTrigger = SheetPrimitive.Trigger;
|
||||||
|
|
||||||
|
const SheetClose = SheetPrimitive.Close;
|
||||||
|
|
||||||
|
const SheetPortal = SheetPrimitive.Portal;
|
||||||
|
|
||||||
|
const SheetOverlay = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SheetPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SheetPrimitive.Overlay
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 z-50 bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
|
||||||
|
|
||||||
|
const sheetVariants = cva(
|
||||||
|
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
side: {
|
||||||
|
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
|
||||||
|
bottom:
|
||||||
|
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
|
||||||
|
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
|
||||||
|
right:
|
||||||
|
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
side: "right",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
interface SheetContentProps
|
||||||
|
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
|
||||||
|
VariantProps<typeof sheetVariants> {}
|
||||||
|
|
||||||
|
const SheetContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SheetPrimitive.Content>,
|
||||||
|
SheetContentProps
|
||||||
|
>(({ side = "right", className, children, ...props }, ref) => (
|
||||||
|
<SheetPortal>
|
||||||
|
<SheetOverlay />
|
||||||
|
<SheetPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(sheetVariants({ side }), className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
|
||||||
|
<Cross2Icon className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</SheetPrimitive.Close>
|
||||||
|
</SheetPrimitive.Content>
|
||||||
|
</SheetPortal>
|
||||||
|
));
|
||||||
|
SheetContent.displayName = SheetPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
const SheetHeader = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col space-y-2 text-center sm:text-left",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
SheetHeader.displayName = "SheetHeader";
|
||||||
|
|
||||||
|
const SheetFooter = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
SheetFooter.displayName = "SheetFooter";
|
||||||
|
|
||||||
|
const SheetTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SheetPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SheetPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-lg font-semibold text-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
SheetTitle.displayName = SheetPrimitive.Title.displayName;
|
||||||
|
|
||||||
|
const SheetDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SheetPrimitive.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SheetPrimitive.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
SheetDescription.displayName = SheetPrimitive.Description.displayName;
|
||||||
|
|
||||||
|
export {
|
||||||
|
Sheet,
|
||||||
|
SheetPortal,
|
||||||
|
SheetOverlay,
|
||||||
|
SheetTrigger,
|
||||||
|
SheetClose,
|
||||||
|
SheetContent,
|
||||||
|
SheetHeader,
|
||||||
|
SheetFooter,
|
||||||
|
SheetTitle,
|
||||||
|
SheetDescription,
|
||||||
|
};
|
15
components/ui/skeleton.tsx
Normal file
15
components/ui/skeleton.tsx
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Skeleton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn("animate-pulse rounded-md bg-primary/10", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Skeleton }
|
28
components/ui/slider.tsx
Normal file
28
components/ui/slider.tsx
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as SliderPrimitive from "@radix-ui/react-slider"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Slider = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SliderPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SliderPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-full touch-none select-none items-center",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SliderPrimitive.Track className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-primary/20">
|
||||||
|
<SliderPrimitive.Range className="absolute h-full bg-primary" />
|
||||||
|
</SliderPrimitive.Track>
|
||||||
|
<SliderPrimitive.Thumb className="block h-4 w-4 rounded-full border border-primary/50 bg-background shadow transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" />
|
||||||
|
</SliderPrimitive.Root>
|
||||||
|
))
|
||||||
|
Slider.displayName = SliderPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Slider }
|
29
components/ui/switch.tsx
Normal file
29
components/ui/switch.tsx
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as SwitchPrimitives from "@radix-ui/react-switch"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Switch = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SwitchPrimitives.Root
|
||||||
|
className={cn(
|
||||||
|
"peer inline-flex h-[20px] w-[36px] shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
>
|
||||||
|
<SwitchPrimitives.Thumb
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SwitchPrimitives.Root>
|
||||||
|
))
|
||||||
|
Switch.displayName = SwitchPrimitives.Root.displayName
|
||||||
|
|
||||||
|
export { Switch }
|
117
components/ui/table.tsx
Normal file
117
components/ui/table.tsx
Normal file
|
@ -0,0 +1,117 @@
|
||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Table = React.forwardRef<
|
||||||
|
HTMLTableElement,
|
||||||
|
React.HTMLAttributes<HTMLTableElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div className="relative w-full overflow-auto">
|
||||||
|
<table
|
||||||
|
ref={ref}
|
||||||
|
className={cn("w-full caption-bottom text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
Table.displayName = "Table"
|
||||||
|
|
||||||
|
const TableHeader = React.forwardRef<
|
||||||
|
HTMLTableSectionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
||||||
|
))
|
||||||
|
TableHeader.displayName = "TableHeader"
|
||||||
|
|
||||||
|
const TableBody = React.forwardRef<
|
||||||
|
HTMLTableSectionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<tbody
|
||||||
|
ref={ref}
|
||||||
|
className={cn("[&_tr:last-child]:border-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableBody.displayName = "TableBody"
|
||||||
|
|
||||||
|
const TableFooter = React.forwardRef<
|
||||||
|
HTMLTableSectionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<tfoot
|
||||||
|
ref={ref}
|
||||||
|
className={cn("bg-primary font-medium text-primary-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableFooter.displayName = "TableFooter"
|
||||||
|
|
||||||
|
const TableRow = React.forwardRef<
|
||||||
|
HTMLTableRowElement,
|
||||||
|
React.HTMLAttributes<HTMLTableRowElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<tr
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableRow.displayName = "TableRow"
|
||||||
|
|
||||||
|
const TableHead = React.forwardRef<
|
||||||
|
HTMLTableCellElement,
|
||||||
|
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<th
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableHead.displayName = "TableHead"
|
||||||
|
|
||||||
|
const TableCell = React.forwardRef<
|
||||||
|
HTMLTableCellElement,
|
||||||
|
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<td
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableCell.displayName = "TableCell"
|
||||||
|
|
||||||
|
const TableCaption = React.forwardRef<
|
||||||
|
HTMLTableCaptionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableCaptionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<caption
|
||||||
|
ref={ref}
|
||||||
|
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableCaption.displayName = "TableCaption"
|
||||||
|
|
||||||
|
export {
|
||||||
|
Table,
|
||||||
|
TableHeader,
|
||||||
|
TableBody,
|
||||||
|
TableFooter,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
TableCell,
|
||||||
|
TableCaption,
|
||||||
|
}
|
55
components/ui/tabs.tsx
Normal file
55
components/ui/tabs.tsx
Normal file
|
@ -0,0 +1,55 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Tabs = TabsPrimitive.Root
|
||||||
|
|
||||||
|
const TabsList = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.List>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.List
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TabsList.displayName = TabsPrimitive.List.displayName
|
||||||
|
|
||||||
|
const TabsTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||||
|
|
||||||
|
const TabsContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||||
|
|
||||||
|
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user