From 734e0bbfc5b8ed859d5412a41acd5701f93c7a9f Mon Sep 17 00:00:00 2001 From: Henry Date: Tue, 7 Oct 2025 22:01:34 +0100 Subject: [PATCH] feat: scaffold templates page --- apps/web/src/components/SideNavigation.tsx | 9 +++- apps/web/src/pages/templates/index.tsx | 17 ++++++++ apps/web/src/views/templates/index.tsx | 40 ++++++++++++++++++ packages/api/src/routers/board.ts | 49 +++++++++++++++++++++- 4 files changed, 112 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/pages/templates/index.tsx create mode 100644 apps/web/src/views/templates/index.tsx diff --git a/apps/web/src/components/SideNavigation.tsx b/apps/web/src/components/SideNavigation.tsx index fe18ae7a..016013a6 100644 --- a/apps/web/src/components/SideNavigation.tsx +++ b/apps/web/src/components/SideNavigation.tsx @@ -2,6 +2,7 @@ import Link from "next/link"; import { useRouter } from "next/router"; import { Button } from "@headlessui/react"; import { t } from "@lingui/core/macro"; +import { useTheme } from "next-themes"; import { useEffect, useState } from "react"; import { TbLayoutSidebarLeftCollapse, @@ -15,10 +16,11 @@ import membersIconDark from "~/assets/members-dark.json"; import membersIconLight from "~/assets/members-light.json"; import settingsIconDark from "~/assets/settings-dark.json"; import settingsIconLight from "~/assets/settings-light.json"; +import templatesIconDark from "~/assets/templates-dark.json"; +import templatesIconLight from "~/assets/templates-light.json"; import ReactiveButton from "~/components/ReactiveButton"; import UserMenu from "~/components/UserMenu"; import WorkspaceMenu from "~/components/WorkspaceMenu"; -import { useTheme } from "next-themes"; interface SideNavigationProps { user: UserType; @@ -69,6 +71,11 @@ export default function SideNavigation({ href: "/boards", icon: isDarkMode ? boardsIconDark : boardsIconLight, }, + { + name: t`Templates`, + href: "/templates", + icon: isDarkMode ? templatesIconDark : templatesIconLight, + }, { name: t`Members`, href: "/members", diff --git a/apps/web/src/pages/templates/index.tsx b/apps/web/src/pages/templates/index.tsx new file mode 100644 index 00000000..66218e5c --- /dev/null +++ b/apps/web/src/pages/templates/index.tsx @@ -0,0 +1,17 @@ +import type { NextPageWithLayout } from "~/pages/_app"; +import { getDashboardLayout } from "~/components/Dashboard"; +import Popup from "~/components/Popup"; +import TemplatesView from "~/views/templates"; + +const TemplatesPage: NextPageWithLayout = () => { + return ( + <> + + + + ); +}; + +TemplatesPage.getLayout = (page) => getDashboardLayout(page); + +export default TemplatesPage; diff --git a/apps/web/src/views/templates/index.tsx b/apps/web/src/views/templates/index.tsx new file mode 100644 index 00000000..fb681e3c --- /dev/null +++ b/apps/web/src/views/templates/index.tsx @@ -0,0 +1,40 @@ +import Link from "next/link"; +import { t } from "@lingui/core/macro"; + +import { PageHead } from "~/components/PageHead"; +import { useWorkspace } from "~/providers/workspace"; +import { api } from "~/utils/api"; + +export default function TemplatesView() { + const { workspace } = useWorkspace(); + const { data: templates, isLoading } = api.board.templates.useQuery( + { workspacePublicId: workspace.publicId ?? "" }, + { enabled: !!workspace.publicId }, + ); + + return ( +
+ +
+

+ {t`Templates`} +

+
+
+ {isLoading &&
{t`Loading templates...`}
} + {!isLoading && (templates?.length ?? 0) === 0 && ( +
{t`No templates yet`}
+ )} + {templates?.map((tpl) => ( + +
{tpl.name}
+ + ))} +
+
+ ); +} diff --git a/packages/api/src/routers/board.ts b/packages/api/src/routers/board.ts index 61679c89..f30c8a60 100644 --- a/packages/api/src/routers/board.ts +++ b/packages/api/src/routers/board.ts @@ -14,6 +14,51 @@ import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc"; import { assertUserInWorkspace } from "../utils/auth"; export const boardRouter = createTRPCRouter({ + templates: protectedProcedure + .meta({ + openapi: { + method: "GET", + path: "/workspaces/{workspacePublicId}/templates", + summary: "Get templates", + description: "Retrieves all templates for a given workspace", + tags: ["Boards"], + protect: true, + }, + }) + .input(z.object({ workspacePublicId: z.string().min(12) })) + .output( + z.custom< + Awaited> + >(), + ) + .query(async ({ ctx, input }) => { + const userId = ctx.user?.id; + + if (!userId) + throw new TRPCError({ + message: `User not authenticated`, + code: "UNAUTHORIZED", + }); + + const workspace = await workspaceRepo.getByPublicId( + ctx.db, + input.workspacePublicId, + ); + + if (!workspace) + throw new TRPCError({ + message: `Workspace with public ID ${input.workspacePublicId} not found`, + code: "NOT_FOUND", + }); + + await assertUserInWorkspace(ctx.db, userId, workspace.id); + + const result = boardRepo.getAllByWorkspaceId(ctx.db, workspace.id, { + type: "template", + }); + + return result; + }), all: protectedProcedure .meta({ openapi: { @@ -225,7 +270,7 @@ export const boardRouter = createTRPCRouter({ code: "INTERNAL_SERVER_ERROR", }); - if (input.lists.length) { + if (input.lists?.length) { const listInputs = input.lists.map((list, index) => ({ publicId: generateUID(), name: list, @@ -237,7 +282,7 @@ export const boardRouter = createTRPCRouter({ await listRepo.bulkCreate(ctx.db, listInputs); } - if (input.labels.length) { + if (input.labels?.length) { const labelInputs = input.labels.map((label, index) => ({ publicId: generateUID(), name: label,