import { AlertTriangle, ArrowUpDown, BookIcon, 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 { TagBadge } from "@/components/shared/tag-badge"; import { TagFilter } from "@/components/shared/tag-filter"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger, } from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, } from "@/components/ui/card"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, 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, isPending } = api.project.all.useQuery(); const { data: auth } = api.user.get.useQuery(); const { data: permissions } = api.user.getPermissions.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(() => { if (typeof window !== "undefined") { return localStorage.getItem("projectsSort") || "createdAt-desc"; } return "createdAt-desc"; }); const [selectedTagIds, setSelectedTagIds] = useState(() => { 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 (!availableTags) return; const validIds = new Set(availableTags.map((t) => t.tagId)); setSelectedTagIds((prev) => { const filtered = prev.filter((id) => validIds.has(id)); return filtered.length === prev.length ? prev : filtered; }); }, [availableTags]); 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 ( <> {!isCloud && (
)}
Projects Create and manage your projects {permissions?.project.create && (
)}
{isPending ? (
Loading...
) : ( <>
setSearchQuery(e.target.value)} className="pr-10" />
({ id: tag.tagId, name: tag.name, color: tag.color || undefined, })) || [] } selectedTags={selectedTagIds} onTagsChange={setSelectedTagIds} />
{filteredProjects?.length === 0 && (
No projects found
)}
{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); // 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 (
{ if (hasNoEnvironments) { e.preventDefault(); } }} >
{project.name}
{project.description} {project.projectTags && project.projectTags.length > 0 && (
{project.projectTags.map((pt) => ( ))}
)} {hasNoEnvironments && (
You have access to this project but no environments are available
)}
e.stopPropagation()} > Actions
e.stopPropagation()} >
e.stopPropagation()} >
e.stopPropagation()} > {permissions?.project.delete && ( e.preventDefault() } > Delete Are you sure to delete this project? {!emptyServices ? (
You have active services, please delete them first
) : ( This action cannot be undone )}
Cancel { await mutateAsync({ projectId: project.projectId, }) .then(() => { toast.success( "Project deleted successfully", ); }) .catch(() => { toast.error( "Error deleting this project", ); }) .finally(() => { utils.project.all.invalidate(); }); }} > Delete
)}
Created {totalServices}{" "} {totalServices === 1 ? "service" : "services"}
); })}
)}
); };