feat: custom board templates (#98) (#209)

* feat: add type and sourceId columns to board schema

* chore: update _journal and add snapshot

* feat: scaffold templates page

* feat: add template pages

* feat: add template card page

* feat: add template view indicator

* fix: ensure checklists default to empty array in board view

* feat: create template from board

* feat: show custom templates in new board form

* feat: create card activity from snapshot creation

* chore: update readme and features

* chore: add translations
This commit is contained in:
Henry
2025-10-10 20:19:42 +01:00
committed by GitHub
parent 362381f516
commit a7a27e7054
41 changed files with 8296 additions and 1357 deletions

View File

@@ -1,23 +1,51 @@
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();
return (
<Dropdown
disabled={isLoading}
items={[
...(isTemplate
? []
: [
{
label: t`Make template`,
action: () => openModal("CREATE_TEMPLATE"),
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

@@ -0,0 +1,138 @@
import { useRouter } from "next/navigation";
import { zodResolver } from "@hookform/resolvers/zod";
import { t } from "@lingui/core/macro";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { HiXMark } from "react-icons/hi2";
import { z } from "zod";
import Button from "~/components/Button";
import Input from "~/components/Input";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
const schema = z.object({
name: z
.string()
.min(1, { message: t`Template name is required` })
.max(100, { message: t`Template name cannot exceed 100 characters` }),
workspacePublicId: z.string(),
sourceBoardPublicId: z.string(),
});
interface NewBoardInputWithTemplate {
name: string;
workspacePublicId: string;
sourceBoardPublicId: string;
}
export function NewTemplateForm({
sourceBoardPublicId,
workspacePublicId,
sourceBoardName,
}: {
sourceBoardPublicId: string;
workspacePublicId: string;
sourceBoardName: string;
}) {
const router = useRouter();
const { closeModal } = useModal();
const { showPopup } = usePopup();
const {
register,
handleSubmit,
formState: { errors },
} = useForm<NewBoardInputWithTemplate>({
resolver: zodResolver(schema),
defaultValues: {
name: sourceBoardName,
workspacePublicId,
sourceBoardPublicId,
},
});
const createBoard = api.board.create.useMutation({
onSuccess: (newTemplate) => {
if (!newTemplate) {
showPopup({
header: t`Unable to create template`,
message: t`Please try again later, or contact customer support.`,
icon: "error",
});
} else {
router.push(`/templates/${newTemplate.publicId}`);
showPopup({
header: t`Template created`,
message: t`Template created successfully`,
icon: "success",
});
}
closeModal();
},
onError: () => {
showPopup({
header: t`Unable to create template`,
message: t`Please try again later, or contact customer support.`,
icon: "error",
});
},
});
const onSubmit = (data: NewBoardInputWithTemplate) => {
createBoard.mutate({
name: data.name,
workspacePublicId: data.workspacePublicId,
sourceBoardPublicId: data.sourceBoardPublicId,
lists: [],
labels: [],
type: "template",
});
};
useEffect(() => {
const titleElement: HTMLElement | null =
document.querySelector<HTMLElement>("#name");
if (titleElement) titleElement.focus();
}, []);
return (
<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 template`}</h2>
<button
type="button"
className="hover:bg-li ght-300 rounded p-1 focus:outline-none dark:hover:bg-dark-300"
onClick={(e) => {
e.preventDefault();
closeModal();
}}
>
<HiXMark size={18} className="dark:text-dark-9000 text-light-900" />
</button>
</div>
<Input
id="name"
placeholder={t`Name`}
{...register("name", { required: true })}
errorMessage={errors.name?.message}
onKeyDown={async (e) => {
if (e.key === "Enter") {
e.preventDefault();
await handleSubmit(onSubmit)();
}
}}
/>
</div>
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
<div>
<Button type="submit" isLoading={createBoard.isPending}>
{t`Create template`}
</Button>
</div>
</div>
</form>
);
}

View File

@@ -7,7 +7,11 @@ import { keepPreviousData } from "@tanstack/react-query";
import { useEffect, useState } from "react";
import { DragDropContext, Draggable } from "react-beautiful-dnd";
import { useForm } from "react-hook-form";
import { HiOutlinePlusSmall, HiOutlineSquare3Stack3D } from "react-icons/hi2";
import {
HiOutlinePlusSmall,
HiOutlineRectangleStack,
HiOutlineSquare3Stack3D,
} from "react-icons/hi2";
import type { UpdateBoardInput } from "@kan/api/types";
@@ -32,14 +36,15 @@ import Filters from "./components/Filters";
import List from "./components/List";
import { NewCardForm } from "./components/NewCardForm";
import { NewListForm } from "./components/NewListForm";
import { NewTemplateForm } from "./components/NewTemplateForm";
import UpdateBoardSlugButton from "./components/UpdateBoardSlugButton";
import { UpdateBoardSlugForm } from "./components/UpdateBoardSlugForm";
import VisibilityButton from "./components/VisibilityButton";
type PublicListId = string;
export default function BoardPage() {
const params = useParams() as { boardId: string[] } | null;
export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
const params = useParams() as { boardId: string | string[] } | null;
const router = useRouter();
const utils = api.useUtils();
const { showPopup } = usePopup();
@@ -49,7 +54,11 @@ export default function BoardPage() {
useState<PublicListId>("");
const [isInitialLoading, setIsInitialLoading] = useState(true);
const boardId = params?.boardId.length ? params.boardId[0] : null;
const boardId = params?.boardId
? Array.isArray(params.boardId)
? params.boardId[0]
: params.boardId
: null;
const updateBoard = api.board.update.useMutation();
@@ -67,10 +76,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 +223,7 @@ export default function BoardPage() {
};
const onDragEnd = ({
source,
source: _source,
destination,
draggableId,
type,
@@ -241,7 +256,10 @@ export default function BoardPage() {
modalSize="sm"
isVisible={isOpen && modalContentType === "DELETE_BOARD"}
>
<DeleteBoardConfirmation boardPublicId={boardId ?? ""} />
<DeleteBoardConfirmation
isTemplate={!!isTemplate}
boardPublicId={boardId ?? ""}
/>
</Modal>
<Modal
@@ -259,6 +277,7 @@ export default function BoardPage() {
isVisible={isOpen && modalContentType === "NEW_CARD"}
>
<NewCardForm
isTemplate={!!isTemplate}
boardPublicId={boardId ?? ""}
listPublicId={selectedPublicListId}
queryParams={queryParams}
@@ -321,6 +340,17 @@ export default function BoardPage() {
queryParams={queryParams}
/>
</Modal>
<Modal
modalSize="sm"
isVisible={isOpen && modalContentType === "CREATE_TEMPLATE"}
>
<NewTemplateForm
workspacePublicId={workspace.publicId ?? ""}
sourceBoardPublicId={boardId ?? ""}
sourceBoardName={boardData?.name ?? ""}
/>
</Modal>
</>
);
};
@@ -328,7 +358,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 +384,46 @@ 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 && (
<div className="inline-flex cursor-default items-center justify-center whitespace-nowrap rounded-md border-[1px] border-light-300 bg-light-50 px-3 py-2 text-sm font-semibold text-light-950 shadow-sm dark:border-dark-300 dark:bg-dark-50 dark:text-dark-950">
<span className="mr-2">
<HiOutlineRectangleStack />
</span>
{t`Template`}
</div>
)}
{!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 +438,12 @@ export default function BoardPage() {
>
{t`New list`}
</Button>
<BoardDropdown isLoading={!boardData} />
<BoardDropdown
isTemplate={!!isTemplate}
isLoading={!boardData}
boardPublicId={boardId ?? ""}
workspacePublicId={workspace.publicId}
/>
</div>
</div>
@@ -475,7 +525,11 @@ export default function BoardPage() {
e.preventDefault();
}}
key={card.publicId}
href={`/cards/${card.publicId}`}
href={
isTemplate
? `/templates/${boardId}/cards/${card.publicId}`
: `/cards/${card.publicId}`
}
className={`mb-2 flex !cursor-pointer flex-col ${
card.publicId.startsWith(
"PLACEHOLDER",

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

@@ -1,3 +1,4 @@
import { useRouter } from "next/navigation";
import { zodResolver } from "@hookform/resolvers/zod";
import { t } from "@lingui/core/macro";
import { useEffect, useState } from "react";
@@ -10,9 +11,10 @@ import Button from "~/components/Button";
import Input from "~/components/Input";
import Toggle from "~/components/Toggle";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
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 +31,25 @@ interface NewBoardInputWithTemplate {
template: Template | null;
}
export function NewBoardForm() {
export function NewBoardForm({ isTemplate }: { isTemplate?: boolean }) {
const utils = api.useUtils();
const { closeModal } = useModal();
const router = useRouter();
const { showPopup } = usePopup();
const { workspace } = useWorkspace();
const [showTemplates, setShowTemplates] = useState(false);
const { data: templates } = api.board.all.useQuery(
{ workspacePublicId: workspace.publicId ?? "", type: "template" },
{ enabled: !!workspace.publicId },
);
const templates = getTemplates();
const formattedTemplates = templates?.map((template) => ({
id: template.publicId,
sourceBoardPublicId: template.publicId,
name: template.name,
lists: template.lists.map((list) => list.name),
labels: template.labels.map((label) => label.name),
}));
const {
register,
@@ -57,18 +71,39 @@ export function NewBoardForm() {
const refetchBoards = () => utils.board.all.refetch();
const createBoard = api.board.create.useMutation({
onSuccess: async () => {
onSuccess: async (board) => {
if (!board) {
showPopup({
header: t`Error`,
message: t`Failed to create board`,
icon: "error",
});
} else {
router.push(
`${isTemplate ? "/templates" : "/boards"}/${board.publicId}`,
);
}
closeModal();
await refetchBoards();
},
onError: () => {
showPopup({
header: t`Error`,
message: t`Failed to create board`,
icon: "error",
});
},
});
const onSubmit = (data: NewBoardInputWithTemplate) => {
createBoard.mutate({
name: data.name,
workspacePublicId: data.workspacePublicId,
sourceBoardPublicId: data.template?.sourceBoardPublicId ?? undefined,
lists: data.template?.lists ?? [],
labels: data.template?.labels ?? [],
type: isTemplate ? "template" : "regular",
});
};
@@ -82,7 +117,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"
@@ -111,21 +146,24 @@ export function NewBoardForm() {
currentBoard={currentTemplate}
setCurrentBoard={(t) => setValue("template", t)}
showTemplates={showTemplates}
customTemplates={formattedTemplates ?? []}
/>
<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

@@ -4,6 +4,7 @@ import { HiCheckCircle } from "react-icons/hi2";
export interface Template {
id: string;
sourceBoardPublicId?: string;
name: string;
lists: string[];
labels: string[];
@@ -88,16 +89,18 @@ export default function TemplateBoards({
currentBoard,
setCurrentBoard,
showTemplates,
customTemplates,
}: {
currentBoard: Template | null;
setCurrentBoard: (board: Template | null) => void;
showTemplates: boolean;
customTemplates: Template[] | null;
}) {
const [showFade, setShowFade] = useState(false);
const [showTopFade, setShowTopFade] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
const templates = getTemplates();
const templates = [...(customTemplates ?? []), ...getTemplates()];
const handleScroll = () => {
if (!scrollRef.current) return;

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

@@ -37,7 +37,7 @@ interface FormValues {
description: string;
}
export function CardRightPanel() {
export function CardRightPanel({ isTemplate }: { isTemplate?: boolean }) {
const router = useRouter();
const cardId = Array.isArray(router.query.cardId)
? router.query.cardId[0]
@@ -121,19 +121,21 @@ export function CardRightPanel() {
isLoading={!card}
/>
</div>
<div className="flex w-full flex-row">
<p className="my-2 mb-2 w-[100px] text-sm font-medium">{t`Members`}</p>
<MemberSelector
cardPublicId={cardId ?? ""}
members={formattedMembers}
isLoading={!card}
/>
</div>
{!isTemplate && (
<div className="flex w-full flex-row">
<p className="my-2 mb-2 w-[100px] text-sm font-medium">{t`Members`}</p>
<MemberSelector
cardPublicId={cardId ?? ""}
members={formattedMembers}
isLoading={!card}
/>
</div>
)}
</div>
);
}
export default function CardPage() {
export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
const router = useRouter();
const utils = api.useUtils();
const {
@@ -228,7 +230,7 @@ export default function CardPage() {
<>
<Link
className="whitespace-nowrap font-bold leading-[2.3rem] tracking-tight text-light-900 dark:text-dark-900 sm:text-[1.2rem]"
href={`/boards/${board?.publicId}`}
href={`${isTemplate ? "/templates" : "/boards"}/${board?.publicId}`}
>
{board?.name}
</Link>
@@ -296,9 +298,11 @@ export default function CardPage() {
isAdmin={workspace.role === "admin"}
/>
</div>
<div className="mt-6">
<NewCommentForm cardPublicId={cardId} />
</div>
{!isTemplate && (
<div className="mt-6">
<NewCommentForm cardPublicId={cardId} />
</div>
)}
</div>
</>
)}

View File

@@ -28,6 +28,7 @@ const FeatureItem = ({
description: string;
icon: Record<string, unknown>;
comingSoon?: boolean;
new?: boolean;
};
}) => {
const [isHovered, setIsHovered] = useState(false);
@@ -66,6 +67,12 @@ const FeatureItem = ({
{t`Coming soon`}
</div>
)}
{feature.new && (
<div className="absolute right-4 top-4 rounded-full border border-light-300 px-2 py-1 text-[10px] text-light-1000 dark:border-dark-600 dark:bg-dark-50 dark:text-dark-900">
{t`New`}
</div>
)}
</div>
);
};
@@ -108,7 +115,7 @@ const Features = ({ theme }: { theme: "light" | "dark" }) => {
title: t`Templates`,
description: t`Save time with reusable board templates.`,
icon: isDark ? templatesIconDark : templatesIconLight,
comingSoon: true,
new: true,
},
{
title: t`Integrations`,