feat: add template pages

This commit is contained in:
Henry
2025-10-08 21:29:24 +01:00
parent 734e0bbfc5
commit 18e85ef6be
12 changed files with 472 additions and 226 deletions

View File

@@ -0,0 +1,17 @@
import type { NextPageWithLayout } from "~/pages/_app";
import { getDashboardLayout } from "~/components/Dashboard";
import Popup from "~/components/Popup";
import BoardView from "~/views/board";
const TemplatePage: NextPageWithLayout = () => {
return (
<>
<BoardView isTemplate />
<Popup />
</>
);
};
TemplatePage.getLayout = (page) => getDashboardLayout(page);
export default TemplatePage;

View File

@@ -1,12 +1,12 @@
import type { NextPageWithLayout } from "~/pages/_app";
import { getDashboardLayout } from "~/components/Dashboard";
import Popup from "~/components/Popup";
import TemplatesView from "~/views/templates";
import BoardsView from "~/views/boards";
const TemplatesPage: NextPageWithLayout = () => {
return (
<>
<TemplatesView />
<BoardsView isTemplate />
<Popup />
</>
);

View File

@@ -1,23 +1,76 @@
import { t } from "@lingui/core/macro";
import { HiEllipsisHorizontal, HiLink, HiOutlineTrash } from "react-icons/hi2";
import {
HiEllipsisHorizontal,
HiLink,
HiOutlineDocumentDuplicate,
HiOutlineTrash,
} from "react-icons/hi2";
import Dropdown from "~/components/Dropdown";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
export default function BoardDropdown({ isLoading }: { isLoading: boolean }) {
export default function BoardDropdown({
isTemplate,
isLoading,
boardPublicId,
workspacePublicId,
}: {
isTemplate: boolean;
isLoading: boolean;
boardPublicId: string;
workspacePublicId: string;
}) {
const { openModal } = useModal();
const { showPopup } = usePopup();
const utils = api.useUtils();
// const makeTemplate = api.template.create.useMutation({
// onSuccess: async () => {
// showPopup({
// header: t`Success`,
// message: t`Template created`,
// icon: "success",
// });
// await utils.template.getAll.invalidate();
// },
// onError: () =>
// showPopup({
// header: t`Error`,
// message: t`Failed to create template`,
// icon: "error",
// }),
// });
return (
<Dropdown
disabled={isLoading}
items={[
...(isTemplate
? []
: [
{
label: t`Make template`,
action: () => {
makeTemplate.mutate({
boardPublicId,
workspacePublicId,
});
},
icon: (
<HiOutlineDocumentDuplicate className="h-[16px] w-[16px] text-dark-900" />
),
},
{
label: t`Edit board URL`,
action: () => openModal("UPDATE_BOARD_SLUG"),
icon: <HiLink className="h-[16px] w-[16px] text-dark-900" />,
},
]),
{
label: t`Edit board URL`,
action: () => openModal("UPDATE_BOARD_SLUG"),
icon: <HiLink className="h-[16px] w-[16px] text-dark-900" />,
},
{
label: t`Delete board`,
label: isTemplate ? t`Delete template` : t`Delete board`,
action: () => openModal("DELETE_BOARD"),
icon: <HiOutlineTrash className="h-[16px] w-[16px] text-dark-900" />,
},

View File

@@ -1,4 +1,5 @@
import { useRouter } from "next/navigation";
import { t } from "@lingui/core/macro";
import Button from "~/components/Button";
import { useModal } from "~/providers/modal";
@@ -6,8 +7,10 @@ import { api } from "~/utils/api";
export function DeleteBoardConfirmation({
boardPublicId,
isTemplate,
}: {
boardPublicId: string;
isTemplate: boolean;
}) {
const router = useRouter();
const { closeModal } = useModal();
@@ -15,7 +18,7 @@ export function DeleteBoardConfirmation({
const deleteBoard = api.board.delete.useMutation({
onSuccess: () => {
closeModal();
router.push(`/boards`);
router.push(isTemplate ? `/templates` : `/boards`);
},
});
@@ -30,18 +33,18 @@ export function DeleteBoardConfirmation({
<div className="p-5">
<div className="flex w-full flex-col justify-between pb-4">
<h2 className="text-md pb-4 font-medium text-neutral-900 dark:text-dark-1000">
Are you sure you want to delete this board?
{t`Are you sure you want to delete this ${isTemplate ? "template" : "board"}?`}
</h2>
<p className="text-sm font-medium text-light-900 dark:text-dark-900">
{"This action can't be undone."}
{t`This action can't be undone.`}
</p>
</div>
<div className="mt-5 flex justify-end space-x-2 sm:mt-6">
<Button onClick={() => closeModal()} variant="secondary">
Cancel
{t`Cancel`}
</Button>
<Button onClick={handleDeleteBoard} isLoading={deleteBoard.isPending}>
Delete
{t`Delete`}
</Button>
</div>
</div>

View File

@@ -11,10 +11,11 @@ import {
import type { NewCardInput } from "@kan/api/types";
import { generateUID } from "@kan/shared/utils";
import type { WorkspaceMember } from "~/components/Editor";
import Avatar from "~/components/Avatar";
import Button from "~/components/Button";
import CheckboxDropdown from "~/components/CheckboxDropdown";
import Editor, { WorkspaceMember } from "~/components/Editor";
import Editor from "~/components/Editor";
import Input from "~/components/Input";
import LabelIcon from "~/components/LabelIcon";
import Toggle from "~/components/Toggle";
@@ -35,12 +36,14 @@ interface QueryParams {
}
interface NewCardFormProps {
isTemplate: boolean;
boardPublicId: string;
listPublicId: string;
queryParams: QueryParams;
}
export function NewCardForm({
isTemplate,
boardPublicId,
listPublicId,
queryParams,
@@ -85,14 +88,13 @@ export function NewCardForm({
return () => subscription.unsubscribe();
}, [watch, saveFormState]);
const { data: boardData } = api.board.byId.useQuery(queryParams, {
enabled: !!boardPublicId,
});
// this adds the new created label to selected labels
useEffect(() => {
const newLabelId = modalStates["NEW_LABEL_CREATED"];
const newLabelId = modalStates.NEW_LABEL_CREATED;
if (newLabelId !== undefined && !labelPublicIds.includes(newLabelId)) {
setValue("labelPublicIds", [...labelPublicIds, newLabelId]);
}
@@ -101,23 +103,23 @@ export function NewCardForm({
// this removes the deleted label from selected labels if it is selected
useEffect(() => {
if (boardData?.labels) {
const availableLabelIds = boardData.labels.map(label => label.publicId);
const newLabelId = modalStates["NEW_LABEL_CREATED"];
const availableLabelIds = boardData.labels.map((label) => label.publicId);
const newLabelId = modalStates.NEW_LABEL_CREATED;
if (newLabelId && availableLabelIds.includes(newLabelId)) {
clearModalState("NEW_LABEL_CREATED");
}
const validLabelIds = labelPublicIds.filter(id =>
availableLabelIds.includes(id) || id === newLabelId
const validLabelIds = labelPublicIds.filter(
(id) => availableLabelIds.includes(id) || id === newLabelId,
);
if (validLabelIds.length !== labelPublicIds.length) {
setValue("labelPublicIds", validLabelIds);
}
}
}, [boardData?.labels, labelPublicIds, modalStates["NEW_LABEL_CREATED"]]);
}, [boardData?.labels, labelPublicIds, modalStates.NEW_LABEL_CREATED]);
const createCard = api.card.create.useMutation({
onMutate: async (args) => {
await utils.board.byId.cancel();
@@ -323,15 +325,19 @@ export function NewCardForm({
saveFormState({ ...formState, description: value });
}}
workspaceMembers={
boardData?.workspace.members?.map((member): WorkspaceMember => ({
publicId: member.publicId,
email: member.email,
user: member.user ? {
id: member.publicId,
name: member.user.name,
image: member.user.image ?? null,
} : null,
})) ?? []
boardData?.workspace.members?.map(
(member): WorkspaceMember => ({
publicId: member.publicId,
email: member.email,
user: member.user
? {
id: member.publicId,
name: member.user.name,
image: member.user.image ?? null,
}
: null,
}),
) ?? []
}
/>
</div>
@@ -347,42 +353,46 @@ export function NewCardForm({
</div>
</CheckboxDropdown>
</div>
<div className="w-fit">
<CheckboxDropdown
items={formattedMembers}
handleSelect={(_groupKey, item) => handleSelectMembers(item.key)}
>
<div className="flex h-full w-full items-center rounded-[5px] border-[1px] border-light-600 bg-light-200 px-2 py-1 text-left text-xs text-light-800 hover:bg-light-300 dark:border-dark-600 dark:bg-dark-400 dark:text-dark-1000 dark:hover:bg-dark-500">
{!memberPublicIds.length ? (
t`Members`
) : (
<div className="flex -space-x-1 overflow-hidden">
{memberPublicIds.map((memberPublicId) => {
const member = formattedMembers.find(
(member) => member.key === memberPublicId,
);
{!isTemplate && (
<div className="w-fit">
<CheckboxDropdown
items={formattedMembers}
handleSelect={(_groupKey, item) =>
handleSelectMembers(item.key)
}
>
<div className="flex h-full w-full items-center rounded-[5px] border-[1px] border-light-600 bg-light-200 px-2 py-1 text-left text-xs text-light-800 hover:bg-light-300 dark:border-dark-600 dark:bg-dark-400 dark:text-dark-1000 dark:hover:bg-dark-500">
{!memberPublicIds.length ? (
t`Members`
) : (
<div className="flex -space-x-1 overflow-hidden">
{memberPublicIds.map((memberPublicId) => {
const member = formattedMembers.find(
(member) => member.key === memberPublicId,
);
return (
<span
key={member?.key}
className="inline-flex h-4 w-4 items-center justify-center rounded-full bg-gray-400 ring-1 ring-light-200 dark:ring-dark-500"
>
<span className="text-[8px] font-medium leading-none text-white">
{member?.value
.split(" ")
.map((namePart) =>
namePart.charAt(0).toUpperCase(),
)
.join("")}
return (
<span
key={member?.key}
className="inline-flex h-4 w-4 items-center justify-center rounded-full bg-gray-400 ring-1 ring-light-200 dark:ring-dark-500"
>
<span className="text-[8px] font-medium leading-none text-white">
{member?.value
.split(" ")
.map((namePart) =>
namePart.charAt(0).toUpperCase(),
)
.join("")}
</span>
</span>
</span>
);
})}
</div>
)}
</div>
</CheckboxDropdown>
</div>
);
})}
</div>
)}
</div>
</CheckboxDropdown>
</div>
)}
<div className="w-fit">
<CheckboxDropdown
items={formattedLabels}

View File

@@ -38,7 +38,7 @@ import VisibilityButton from "./components/VisibilityButton";
type PublicListId = string;
export default function BoardPage() {
export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
const params = useParams() as { boardId: string[] } | null;
const router = useRouter();
const utils = api.useUtils();
@@ -67,10 +67,16 @@ export default function BoardPage() {
});
};
const queryParams = {
const queryParams: {
boardPublicId: string;
members: string[];
labels: string[];
type: "regular" | "template";
} = {
boardPublicId: boardId ?? "",
members: formatToArray(router.query.members),
labels: formatToArray(router.query.labels),
type: isTemplate ? "template" : "regular",
};
const {
@@ -208,7 +214,7 @@ export default function BoardPage() {
};
const onDragEnd = ({
source,
source: _source,
destination,
draggableId,
type,
@@ -241,7 +247,10 @@ export default function BoardPage() {
modalSize="sm"
isVisible={isOpen && modalContentType === "DELETE_BOARD"}
>
<DeleteBoardConfirmation boardPublicId={boardId ?? ""} />
<DeleteBoardConfirmation
isTemplate={!!isTemplate}
boardPublicId={boardId ?? ""}
/>
</Modal>
<Modal
@@ -259,6 +268,7 @@ export default function BoardPage() {
isVisible={isOpen && modalContentType === "NEW_CARD"}
>
<NewCardForm
isTemplate={!!isTemplate}
boardPublicId={boardId ?? ""}
listPublicId={selectedPublicListId}
queryParams={queryParams}
@@ -328,7 +338,7 @@ export default function BoardPage() {
return (
<>
<PageHead
title={`${boardData?.name ?? t`Board`} | ${workspace.name ?? t`Workspace`}`}
title={`${(boardData?.name ?? isTemplate) ? t`Board` : t`Template`} | ${workspace.name}`}
/>
<div className="relative flex h-full flex-col">
<PatternedBackground />
@@ -354,31 +364,38 @@ export default function BoardPage() {
)}
{!boardData && !isLoading && (
<p className="order-2 block p-0 py-0 font-bold leading-[2.3rem] tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem] md:order-1">
{t`Board not found`}
{t`${isTemplate ? "Template" : "Board"} not found`}
</p>
)}
<div className="order-1 mb-4 flex items-center justify-end space-x-2 md:order-2 md:mb-0">
<UpdateBoardSlugButton
handleOnClick={() => openModal("UPDATE_BOARD_SLUG")}
isLoading={isLoading}
workspaceSlug={workspace.slug ?? ""}
boardSlug={boardData?.slug ?? ""}
/>
<VisibilityButton
visibility={boardData?.visibility ?? "private"}
boardPublicId={boardId ?? ""}
boardSlug={boardData?.slug ?? ""}
queryParams={queryParams}
isLoading={!boardData}
isAdmin={workspace.role === "admin"}
/>
<Filters
labels={boardData?.labels ?? []}
members={boardData?.workspace.members?.filter(member => member.user !== null) ?? []}
position="left"
isLoading={!boardData}
/>
{!isTemplate && (
<>
<UpdateBoardSlugButton
handleOnClick={() => openModal("UPDATE_BOARD_SLUG")}
isLoading={isLoading}
workspaceSlug={workspace.slug ?? ""}
boardSlug={boardData?.slug ?? ""}
/>
<VisibilityButton
visibility={boardData?.visibility ?? "private"}
boardPublicId={boardId ?? ""}
boardSlug={boardData?.slug ?? ""}
queryParams={queryParams}
isLoading={!boardData}
isAdmin={workspace.role === "admin"}
/>
{boardData && (
<Filters
labels={boardData.labels}
members={boardData.workspace.members.filter(
(member) => member.user !== null,
)}
position="left"
isLoading={!boardData}
/>
)}
</>
)}
<Button
iconLeft={
<HiOutlinePlusSmall
@@ -393,7 +410,12 @@ export default function BoardPage() {
>
{t`New list`}
</Button>
<BoardDropdown isLoading={!boardData} />
<BoardDropdown
isTemplate={!!isTemplate}
isLoading={!boardData}
boardPublicId={boardId ?? ""}
workspacePublicId={workspace.publicId}
/>
</div>
</div>
@@ -491,7 +513,7 @@ export default function BoardPage() {
title={card.title}
labels={card.labels}
members={card.members}
checklists={card.checklists ?? []}
checklists={card.checklists}
/>
</Link>
)}

View File

@@ -8,12 +8,15 @@ import { useModal } from "~/providers/modal";
import { useWorkspace } from "~/providers/workspace";
import { api } from "~/utils/api";
export function BoardsList() {
export function BoardsList({ isTemplate }: { isTemplate?: boolean }) {
const { workspace } = useWorkspace();
const { openModal } = useModal();
const { data, isLoading } = api.board.all.useQuery(
{ workspacePublicId: workspace.publicId },
{
workspacePublicId: workspace.publicId,
type: isTemplate ? "template" : "regular",
},
{ enabled: workspace.publicId ? true : false },
);
@@ -32,14 +35,14 @@ export function BoardsList() {
<div className="flex flex-col items-center">
<HiOutlineRectangleStack className="h-10 w-10 text-light-800 dark:text-dark-800" />
<p className="mb-2 mt-4 text-[14px] font-bold text-light-1000 dark:text-dark-950">
{t`No boards`}
{t`No ${isTemplate ? "templates" : "boards"}`}
</p>
<p className="text-[14px] text-light-900 dark:text-dark-900">
{t`Get started by creating a new board`}
{t`Get started by creating a new ${isTemplate ? "template" : "board"}`}
</p>
</div>
<Button onClick={() => openModal("NEW_BOARD")}>
{t`Create new board`}
{t`Create new ${isTemplate ? "template" : "board"}`}
</Button>
</div>
);
@@ -47,7 +50,10 @@ export function BoardsList() {
return (
<div className="3xl:grid-cols-4 grid h-fit w-full grid-cols-1 gap-4 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-3">
{data?.map((board) => (
<Link key={board.publicId} href={`boards/${board.publicId}`}>
<Link
key={board.publicId}
href={`${isTemplate ? "templates" : "boards"}/${board.publicId}`}
>
<div className="align-center relative mr-5 flex h-[150px] w-full items-center justify-center rounded-md border border-dashed border-light-400 bg-light-50 shadow-sm hover:bg-light-200 dark:border-dark-600 dark:bg-dark-50 dark:hover:bg-dark-100">
<PatternedBackground />
<p className="px-4 text-[14px] font-bold text-neutral-700 dark:text-dark-1000">

View File

@@ -12,7 +12,7 @@ import Toggle from "~/components/Toggle";
import { useModal } from "~/providers/modal";
import { useWorkspace } from "~/providers/workspace";
import { api } from "~/utils/api";
import TemplateBoards, { getTemplates } from "./TemplateBoards";
import TemplateBoards from "./TemplateBoards";
const schema = z.object({
name: z
@@ -29,13 +29,15 @@ interface NewBoardInputWithTemplate {
template: Template | null;
}
export function NewBoardForm() {
export function NewBoardForm({ isTemplate }: { isTemplate?: boolean }) {
const utils = api.useUtils();
const { closeModal } = useModal();
const { workspace } = useWorkspace();
const [showTemplates, setShowTemplates] = useState(false);
const templates = getTemplates();
const { data: templates } = api.board.all.useQuery(
{ workspacePublicId: workspace.publicId ?? "", type: "template" },
{ enabled: !!workspace.publicId },
);
const {
register,
@@ -69,6 +71,7 @@ export function NewBoardForm() {
workspacePublicId: data.workspacePublicId,
lists: data.template?.lists ?? [],
labels: data.template?.labels ?? [],
type: isTemplate ? "template" : "regular",
});
};
@@ -82,7 +85,7 @@ export function NewBoardForm() {
<form onSubmit={handleSubmit(onSubmit)}>
<div className="px-5 pt-5">
<div className="text-neutral-9000 flex w-full items-center justify-between pb-4 dark:text-dark-1000">
<h2 className="text-sm font-bold">{t`New board`}</h2>
<h2 className="text-sm font-bold">{t`New ${isTemplate ? "template" : "board"}`}</h2>
<button
type="button"
className="hover:bg-li ght-300 rounded p-1 focus:outline-none dark:hover:bg-dark-300"
@@ -113,19 +116,21 @@ export function NewBoardForm() {
showTemplates={showTemplates}
/>
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
<Toggle
label={t`Use template`}
isChecked={showTemplates}
onChange={() => {
setShowTemplates(!showTemplates);
if (!showTemplates && !currentTemplate) {
setValue("template", templates[0] ?? null);
}
}}
/>
{!isTemplate && (
<Toggle
label={t`Use template`}
isChecked={showTemplates}
onChange={() => {
setShowTemplates(!showTemplates);
if (!showTemplates && !currentTemplate) {
setValue("template", (templates?.[0] as any) ?? null);
}
}}
/>
)}
<div>
<Button type="submit" isLoading={createBoard.isPending}>
{t`Create board`}
{t`Create ${isTemplate ? "template" : "board"}`}
</Button>
</div>
</div>

View File

@@ -13,7 +13,7 @@ import { BoardsList } from "./components/BoardsList";
import { ImportBoardsForm } from "./components/ImportBoardsForm";
import { NewBoardForm } from "./components/NewBoardForm";
export default function BoardsPage() {
export default function BoardsPage({ isTemplate }: { isTemplate?: boolean }) {
const { openModal, modalContentType, isOpen } = useModal();
const { availableWorkspaces, workspace, hasLoaded } = useWorkspace();
@@ -25,23 +25,27 @@ export default function BoardsPage() {
return (
<>
<PageHead title={t`Boards | ${workspace.name ?? "Workspace"}`} />
<PageHead
title={t`${isTemplate ? "Templates" : "Boards"} | ${workspace.name ?? "Workspace"}`}
/>
<div className="m-auto h-full max-w-[1100px] p-6 px-5 md:px-28 md:py-12">
<div className="relative z-10 mb-8 flex w-full items-center justify-between">
<h1 className="font-bold tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem]">
{t`Boards`}
{t`${isTemplate ? "Templates" : "Boards"}`}
</h1>
<div className="flex gap-2">
<Button
type="button"
variant="secondary"
onClick={() => openModal("IMPORT_BOARDS")}
iconLeft={
<HiArrowDownTray aria-hidden="true" className="h-4 w-4" />
}
>
{t`Import`}
</Button>
{!isTemplate && (
<Button
type="button"
variant="secondary"
onClick={() => openModal("IMPORT_BOARDS")}
iconLeft={
<HiArrowDownTray aria-hidden="true" className="h-4 w-4" />
}
>
{t`Import`}
</Button>
)}
<Button
type="button"
variant="primary"
@@ -67,7 +71,7 @@ export default function BoardsPage() {
modalSize="sm"
isVisible={isOpen && modalContentType === "NEW_BOARD"}
>
<NewBoardForm />
<NewBoardForm isTemplate={!!isTemplate} />
</Modal>
<Modal
@@ -86,7 +90,7 @@ export default function BoardsPage() {
</>
<div className="flex h-full flex-row">
<BoardsList />
<BoardsList isTemplate={!!isTemplate} />
</div>
</div>
</>

View File

@@ -1,40 +0,0 @@
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 (
<div className="m-auto h-full max-w-[1100px] p-6 px-5 md:px-28 md:py-12">
<PageHead title={t`Templates | ${workspace.name ?? "Workspace"}`} />
<div className="relative z-10 mb-8 flex w-full items-center justify-between">
<h1 className="font-bold tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem]">
{t`Templates`}
</h1>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-3">
{isLoading && <div>{t`Loading templates...`}</div>}
{!isLoading && (templates?.length ?? 0) === 0 && (
<div className="text-sm text-light-900 dark:text-dark-900">{t`No templates yet`}</div>
)}
{templates?.map((tpl) => (
<Link
key={tpl.publicId}
href={`/boards/${workspace.slug}/${tpl.publicId}`}
className="rounded border border-light-300 p-3 text-sm hover:bg-light-100 dark:border-dark-300 dark:hover:bg-dark-100"
>
<div className="font-medium">{tpl.name}</div>
</Link>
))}
</div>
</div>
);
}