mirror of
https://github.com/Dokploy/dokploy
synced 2025-06-26 18:27:59 +00:00
feat: initial commit
This commit is contained in:
148
components/dashboard/projects/add.tsx
Normal file
148
components/dashboard/projects/add.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { AlertTriangle, PlusIcon } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
||||
const AddProjectSchema = z.object({
|
||||
name: z.string().min(1, {
|
||||
message: "Name is required",
|
||||
}),
|
||||
description: z.string().optional(),
|
||||
});
|
||||
|
||||
type AddProject = z.infer<typeof AddProjectSchema>;
|
||||
|
||||
export const AddProject = () => {
|
||||
const utils = api.useUtils();
|
||||
|
||||
const { mutateAsync, isLoading, error, isError } =
|
||||
api.project.create.useMutation();
|
||||
|
||||
const form = useForm<AddProject>({
|
||||
defaultValues: {
|
||||
description: "",
|
||||
name: "",
|
||||
},
|
||||
resolver: zodResolver(AddProjectSchema),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
form.reset({
|
||||
description: "",
|
||||
name: "",
|
||||
});
|
||||
}, [form, form.reset, form.formState.isSubmitSuccessful]);
|
||||
|
||||
const onSubmit = async (data: AddProject) => {
|
||||
await mutateAsync({
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Project Created");
|
||||
await utils.project.all.invalidate();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error to create a project");
|
||||
});
|
||||
};
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
Create Project
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:m:max-w-lg ">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add a project</DialogTitle>
|
||||
<DialogDescription>The home of something big!</DialogDescription>
|
||||
</DialogHeader>
|
||||
{isError && (
|
||||
<div className="flex flex-row gap-4 rounded-lg bg-red-50 p-2 dark:bg-red-950">
|
||||
<AlertTriangle className="text-red-600 dark:text-red-400" />
|
||||
<span className="text-sm text-red-600 dark:text-red-400">
|
||||
{error?.message}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<Form {...form}>
|
||||
<form
|
||||
id="hook-form-add-project"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="grid w-full gap-4"
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Tesla" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Description about your project..."
|
||||
className="resize-none"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
isLoading={isLoading}
|
||||
form="hook-form-add-project"
|
||||
type="submit"
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
57
components/dashboard/projects/project.tsx
Normal file
57
components/dashboard/projects/project.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
// import { ComponentProps } from "react";
|
||||
// import { formatDistanceToNow } from "date-fns";
|
||||
// import { cn } from "@/lib/utils";
|
||||
// import { Badge } from "@/components/ui/badge";
|
||||
|
||||
// interface Props {
|
||||
// item: ProjectType;
|
||||
// }
|
||||
|
||||
// export const Project = ({ item }: Props) => {
|
||||
// return (
|
||||
// <button
|
||||
// className={cn(
|
||||
// "flex flex-col items-start gap-2 rounded-lg border p-5 text-left text-sm transition-all hover:bg-accent",
|
||||
// )}
|
||||
// >
|
||||
// <div className="flex w-full flex-col gap-1">
|
||||
// <div className="flex items-center">
|
||||
// <div className="flex items-center gap-2">
|
||||
// <div className="font-semibold">{item.name}</div>
|
||||
// {!item.read && (
|
||||
// <span className="flex h-2 w-2 rounded-full bg-blue-600" />
|
||||
// )}
|
||||
// </div>
|
||||
// <div className={cn("ml-auto text-xs", "text-muted-foreground")}>
|
||||
// {formatDistanceToNow(new Date(item.date), {
|
||||
// addSuffix: true,
|
||||
// })}
|
||||
// </div>
|
||||
// </div>
|
||||
// </div>
|
||||
// {item.labels.length ? (
|
||||
// <div className="flex items-center gap-2">
|
||||
// {item.labels.map((label) => (
|
||||
// <Badge key={label} variant={getBadgeVariantFromLabel(label)}>
|
||||
// {label}
|
||||
// </Badge>
|
||||
// ))}
|
||||
// </div>
|
||||
// ) : null}
|
||||
// </button>
|
||||
// );
|
||||
// };
|
||||
|
||||
// const getBadgeVariantFromLabel = (
|
||||
// label: string,
|
||||
// ): ComponentProps<typeof Badge>["variant"] => {
|
||||
// if (["work"].includes(label.toLowerCase())) {
|
||||
// return "default";
|
||||
// }
|
||||
|
||||
// if (["personal"].includes(label.toLowerCase())) {
|
||||
// return "outline";
|
||||
// }
|
||||
|
||||
// return "secondary";
|
||||
// };
|
||||
196
components/dashboard/projects/show.tsx
Normal file
196
components/dashboard/projects/show.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { api } from "@/utils/api";
|
||||
import {
|
||||
AlertTriangle,
|
||||
BookIcon,
|
||||
ExternalLinkIcon,
|
||||
FolderInput,
|
||||
MoreHorizontalIcon,
|
||||
TrashIcon,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { toast } from "sonner";
|
||||
import { UpdateProject } from "./update";
|
||||
import { DateTooltip } from "@/components/shared/date-tooltip";
|
||||
|
||||
export const ShowProjects = () => {
|
||||
const utils = api.useUtils();
|
||||
const { data } = api.project.all.useQuery();
|
||||
const { data: auth } = api.auth.get.useQuery();
|
||||
const { data: user } = api.user.byAuthId.useQuery(
|
||||
{
|
||||
authId: auth?.id || "",
|
||||
},
|
||||
{
|
||||
enabled: !!auth?.id && auth?.rol === "user",
|
||||
},
|
||||
);
|
||||
const { mutateAsync } = api.project.remove.useMutation();
|
||||
return (
|
||||
<>
|
||||
{data?.length === 0 && (
|
||||
<div className="mt-6 flex h-[50vh] w-full flex-col items-center justify-center ">
|
||||
<FolderInput className="size-10 md:size-28 text-muted" />
|
||||
<span className="text-center font-medium text-muted-foreground">
|
||||
No projects added yet. Click on Create project.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-6 w-full grid sm:grid-cols-2 lg:grid-cols-3 flex-wrap gap-5 pb-10">
|
||||
{data?.map((project) => {
|
||||
const emptyServices =
|
||||
project?.mariadb.length === 0 &&
|
||||
project?.mongo.length === 0 &&
|
||||
project?.mysql.length === 0 &&
|
||||
project?.postgres.length === 0 &&
|
||||
project?.redis.length === 0 &&
|
||||
project?.applications.length === 0;
|
||||
|
||||
const totalServices =
|
||||
project?.mariadb.length +
|
||||
project?.mongo.length +
|
||||
project?.mysql.length +
|
||||
project?.postgres.length +
|
||||
project?.redis.length +
|
||||
project?.applications.length;
|
||||
return (
|
||||
<div key={project.projectId} className="w-full lg:max-w-md">
|
||||
<Card className="group relative w-full bg-transparent transition-colors hover:bg-card">
|
||||
<Link href={`/dashboard/project/${project.projectId}`}>
|
||||
<Button
|
||||
className="absolute -right-3 -top-3 size-9 translate-y-1 rounded-full p-0 opacity-0 transition-all duration-200 group-hover:translate-y-0 group-hover:opacity-100"
|
||||
size="sm"
|
||||
variant="default"
|
||||
>
|
||||
<ExternalLinkIcon className="size-3.5" />
|
||||
</Button>
|
||||
</Link>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between gap-2">
|
||||
<span className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<BookIcon className="size-4 text-muted-foreground" />
|
||||
<span className="text-base font-medium leading-none">
|
||||
{project.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
{project.description}
|
||||
</span>
|
||||
</span>
|
||||
<div className="flex self-start space-x-1">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="px-2">
|
||||
<MoreHorizontalIcon className="size-5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-[200px] space-y-2">
|
||||
<DropdownMenuLabel className="font-normal">
|
||||
Actions
|
||||
</DropdownMenuLabel>
|
||||
|
||||
<UpdateProject projectId={project.projectId} />
|
||||
|
||||
{(auth?.rol === "admin" ||
|
||||
user?.canDeleteProjects) && (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger className="w-full">
|
||||
<DropdownMenuItem
|
||||
className="w-full cursor-pointer space-x-3"
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
>
|
||||
<TrashIcon className="size-4" />
|
||||
<span>Delete</span>
|
||||
</DropdownMenuItem>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
Are you sure to delete this project?
|
||||
</AlertDialogTitle>
|
||||
{!emptyServices ? (
|
||||
<div className="flex flex-row gap-4 rounded-lg bg-yellow-50 p-2 dark:bg-yellow-950">
|
||||
<AlertTriangle className="text-yellow-600 dark:text-yellow-400" />
|
||||
<span className="text-sm text-yellow-600 dark:text-yellow-400">
|
||||
You have active services, please delete
|
||||
them first
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<AlertDialogDescription>
|
||||
This action cannot be undone
|
||||
</AlertDialogDescription>
|
||||
)}
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
disabled={!emptyServices}
|
||||
onClick={async () => {
|
||||
await mutateAsync({
|
||||
projectId: project.projectId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success(
|
||||
"Project delete succesfully",
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error(
|
||||
"Error to delete this project",
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
utils.project.all.invalidate();
|
||||
});
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardFooter className="pt-4">
|
||||
<div className="space-y-1 text-sm flex flex-row justify-between max-sm:flex-wrap w-full gap-2 sm:gap-4">
|
||||
<DateTooltip date={project.createdAt}>Created</DateTooltip>
|
||||
<span>
|
||||
{totalServices}{" "}
|
||||
{totalServices === 1 ? "service" : "services"}
|
||||
</span>
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
165
components/dashboard/projects/update.tsx
Normal file
165
components/dashboard/projects/update.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useEffect } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { AlertTriangle, SquarePen } from "lucide-react";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
const updateProjectSchema = z.object({
|
||||
name: z.string().min(1, {
|
||||
message: "Name is required",
|
||||
}),
|
||||
description: z.string().optional(),
|
||||
});
|
||||
|
||||
type UpdateProject = z.infer<typeof updateProjectSchema>;
|
||||
|
||||
interface Props {
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
export const UpdateProject = ({ projectId }: Props) => {
|
||||
const utils = api.useUtils();
|
||||
const { mutateAsync, error, isError } = api.project.update.useMutation();
|
||||
const { data } = api.project.one.useQuery(
|
||||
{
|
||||
projectId,
|
||||
},
|
||||
{
|
||||
enabled: !!projectId,
|
||||
},
|
||||
);
|
||||
const form = useForm<UpdateProject>({
|
||||
defaultValues: {
|
||||
description: data?.description ?? "",
|
||||
name: data?.name ?? "",
|
||||
},
|
||||
resolver: zodResolver(updateProjectSchema),
|
||||
});
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset({
|
||||
description: data.description ?? "",
|
||||
name: data.name,
|
||||
});
|
||||
}
|
||||
}, [data, form, form.reset]);
|
||||
|
||||
const onSubmit = async (formData: UpdateProject) => {
|
||||
await mutateAsync({
|
||||
name: formData.name,
|
||||
projectId: projectId,
|
||||
description: formData.description || "",
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Project updated succesfully");
|
||||
utils.project.all.invalidate();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error to update the project");
|
||||
})
|
||||
.finally(() => {});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<DropdownMenuItem
|
||||
className="w-full cursor-pointer space-x-3"
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
>
|
||||
<SquarePen className="size-4" />
|
||||
<span>Update</span>
|
||||
</DropdownMenuItem>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Modify project</DialogTitle>
|
||||
<DialogDescription>
|
||||
Update the details of the project
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{isError && (
|
||||
<div className="flex flex-row gap-4 rounded-lg bg-red-50 p-2 dark:bg-red-950">
|
||||
<AlertTriangle className="text-red-600 dark:text-red-400" />
|
||||
<span className="text-sm text-red-600 dark:text-red-400">
|
||||
{error.message}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4">
|
||||
<div className="grid items-center gap-4">
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
id="hook-form-update-project"
|
||||
className="grid w-full gap-4 "
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Tesla" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Description about your project..."
|
||||
className="resize-none"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button form="hook-form-update-project" type="submit">
|
||||
Update
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user