diff --git a/apps/dashboard/components/navigation/sidebar/app-sidebar/components/nav-items/flat-nav-item.tsx b/apps/dashboard/components/navigation/sidebar/app-sidebar/components/nav-items/flat-nav-item.tsx
index 1c7466102e..42c1453204 100644
--- a/apps/dashboard/components/navigation/sidebar/app-sidebar/components/nav-items/flat-nav-item.tsx
+++ b/apps/dashboard/components/navigation/sidebar/app-sidebar/components/nav-items/flat-nav-item.tsx
@@ -2,18 +2,12 @@ import { SidebarMenuButton, SidebarMenuItem } from "@/components/ui/sidebar";
import { useDelayLoader } from "@/hooks/use-delay-loader";
import { useRouter } from "next/navigation";
import { useTransition } from "react";
-import type { NavItem } from "../../../workspace-navigations";
+import type { NavProps } from ".";
import { NavLink } from "../nav-link";
import { AnimatedLoadingSpinner } from "./animated-loading-spinner";
import { getButtonStyles } from "./utils";
-export const FlatNavItem = ({
- item,
- onLoadMore,
-}: {
- item: NavItem & { loadMoreAction?: boolean };
- onLoadMore?: () => void;
-}) => {
+export const FlatNavItem = ({ item, onLoadMore }: NavProps) => {
const [isPending, startTransition] = useTransition();
const showLoader = useDelayLoader(isPending);
const router = useRouter();
@@ -23,7 +17,7 @@ export const FlatNavItem = ({
const handleClick = () => {
if (isLoadMoreButton && onLoadMore) {
- onLoadMore();
+ onLoadMore(item);
return;
}
diff --git a/apps/dashboard/components/navigation/sidebar/app-sidebar/components/nav-items/index.tsx b/apps/dashboard/components/navigation/sidebar/app-sidebar/components/nav-items/index.tsx
index 71bffed86f..a016385fe9 100644
--- a/apps/dashboard/components/navigation/sidebar/app-sidebar/components/nav-items/index.tsx
+++ b/apps/dashboard/components/navigation/sidebar/app-sidebar/components/nav-items/index.tsx
@@ -2,16 +2,14 @@ import type { NavItem } from "../../../workspace-navigations";
import { FlatNavItem } from "./flat-nav-item";
import { NestedNavItem } from "./nested-nav-item";
-export const NavItems = ({
- item,
- onLoadMore,
-}: {
+export type NavProps = {
item: NavItem & {
items?: (NavItem & { loadMoreAction?: boolean })[];
loadMoreAction?: boolean;
};
- onLoadMore?: () => void;
-}) => {
+ onLoadMore?: (item: NavItem) => void;
+};
+export const NavItems = ({ item, onLoadMore }: NavProps) => {
if (!item.items || item.items.length === 0) {
return ;
}
diff --git a/apps/dashboard/components/navigation/sidebar/app-sidebar/components/nav-items/nested-nav-item.tsx b/apps/dashboard/components/navigation/sidebar/app-sidebar/components/nav-items/nested-nav-item.tsx
index d9759795cd..5d38dddf10 100644
--- a/apps/dashboard/components/navigation/sidebar/app-sidebar/components/nav-items/nested-nav-item.tsx
+++ b/apps/dashboard/components/navigation/sidebar/app-sidebar/components/nav-items/nested-nav-item.tsx
@@ -12,6 +12,7 @@ import { CaretRight } from "@unkey/icons";
import { usePathname, useRouter } from "next/navigation";
import { useLayoutEffect, useState, useTransition } from "react";
import slugify from "slugify";
+import type { NavProps } from ".";
import type { NavItem } from "../../../workspace-navigations";
import { NavLink } from "../nav-link";
import { AnimatedLoadingSpinner } from "./animated-loading-spinner";
@@ -23,9 +24,7 @@ export const NestedNavItem = ({
depth = 0,
maxDepth = 1,
isSubItem = false,
-}: {
- item: NavItem;
- onLoadMore?: () => void;
+}: NavProps & {
depth?: number;
maxDepth?: number;
isSubItem?: boolean;
@@ -54,13 +53,15 @@ export const NestedNavItem = ({
setIsChildrenOpen(!!hasMatchingChild);
- const itemPath = `/${slugify(item.label, {
- lower: true,
- replacement: "-",
- })}`;
+ if (typeof item.label === "string") {
+ const itemPath = `/${slugify(item.label, {
+ lower: true,
+ replacement: "-",
+ })}`;
- if (pathname.startsWith(itemPath)) {
- setIsOpen(true);
+ if (pathname.startsWith(itemPath)) {
+ setIsOpen(true);
+ }
}
}, [pathname, item.items, item.label, hasChildren]);
@@ -97,7 +98,7 @@ export const NestedNavItem = ({
// If this subitem has children and is not at max depth, render it as another NestedNavItem
if (hasChildren && depth < maxDepth) {
return (
-
+
{
if (isLoadMoreButton && onLoadMore) {
- onLoadMore();
+ onLoadMore(subItem);
return;
}
@@ -140,7 +141,7 @@ export const NestedNavItem = ({
};
return (
-
+
{
if (hasNextPage) {
apisItem.items?.push({
icon: () => null,
- href: "#load-more",
- //@ts-expect-error will fix that later
+ href: "#load-more-apis",
label: More
,
active: false,
loadMoreAction: true,
diff --git a/apps/dashboard/components/navigation/sidebar/app-sidebar/hooks/use-projects-navigation.tsx b/apps/dashboard/components/navigation/sidebar/app-sidebar/hooks/use-projects-navigation.tsx
new file mode 100644
index 0000000000..d3c18044a0
--- /dev/null
+++ b/apps/dashboard/components/navigation/sidebar/app-sidebar/hooks/use-projects-navigation.tsx
@@ -0,0 +1,74 @@
+"use client";
+import type { NavItem } from "@/components/navigation/sidebar/workspace-navigations";
+import { trpc } from "@/lib/trpc/client";
+import { useSelectedLayoutSegments } from "next/navigation";
+import { useMemo } from "react";
+
+export const useProjectNavigation = (baseNavItems: NavItem[]) => {
+ const segments = useSelectedLayoutSegments() ?? [];
+ const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } =
+ trpc.deploy.project.list.useInfiniteQuery(
+ { query: [] },
+ {
+ getNextPageParam: (lastPage) => lastPage.nextCursor,
+ },
+ );
+
+ const projectNavItems = useMemo(() => {
+ if (!data?.pages) {
+ return [];
+ }
+ return data.pages.flatMap((page) =>
+ page.projects.map((project) => {
+ const currentProjectActive = segments.at(0) === "projects" && segments.at(1) === project.id;
+
+ const projectNavItem: NavItem = {
+ href: `/projects/${project.id}`,
+ icon: null,
+ label: project.name,
+ active: currentProjectActive,
+ showSubItems: true,
+ };
+
+ return projectNavItem;
+ }),
+ );
+ }, [data?.pages, segments]);
+
+ const enhancedNavItems = useMemo(() => {
+ const items = [...baseNavItems];
+ const projectsItemIndex = items.findIndex((item) => item.href === "/projects");
+
+ if (projectsItemIndex !== -1) {
+ const projectsItem = { ...items[projectsItemIndex] };
+ projectsItem.showSubItems = true;
+ projectsItem.items = [...(projectsItem.items || []), ...projectNavItems];
+
+ if (hasNextPage) {
+ projectsItem.items?.push({
+ icon: () => null,
+ href: "#load-more-projects",
+ label: More
,
+ active: false,
+ loadMoreAction: true,
+ });
+ }
+
+ items[projectsItemIndex] = projectsItem;
+ }
+
+ return items;
+ }, [baseNavItems, projectNavItems, hasNextPage]);
+
+ const loadMore = () => {
+ if (!isFetchingNextPage && hasNextPage) {
+ fetchNextPage();
+ }
+ };
+
+ return {
+ enhancedNavItems,
+ isLoading,
+ loadMore,
+ };
+};
diff --git a/apps/dashboard/components/navigation/sidebar/app-sidebar/hooks/use-ratelimit-navigation.tsx b/apps/dashboard/components/navigation/sidebar/app-sidebar/hooks/use-ratelimit-navigation.tsx
index 99b5be86da..a97931b756 100644
--- a/apps/dashboard/components/navigation/sidebar/app-sidebar/hooks/use-ratelimit-navigation.tsx
+++ b/apps/dashboard/components/navigation/sidebar/app-sidebar/hooks/use-ratelimit-navigation.tsx
@@ -94,7 +94,7 @@ export const useRatelimitNavigation = (baseNavItems: NavItem[]) => {
ratelimitsItem.items?.push({
icon: () => null,
href: "#load-more-ratelimits",
- label: "More",
+ label: More
,
active: false,
loadMoreAction: true,
});
diff --git a/apps/dashboard/components/navigation/sidebar/app-sidebar/index.tsx b/apps/dashboard/components/navigation/sidebar/app-sidebar/index.tsx
index 9b392558e1..9d941bfd9e 100644
--- a/apps/dashboard/components/navigation/sidebar/app-sidebar/index.tsx
+++ b/apps/dashboard/components/navigation/sidebar/app-sidebar/index.tsx
@@ -24,6 +24,7 @@ import { UsageBanner } from "../usage-banner";
import { NavItems } from "./components/nav-items";
import { ToggleSidebarButton } from "./components/nav-items/toggle-sidebar-button";
import { useApiNavigation } from "./hooks/use-api-navigation";
+import { useProjectNavigation } from "./hooks/use-projects-navigation";
import { useRatelimitNavigation } from "./hooks/use-ratelimit-navigation";
export function AppSidebar({
@@ -55,19 +56,23 @@ export function AppSidebar({
const { enhancedNavItems: ratelimitAddedNavItems, loadMore: loadMoreRatelimits } =
useRatelimitNavigation(apiAddedNavItems);
+ const { enhancedNavItems: projectAddedNavItems, loadMore: loadMoreProjects } =
+ useProjectNavigation(ratelimitAddedNavItems);
+
const handleLoadMore = useCallback(
(item: NavItem & { loadMoreAction?: boolean }) => {
- // If this is the ratelimit "load more" item
- if (item.href === "#load-more-ratelimits") {
+ if (item.href === "#load-more-projects") {
+ loadMoreProjects();
+ } else if (item.href === "#load-more-ratelimits") {
loadMoreRatelimits();
- } else {
- // Default to API loading (existing behavior)
+ } else if (item.href === "#load-more-apis") {
loadMoreApis();
+ } else {
+ console.error(`Unknown item.href: ${item.href}`);
}
},
- [loadMoreApis, loadMoreRatelimits],
+ [loadMoreApis, loadMoreRatelimits, loadMoreProjects],
);
-
const toggleNavItem: NavItem = useMemo(
() => ({
label: "Toggle Sidebar",
@@ -111,12 +116,8 @@ export function AppSidebar({
{state === "collapsed" && (
)}
- {ratelimitAddedNavItems.map((item) => (
- handleLoadMore(item)}
- />
+ {projectAddedNavItems.map((item) => (
+
))}
diff --git a/apps/dashboard/components/navigation/sidebar/workspace-navigations.tsx b/apps/dashboard/components/navigation/sidebar/workspace-navigations.tsx
index f24bceb8e4..e622001878 100644
--- a/apps/dashboard/components/navigation/sidebar/workspace-navigations.tsx
+++ b/apps/dashboard/components/navigation/sidebar/workspace-navigations.tsx
@@ -19,7 +19,7 @@ export type NavItem = {
icon: React.ElementType | null;
href: string;
external?: boolean;
- label: string;
+ label: string | React.ReactNode;
active?: boolean;
tag?: React.ReactNode;
hidden?: boolean;
diff --git a/apps/dashboard/lib/trpc/routers/deploy/project/list.ts b/apps/dashboard/lib/trpc/routers/deploy/project/list.ts
index 5436b0f5cd..ab99f7479b 100644
--- a/apps/dashboard/lib/trpc/routers/deploy/project/list.ts
+++ b/apps/dashboard/lib/trpc/routers/deploy/project/list.ts
@@ -1,5 +1,19 @@
import { projectsQueryPayload as projectsInputSchema } from "@/app/(app)/projects/_components/list/projects-list.schema";
-import { and, count, db, desc, eq, exists, inArray, like, lt, or, schema } from "@/lib/db";
+import {
+ and,
+ count,
+ db,
+ desc,
+ eq,
+ exists,
+ inArray,
+ isNotNull,
+ isNull,
+ like,
+ lt,
+ or,
+ schema,
+} from "@/lib/db";
import { ratelimit, requireUser, requireWorkspace, t, withRatelimit } from "@/lib/trpc/trpc";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
@@ -45,9 +59,23 @@ export const queryProjects = t.procedure
// Add cursor condition for pagination
if (input.cursor && typeof input.cursor === "number") {
- baseConditions.push(lt(schema.projects.updatedAt, input.cursor));
- }
+ const cursorDate = input.cursor;
+ const sql = or(
+ // updatedAt exists and is less than cursor
+ and(isNotNull(schema.projects.updatedAt), lt(schema.projects.updatedAt, cursorDate)),
+ // updatedAt is null, use createdAt instead
+ and(isNull(schema.projects.updatedAt), lt(schema.projects.createdAt, cursorDate)),
+ );
+ if (!sql) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Invalid cursor: Failed to create pagination condition",
+ });
+ }
+
+ baseConditions.push(sql);
+ }
const filterConditions = [];
// Single query field that searches across name, branch, and hostnames
@@ -148,7 +176,7 @@ export const queryProjects = t.procedure
projectId: true,
hostname: true,
},
- orderBy: [desc(schema.routes.createdAt)],
+ orderBy: [desc(schema.projects.updatedAt), desc(schema.projects.createdAt)],
})
: [];
@@ -183,7 +211,10 @@ export const queryProjects = t.procedure
projects,
hasMore,
total: totalResult[0]?.count ?? 0,
- nextCursor: projects.length > 0 ? projects[projects.length - 1].updatedAt : null,
+ nextCursor:
+ hasMore && projects.length > 0
+ ? (projects[projects.length - 1].updatedAt ?? projects[projects.length - 1].createdAt)
+ : null,
};
return response;