Files
dokploy/apps/dokploy/components/dashboard/projects/show.tsx
T
Andrey Onishchenko affd17d788 feat: add project tags for organizing services
Add tag management system that allows users to create, edit, and delete
tags scoped to their organization, and assign them to projects for
better organization and filtering.

- Add tag and project_tag database schemas with Drizzle migration
- Add tRPC router for tag CRUD and project-tag assignment operations
- Add tag management page in Settings with color picker
- Add tag selector to project create/edit form
- Add tag filter to project list with localStorage persistence
- Display tag badges on project cards
2026-02-13 19:14:27 +03:00

658 lines
23 KiB
TypeScript

import {
AlertTriangle,
ArrowUpDown,
BookIcon,
ExternalLinkIcon,
FolderInput,
Loader2,
MoreHorizontalIcon,
Search,
TrashIcon,
} from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/router";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { BreadcrumbSidebar } from "@/components/shared/breadcrumb-sidebar";
import { DateTooltip } from "@/components/shared/date-tooltip";
import { FocusShortcutInput } from "@/components/shared/focus-shortcut-input";
import { StatusTooltip } from "@/components/shared/status-tooltip";
import { TagFilter } from "@/components/shared/tag-filter";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { TimeBadge } from "@/components/ui/time-badge";
import { api } from "@/utils/api";
import { useDebounce } from "@/utils/hooks/use-debounce";
import { HandleProject } from "./handle-project";
import { ProjectEnvironment } from "./project-environment";
export const ShowProjects = () => {
const utils = api.useUtils();
const router = useRouter();
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data, isLoading } = api.project.all.useQuery();
const { data: auth } = api.user.get.useQuery();
const { mutateAsync } = api.project.remove.useMutation();
const { data: availableTags } = api.tag.all.useQuery();
const [searchQuery, setSearchQuery] = useState(
router.isReady && typeof router.query.q === "string" ? router.query.q : "",
);
const debouncedSearchQuery = useDebounce(searchQuery, 500);
const [sortBy, setSortBy] = useState<string>(() => {
if (typeof window !== "undefined") {
return localStorage.getItem("projectsSort") || "createdAt-desc";
}
return "createdAt-desc";
});
const [selectedTagIds, setSelectedTagIds] = useState<string[]>(() => {
if (typeof window !== "undefined") {
const saved = localStorage.getItem("projectsTagFilter");
return saved ? JSON.parse(saved) : [];
}
return [];
});
useEffect(() => {
localStorage.setItem("projectsSort", sortBy);
}, [sortBy]);
useEffect(() => {
localStorage.setItem("projectsTagFilter", JSON.stringify(selectedTagIds));
}, [selectedTagIds]);
useEffect(() => {
if (!router.isReady) return;
const urlQuery = typeof router.query.q === "string" ? router.query.q : "";
if (urlQuery !== searchQuery) {
setSearchQuery(urlQuery);
}
}, [router.isReady, router.query.q]);
useEffect(() => {
if (!router.isReady) return;
const urlQuery = typeof router.query.q === "string" ? router.query.q : "";
if (debouncedSearchQuery === urlQuery) return;
const newQuery = { ...router.query };
if (debouncedSearchQuery) {
newQuery.q = debouncedSearchQuery;
} else {
delete newQuery.q;
}
router.replace({ pathname: router.pathname, query: newQuery }, undefined, {
shallow: true,
});
}, [debouncedSearchQuery]);
const filteredProjects = useMemo(() => {
if (!data) return [];
let filtered = data.filter(
(project) =>
project.name
.toLowerCase()
.includes(debouncedSearchQuery.toLowerCase()) ||
project.description
?.toLowerCase()
.includes(debouncedSearchQuery.toLowerCase()),
);
// Filter by selected tags (OR logic: show projects with ANY selected tag)
if (selectedTagIds.length > 0) {
filtered = filtered.filter((project) =>
project.projectTags?.some((pt) =>
selectedTagIds.includes(pt.tag.tagId),
),
);
}
// Then sort the filtered results
const [field, direction] = sortBy.split("-");
return [...filtered].sort((a, b) => {
let comparison = 0;
switch (field) {
case "name":
comparison = a.name.localeCompare(b.name);
break;
case "createdAt":
comparison =
new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
break;
case "services": {
const aTotalServices = a.environments.reduce((total, env) => {
return (
total +
(env.applications?.length || 0) +
(env.mariadb?.length || 0) +
(env.mongo?.length || 0) +
(env.mysql?.length || 0) +
(env.postgres?.length || 0) +
(env.redis?.length || 0) +
(env.compose?.length || 0)
);
}, 0);
const bTotalServices = b.environments.reduce((total, env) => {
return (
total +
(env.applications?.length || 0) +
(env.mariadb?.length || 0) +
(env.mongo?.length || 0) +
(env.mysql?.length || 0) +
(env.postgres?.length || 0) +
(env.redis?.length || 0) +
(env.compose?.length || 0)
);
}, 0);
comparison = aTotalServices - bTotalServices;
break;
}
default:
comparison = 0;
}
return direction === "asc" ? comparison : -comparison;
});
}, [data, debouncedSearchQuery, sortBy, selectedTagIds]);
return (
<>
<BreadcrumbSidebar
list={[{ name: "Projects", href: "/dashboard/projects" }]}
/>
{!isCloud && (
<div className="absolute top-4 right-4">
<TimeBadge />
</div>
)}
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl ">
<div className="rounded-xl bg-background shadow-md ">
<div className="flex justify-between gap-4 w-full items-center flex-wrap p-6">
<CardHeader className="p-0">
<CardTitle className="text-xl flex flex-row gap-2">
<FolderInput className="size-6 text-muted-foreground self-center" />
Projects
</CardTitle>
<CardDescription>
Create and manage your projects
</CardDescription>
</CardHeader>
{(auth?.role === "owner" ||
auth?.role === "admin" ||
auth?.canCreateProjects) && (
<div className="">
<HandleProject />
</div>
)}
</div>
<CardContent className="space-y-2 py-8 border-t gap-4 flex flex-col min-h-[60vh]">
{isLoading ? (
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground min-h-[60vh]">
<span>Loading...</span>
<Loader2 className="animate-spin size-4" />
</div>
) : (
<>
<div className="flex max-sm:flex-col gap-4 items-center w-full">
<div className="flex-1 relative max-sm:w-full">
<FocusShortcutInput
placeholder="Filter projects..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pr-10"
/>
<Search className="absolute right-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
</div>
<div className="flex items-center gap-2">
<TagFilter
tags={
availableTags?.map((tag) => ({
id: tag.tagId,
name: tag.name,
color: tag.color || undefined,
})) || []
}
selectedTags={selectedTagIds}
onTagsChange={setSelectedTagIds}
/>
<div className="flex items-center gap-2 min-w-48 max-sm:w-full">
<ArrowUpDown className="size-4 text-muted-foreground" />
<Select value={sortBy} onValueChange={setSortBy}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Sort by..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="name-asc">Name (A-Z)</SelectItem>
<SelectItem value="name-desc">
Name (Z-A)
</SelectItem>
<SelectItem value="createdAt-desc">
Newest first
</SelectItem>
<SelectItem value="createdAt-asc">
Oldest first
</SelectItem>
<SelectItem value="services-desc">
Most services
</SelectItem>
<SelectItem value="services-asc">
Least services
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
{filteredProjects?.length === 0 && (
<div className="mt-6 flex h-[50vh] w-full flex-col items-center justify-center space-y-4">
<FolderInput className="size-8 self-center text-muted-foreground" />
<span className="text-center font-medium text-muted-foreground">
No projects found
</span>
</div>
)}
<div className="w-full grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4 3xl:grid-cols-5 flex-wrap gap-5">
{filteredProjects?.map((project) => {
const emptyServices = project?.environments
.map(
(env) =>
env.applications.length === 0 &&
env.mariadb.length === 0 &&
env.mongo.length === 0 &&
env.mysql.length === 0 &&
env.postgres.length === 0 &&
env.redis.length === 0 &&
env.applications.length === 0 &&
env.compose.length === 0,
)
.every(Boolean);
const totalServices = project?.environments
.map(
(env) =>
env.mariadb.length +
env.mongo.length +
env.mysql.length +
env.postgres.length +
env.redis.length +
env.applications.length +
env.compose.length,
)
.reduce((acc, curr) => acc + curr, 0);
const haveServicesWithDomains = project?.environments
.map(
(env) =>
env.applications.length > 0 ||
env.compose.length > 0,
)
.some(Boolean);
// Find default environment from accessible environments, or fall back to first accessible environment
const accessibleEnvironment =
project?.environments.find((env) => env.isDefault) ||
project?.environments?.[0];
const hasNoEnvironments = !accessibleEnvironment;
return (
<div
key={project.projectId}
className="w-full lg:max-w-md"
>
<Link
href={
hasNoEnvironments
? "#"
: `/dashboard/project/${project.projectId}/environment/${accessibleEnvironment?.environmentId}`
}
onClick={(e) => {
if (hasNoEnvironments) {
e.preventDefault();
}
}}
>
<Card className="group relative w-full h-full bg-transparent transition-colors hover:bg-border">
{haveServicesWithDomains ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<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>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-[200px] space-y-2 overflow-y-auto max-h-[400px]"
onClick={(e) => e.stopPropagation()}
>
{project.environments.some(
(env) => env.applications.length > 0,
) && (
<DropdownMenuGroup>
<DropdownMenuLabel>
Applications
</DropdownMenuLabel>
{project.environments.map((env) =>
env.applications.map((app) => (
<div key={app.applicationId}>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuLabel className="font-normal capitalize text-xs flex items-center justify-between">
{app.name}
<StatusTooltip
status={
app.applicationStatus
}
/>
</DropdownMenuLabel>
<DropdownMenuSeparator />
{app.domains.map((domain) => (
<DropdownMenuItem
key={domain.domainId}
asChild
>
<Link
className="space-x-4 text-xs cursor-pointer justify-between"
target="_blank"
href={`${
domain.https
? "https"
: "http"
}://${domain.host}${
domain.path
}`}
>
<span className="truncate">
{domain.host}
</span>
<ExternalLinkIcon className="size-4 shrink-0" />
</Link>
</DropdownMenuItem>
))}
</DropdownMenuGroup>
</div>
)),
)}
</DropdownMenuGroup>
)}
{project.environments.some(
(env) => env.compose.length > 0,
) && (
<DropdownMenuGroup>
<DropdownMenuLabel>
Compose
</DropdownMenuLabel>
{project.environments.map((env) =>
env.compose.map((comp) => (
<div key={comp.composeId}>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuLabel className="font-normal capitalize text-xs flex items-center justify-between">
{comp.name}
<StatusTooltip
status={comp.composeStatus}
/>
</DropdownMenuLabel>
<DropdownMenuSeparator />
{comp.domains.map((domain) => (
<DropdownMenuItem
key={domain.domainId}
asChild
>
<Link
className="space-x-4 text-xs cursor-pointer justify-between"
target="_blank"
href={`${
domain.https
? "https"
: "http"
}://${domain.host}${
domain.path
}`}
>
<span className="truncate">
{domain.host}
</span>
<ExternalLinkIcon className="size-4 shrink-0" />
</Link>
</DropdownMenuItem>
))}
</DropdownMenuGroup>
</div>
)),
)}
</DropdownMenuGroup>
)}
</DropdownMenuContent>
</DropdownMenu>
) : null}
<CardHeader>
<CardTitle className="flex items-center justify-between gap-2 overflow-clip">
<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 break-normal">
{project.description}
</span>
{project.projectTags &&
project.projectTags.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-2">
{project.projectTags.map((pt) => (
<Badge
key={pt.tag.tagId}
variant="blank"
style={{
backgroundColor: pt.tag.color
? `${pt.tag.color}33`
: undefined,
color:
pt.tag.color || undefined,
borderColor: pt.tag.color
? `${pt.tag.color}66`
: undefined,
}}
className="border"
>
{pt.tag.name}
</Badge>
))}
</div>
)}
{hasNoEnvironments && (
<div className="flex flex-row gap-2 items-center rounded-lg bg-yellow-50 p-2 mt-2 dark:bg-yellow-950">
<AlertTriangle className="size-4 text-yellow-600 dark:text-yellow-400 shrink-0" />
<span className="text-xs text-yellow-600 dark:text-yellow-400">
You have access to this project but no
environments are available
</span>
</div>
)}
</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 overflow-y-auto max-h-[280px]"
onClick={(e) => e.stopPropagation()}
>
<DropdownMenuLabel className="font-normal">
Actions
</DropdownMenuLabel>
<div
onClick={(e) => e.stopPropagation()}
>
<ProjectEnvironment
projectId={project.projectId}
/>
</div>
<div
onClick={(e) => e.stopPropagation()}
>
<HandleProject
projectId={project.projectId}
/>
</div>
<div
onClick={(e) => e.stopPropagation()}
>
{(auth?.role === "owner" ||
auth?.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 deleted successfully",
);
})
.catch(() => {
toast.error(
"Error deleting this project",
);
})
.finally(() => {
utils.project.all.invalidate();
});
}}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
</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>
</Link>
</div>
);
})}
</div>
</>
)}
</CardContent>
</div>
</Card>
</div>
</>
);
};