* fix: add 404 not found page Closes #320 - Add custom 404 page matching the app's design system - Include PatternedBackground and proper dark mode support - Provide navigation links to homepage and boards - Add i18n support with @lingui/react * fix: add 404 redirects for non-existent boards and cards Add useEffect hooks to redirect to /404 page when: - Board does not exist in board view - Card does not exist in card view This addresses the cases mentioned in maintainer feedback where the static 404 page wasn't being used for dynamic routes. Addresses review feedback on PR #349 * feat: redirect to 404 for non-existent public workspaces and cards Per maintainer feedback: - Add redirect logic to public boards view when workspace doesn't exist - Add redirect logic to public card modal when card doesn't exist - Uses error?.data?.code === 'NOT_FOUND' check as suggested - Closes modal before redirecting for card modal
570 lines
20 KiB
TypeScript
570 lines
20 KiB
TypeScript
import Link from "next/link";
|
|
import { useRouter } from "next/router";
|
|
import { t } from "@lingui/core/macro";
|
|
import { useEffect, useState } from "react";
|
|
import { useForm } from "react-hook-form";
|
|
import { HiXMark } from "react-icons/hi2";
|
|
import { IoChevronForwardSharp } from "react-icons/io5";
|
|
|
|
import { authClient } from "@kan/auth/client";
|
|
|
|
import Avatar from "~/components/Avatar";
|
|
import Editor from "~/components/Editor";
|
|
import FeedbackModal from "~/components/FeedbackModal";
|
|
import { LabelForm } from "~/components/LabelForm";
|
|
import LabelIcon from "~/components/LabelIcon";
|
|
import Modal from "~/components/modal";
|
|
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
|
|
import { PageHead } from "~/components/PageHead";
|
|
import { EditYouTubeModal } from "~/components/YouTubeEmbed/EditYouTubeModal";
|
|
import { usePermissions } from "~/hooks/usePermissions";
|
|
import { useModal } from "~/providers/modal";
|
|
import { usePopup } from "~/providers/popup";
|
|
import { useWorkspace } from "~/providers/workspace";
|
|
import { api } from "~/utils/api";
|
|
import { invalidateCard } from "~/utils/cardInvalidation";
|
|
import { formatMemberDisplayName, getAvatarUrl } from "~/utils/helpers";
|
|
import { DeleteLabelConfirmation } from "../../components/DeleteLabelConfirmation";
|
|
import ActivityList from "./components/ActivityList";
|
|
import { AttachmentThumbnails } from "./components/AttachmentThumbnails";
|
|
import { AttachmentUpload } from "./components/AttachmentUpload";
|
|
import Checklists from "./components/Checklists";
|
|
import { DeleteCardConfirmation } from "./components/DeleteCardConfirmation";
|
|
import { DeleteChecklistConfirmation } from "./components/DeleteChecklistConfirmation";
|
|
import { DeleteCommentConfirmation } from "./components/DeleteCommentConfirmation";
|
|
import Dropdown from "./components/Dropdown";
|
|
import { DueDateSelector } from "./components/DueDateSelector";
|
|
import LabelSelector from "./components/LabelSelector";
|
|
import ListSelector from "./components/ListSelector";
|
|
import MemberSelector from "./components/MemberSelector";
|
|
import { NewChecklistForm } from "./components/NewChecklistForm";
|
|
import NewCommentForm from "./components/NewCommentForm";
|
|
|
|
interface FormValues {
|
|
cardId: string;
|
|
title: string;
|
|
description: string;
|
|
}
|
|
|
|
export function CardRightPanel({ isTemplate }: { isTemplate?: boolean }) {
|
|
const router = useRouter();
|
|
const { canEditCard } = usePermissions();
|
|
const { data: session } = authClient.useSession();
|
|
const cardId = Array.isArray(router.query.cardId)
|
|
? router.query.cardId[0]
|
|
: router.query.cardId;
|
|
|
|
const { data: card } = api.card.byId.useQuery(
|
|
{ cardPublicId: cardId ?? "" },
|
|
{ enabled: !!cardId && cardId.length >= 12 },
|
|
);
|
|
|
|
const isCreator = card?.createdBy && session?.user.id === card.createdBy;
|
|
const canEdit = canEditCard || isCreator;
|
|
|
|
const board = card?.list.board;
|
|
const labels = board?.labels;
|
|
const workspaceMembers = board?.workspace.members;
|
|
const selectedLabels = card?.labels;
|
|
const selectedMembers = card?.members;
|
|
|
|
const formattedLabels =
|
|
labels?.map((label) => {
|
|
const isSelected = selectedLabels?.some(
|
|
(selectedLabel) => selectedLabel.publicId === label.publicId,
|
|
);
|
|
|
|
return {
|
|
key: label.publicId,
|
|
value: label.name,
|
|
selected: isSelected ?? false,
|
|
leftIcon: <LabelIcon colourCode={label.colourCode} />,
|
|
};
|
|
}) ?? [];
|
|
|
|
const formattedLists =
|
|
board?.lists.map((list) => ({
|
|
key: list.publicId,
|
|
value: list.name,
|
|
selected: list.publicId === card?.list.publicId,
|
|
})) ?? [];
|
|
|
|
const formattedMembers =
|
|
workspaceMembers?.map((member) => {
|
|
const isSelected = selectedMembers?.some(
|
|
(assignedMember) => assignedMember.publicId === member.publicId,
|
|
);
|
|
|
|
return {
|
|
key: member.publicId,
|
|
value: formatMemberDisplayName(
|
|
member.user?.name ?? null,
|
|
member.user?.email ?? member.email,
|
|
),
|
|
imageUrl: member.user?.image
|
|
? getAvatarUrl(member.user.image)
|
|
: undefined,
|
|
selected: isSelected ?? false,
|
|
leftIcon: (
|
|
<Avatar
|
|
size="xs"
|
|
name={member.user?.name ?? ""}
|
|
imageUrl={
|
|
member.user?.image ? getAvatarUrl(member.user.image) : undefined
|
|
}
|
|
email={member.user?.email ?? member.email}
|
|
/>
|
|
),
|
|
};
|
|
}) ?? [];
|
|
|
|
return (
|
|
<div className="h-full w-[360px] border-l-[1px] border-light-300 bg-light-50 p-8 text-light-900 dark:border-dark-300 dark:bg-dark-50 dark:text-dark-900">
|
|
<div className="mb-4 flex w-full flex-row pt-[18px]">
|
|
<p className="my-2 mb-2 w-[100px] text-sm font-medium">{t`List`}</p>
|
|
<ListSelector
|
|
cardPublicId={cardId ?? ""}
|
|
lists={formattedLists}
|
|
isLoading={!card}
|
|
disabled={!canEdit}
|
|
/>
|
|
</div>
|
|
<div className="mb-4 flex w-full flex-row">
|
|
<p className="my-2 mb-2 w-[100px] text-sm font-medium">{t`Labels`}</p>
|
|
<LabelSelector
|
|
cardPublicId={cardId ?? ""}
|
|
labels={formattedLabels}
|
|
isLoading={!card}
|
|
disabled={!canEdit}
|
|
/>
|
|
</div>
|
|
{!isTemplate && (
|
|
<div className="mb-4 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}
|
|
disabled={!canEdit}
|
|
/>
|
|
</div>
|
|
)}
|
|
<div className="mb-4 flex w-full flex-row">
|
|
<p className="my-2 mb-2 w-[100px] text-sm font-medium">{t`Due date`}</p>
|
|
<DueDateSelector
|
|
cardPublicId={cardId ?? ""}
|
|
dueDate={card?.dueDate}
|
|
isLoading={!card}
|
|
disabled={!canEdit}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
|
const router = useRouter();
|
|
const utils = api.useUtils();
|
|
const {
|
|
modalContentType,
|
|
entityId,
|
|
getModalState,
|
|
clearModalState,
|
|
isOpen,
|
|
modalStates,
|
|
} = useModal();
|
|
const { showPopup } = usePopup();
|
|
const { workspace } = useWorkspace();
|
|
const { canEditCard } = usePermissions();
|
|
const { data: session } = authClient.useSession();
|
|
const [activeChecklistForm, setActiveChecklistForm] = useState<string | null>(
|
|
null,
|
|
);
|
|
|
|
const cardId = Array.isArray(router.query.cardId)
|
|
? router.query.cardId[0]
|
|
: router.query.cardId;
|
|
|
|
const { data: card, isLoading, error } = api.card.byId.useQuery(
|
|
{ cardPublicId: cardId ?? "" },
|
|
{ enabled: !!cardId && cardId.length >= 12 },
|
|
);
|
|
|
|
// Redirect to 404 if card doesn't exist
|
|
useEffect(() => {
|
|
if (router.isReady && cardId && !isLoading) {
|
|
if (error?.data?.code === "NOT_FOUND" || (!card && !isLoading)) {
|
|
router.replace("/404");
|
|
}
|
|
}
|
|
}, [router, cardId, isLoading, error, card]);
|
|
|
|
const isCreator = card?.createdBy && session?.user.id === card.createdBy;
|
|
const canEdit = canEditCard || isCreator;
|
|
|
|
const refetchCard = async () => {
|
|
if (cardId) await utils.card.byId.refetch({ cardPublicId: cardId });
|
|
};
|
|
|
|
const board = card?.list.board;
|
|
const workspaceMembers = board?.workspace.members;
|
|
const boardId = board?.publicId;
|
|
|
|
const editorWorkspaceMembers =
|
|
workspaceMembers
|
|
?.filter((member) => member.email)
|
|
.map((member) => ({
|
|
publicId: member.publicId,
|
|
email: member.email,
|
|
user: member.user
|
|
? {
|
|
id: member.user.id,
|
|
name: member.user.name ?? null,
|
|
image: member.user.image ?? null,
|
|
}
|
|
: null,
|
|
})) ?? [];
|
|
|
|
const updateCard = api.card.update.useMutation({
|
|
onError: () => {
|
|
showPopup({
|
|
header: t`Unable to update card`,
|
|
message: t`Please try again later, or contact customer support.`,
|
|
icon: "error",
|
|
});
|
|
},
|
|
onSettled: async () => {
|
|
if (cardId) await invalidateCard(utils, cardId);
|
|
},
|
|
});
|
|
|
|
const addOrRemoveLabel = api.card.addOrRemoveLabel.useMutation({
|
|
onError: () => {
|
|
showPopup({
|
|
header: t`Unable to add label`,
|
|
message: t`Please try again later, or contact customer support.`,
|
|
icon: "error",
|
|
});
|
|
},
|
|
onSettled: async () => {
|
|
if (cardId) {
|
|
await utils.card.byId.invalidate({ cardPublicId: cardId });
|
|
}
|
|
},
|
|
});
|
|
|
|
const { register, handleSubmit, setValue, watch } = useForm<FormValues>({
|
|
values: {
|
|
cardId: cardId ?? "",
|
|
title: card?.title ?? "",
|
|
description: card?.description ?? "",
|
|
},
|
|
});
|
|
|
|
const onSubmit = (values: FormValues) => {
|
|
updateCard.mutate({
|
|
cardPublicId: values.cardId,
|
|
title: values.title,
|
|
description: values.description,
|
|
});
|
|
};
|
|
|
|
// this adds the new created label to selected labels
|
|
useEffect(() => {
|
|
const newLabelId = modalStates.NEW_LABEL_CREATED;
|
|
if (newLabelId && cardId) {
|
|
const isAlreadyAdded = card?.labels.some(
|
|
(label) => label.publicId === newLabelId,
|
|
);
|
|
|
|
if (!isAlreadyAdded) {
|
|
addOrRemoveLabel.mutate({
|
|
cardPublicId: cardId,
|
|
labelPublicId: newLabelId,
|
|
});
|
|
}
|
|
clearModalState("NEW_LABEL_CREATED");
|
|
}
|
|
}, [modalStates.NEW_LABEL_CREATED, card, cardId]);
|
|
|
|
// Open the new item form after creating a new checklist
|
|
useEffect(() => {
|
|
if (!card) return;
|
|
const state = getModalState("ADD_CHECKLIST");
|
|
const createdId: string | undefined = state?.createdChecklistId;
|
|
if (createdId) {
|
|
setActiveChecklistForm(createdId);
|
|
clearModalState("ADD_CHECKLIST");
|
|
}
|
|
}, [card, getModalState, clearModalState]);
|
|
|
|
// Auto-resize title textarea
|
|
useEffect(() => {
|
|
const titleTextarea = document.getElementById(
|
|
"title",
|
|
) as HTMLTextAreaElement;
|
|
if (titleTextarea) {
|
|
titleTextarea.style.height = "auto";
|
|
titleTextarea.style.height = `${titleTextarea.scrollHeight}px`;
|
|
}
|
|
}, [card]);
|
|
|
|
if (!cardId) return <></>;
|
|
|
|
return (
|
|
<>
|
|
<PageHead
|
|
title={t`${card?.title ?? t`Card`} | ${board?.name ?? t`Board`}`}
|
|
/>
|
|
<div className="flex h-full flex-1 flex-col overflow-hidden">
|
|
{/* Full-width top strip with board link and dropdown */}
|
|
<div className="flex w-full items-center justify-between border-b-[1px] border-light-300 bg-light-50 px-8 py-2 dark:border-dark-300 dark:bg-dark-50">
|
|
{!card && isLoading && (
|
|
<div className="flex space-x-2">
|
|
<div className="h-[1.5rem] w-[150px] animate-pulse rounded-[5px] bg-light-300 dark:bg-dark-300" />
|
|
</div>
|
|
)}
|
|
{card && (
|
|
<>
|
|
<div className="flex items-center gap-1">
|
|
<Link
|
|
className="whitespace-nowrapleading-[1.5rem] text-sm font-bold text-light-900 dark:text-dark-950"
|
|
href={`${isTemplate ? "/templates" : "/boards"}`}
|
|
>
|
|
{workspace.name}
|
|
</Link>
|
|
<IoChevronForwardSharp className="h-[10px] w-[10px] text-light-900 dark:text-dark-900" />
|
|
<Link
|
|
className="whitespace-nowrap text-sm font-bold leading-[1.5rem] text-light-900 dark:text-dark-950"
|
|
href={`${isTemplate ? "/templates" : "/boards"}/${board?.publicId}`}
|
|
>
|
|
{board?.name}
|
|
</Link>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Dropdown
|
|
cardPublicId={cardId}
|
|
isTemplate={isTemplate}
|
|
boardPublicId={boardId}
|
|
cardCreatedBy={card?.createdBy}
|
|
/>
|
|
<Link
|
|
href={`/${isTemplate ? "templates" : "boards"}/${boardId}`}
|
|
className="flex h-7 w-7 items-center justify-center rounded-[5px] text-light-900 hover:bg-light-200 dark:text-dark-900 dark:hover:bg-dark-200"
|
|
aria-label={t`Close`}
|
|
>
|
|
<HiXMark className="h-4 w-4" />
|
|
</Link>
|
|
</div>
|
|
</>
|
|
)}
|
|
{!card && !isLoading && (
|
|
<p className="block p-0 py-0 font-bold leading-[1.5rem] tracking-tight text-light-900 dark:text-dark-900 sm:text-[1rem]">
|
|
{t`Card not found`}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<div className="scrollbar-thumb-rounded-[4px] scrollbar-track-rounded-[4px] w-full flex-1 overflow-y-auto scrollbar scrollbar-track-light-200 scrollbar-thumb-light-400 hover:scrollbar-thumb-light-400 dark:scrollbar-track-dark-100 dark:scrollbar-thumb-dark-300 dark:hover:scrollbar-thumb-dark-300">
|
|
<div className="p-auto mx-auto flex h-full w-full max-w-[800px] flex-col">
|
|
<div className="p-6 md:p-8">
|
|
<div className="mb-8 md:mt-4">
|
|
{!card && isLoading && (
|
|
<div className="flex space-x-2">
|
|
<div className="h-[2.3rem] w-[300px] animate-pulse rounded-[5px] bg-light-300 dark:bg-dark-300" />
|
|
</div>
|
|
)}
|
|
{card && (
|
|
<form
|
|
onSubmit={handleSubmit(onSubmit)}
|
|
className="w-full space-y-6"
|
|
>
|
|
<div>
|
|
<textarea
|
|
id="title"
|
|
{...register("title")}
|
|
onBlur={canEdit ? handleSubmit(onSubmit) : undefined}
|
|
rows={1}
|
|
disabled={!canEdit}
|
|
className={`block w-full resize-none overflow-hidden border-0 bg-transparent p-0 py-0 font-bold leading-relaxed text-neutral-900 focus:ring-0 dark:text-dark-1000 sm:text-[1.2rem] ${!canEdit ? "cursor-default" : ""}`}
|
|
onInput={(e) => {
|
|
const target = e.target as HTMLTextAreaElement;
|
|
target.style.height = "auto";
|
|
target.style.height = `${target.scrollHeight}px`;
|
|
}}
|
|
/>
|
|
</div>
|
|
</form>
|
|
)}
|
|
{!card && !isLoading && (
|
|
<p className="block p-0 py-0 font-bold leading-[2.3rem] tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem]">
|
|
{t`Card not found`}
|
|
</p>
|
|
)}
|
|
</div>
|
|
{card && (
|
|
<>
|
|
<div className="mb-10 flex w-full max-w-2xl flex-col justify-between">
|
|
<form
|
|
onSubmit={handleSubmit(onSubmit)}
|
|
className="w-full space-y-6"
|
|
>
|
|
<div className="mt-2">
|
|
<Editor
|
|
content={card.description}
|
|
onChange={
|
|
canEdit
|
|
? (e) => setValue("description", e)
|
|
: undefined
|
|
}
|
|
onBlur={
|
|
canEdit ? () => handleSubmit(onSubmit)() : undefined
|
|
}
|
|
workspaceMembers={workspaceMembers ?? []}
|
|
readOnly={!canEdit}
|
|
/>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
<Checklists
|
|
checklists={card.checklists}
|
|
cardPublicId={cardId}
|
|
activeChecklistForm={activeChecklistForm}
|
|
setActiveChecklistForm={setActiveChecklistForm}
|
|
viewOnly={!canEdit}
|
|
/>
|
|
{!isTemplate && (
|
|
<>
|
|
{card?.attachments.length > 0 && (
|
|
<div className="mt-6">
|
|
<AttachmentThumbnails
|
|
attachments={card.attachments}
|
|
cardPublicId={cardId ?? ""}
|
|
isReadOnly={!canEdit}
|
|
/>
|
|
</div>
|
|
)}
|
|
{canEdit && (
|
|
<div className="mt-6">
|
|
<AttachmentUpload cardPublicId={cardId} />
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
<div className="border-t-[1px] border-light-300 pt-12 dark:border-dark-300">
|
|
<h2 className="text-md pb-4 font-medium text-light-1000 dark:text-dark-1000">
|
|
{t`Activity`}
|
|
</h2>
|
|
<div>
|
|
<ActivityList
|
|
cardPublicId={cardId}
|
|
isLoading={!card}
|
|
isAdmin={workspace.role === "admin"}
|
|
/>
|
|
</div>
|
|
{!isTemplate && (
|
|
<div className="mt-6">
|
|
<NewCommentForm
|
|
cardPublicId={cardId}
|
|
workspaceMembers={editorWorkspaceMembers}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<>
|
|
<Modal
|
|
modalSize="md"
|
|
isVisible={isOpen && modalContentType === "NEW_FEEDBACK"}
|
|
>
|
|
<FeedbackModal />
|
|
</Modal>
|
|
|
|
<Modal
|
|
modalSize="sm"
|
|
isVisible={isOpen && modalContentType === "NEW_LABEL"}
|
|
>
|
|
<LabelForm boardPublicId={boardId ?? ""} refetch={refetchCard} />
|
|
</Modal>
|
|
|
|
<Modal
|
|
modalSize="sm"
|
|
isVisible={isOpen && modalContentType === "EDIT_LABEL"}
|
|
>
|
|
<LabelForm
|
|
boardPublicId={boardId ?? ""}
|
|
refetch={refetchCard}
|
|
isEdit
|
|
/>
|
|
</Modal>
|
|
|
|
<Modal
|
|
modalSize="sm"
|
|
isVisible={isOpen && modalContentType === "DELETE_LABEL"}
|
|
>
|
|
<DeleteLabelConfirmation
|
|
refetch={refetchCard}
|
|
labelPublicId={entityId}
|
|
/>
|
|
</Modal>
|
|
|
|
<Modal
|
|
modalSize="sm"
|
|
isVisible={isOpen && modalContentType === "DELETE_CARD"}
|
|
>
|
|
<DeleteCardConfirmation
|
|
boardPublicId={boardId ?? ""}
|
|
cardPublicId={cardId}
|
|
/>
|
|
</Modal>
|
|
|
|
<Modal
|
|
modalSize="sm"
|
|
isVisible={isOpen && modalContentType === "DELETE_COMMENT"}
|
|
>
|
|
<DeleteCommentConfirmation
|
|
cardPublicId={cardId}
|
|
commentPublicId={entityId}
|
|
/>
|
|
</Modal>
|
|
|
|
<Modal
|
|
modalSize="sm"
|
|
isVisible={isOpen && modalContentType === "NEW_WORKSPACE"}
|
|
>
|
|
<NewWorkspaceForm />
|
|
</Modal>
|
|
|
|
<Modal
|
|
modalSize="sm"
|
|
isVisible={isOpen && modalContentType === "ADD_CHECKLIST"}
|
|
>
|
|
<NewChecklistForm cardPublicId={cardId} />
|
|
</Modal>
|
|
|
|
<Modal
|
|
modalSize="sm"
|
|
isVisible={isOpen && modalContentType === "DELETE_CHECKLIST"}
|
|
>
|
|
<DeleteChecklistConfirmation
|
|
cardPublicId={cardId}
|
|
checklistPublicId={entityId}
|
|
/>
|
|
</Modal>
|
|
|
|
<Modal
|
|
modalSize="sm"
|
|
isVisible={isOpen && modalContentType === "EDIT_YOUTUBE"}
|
|
>
|
|
<EditYouTubeModal />
|
|
</Modal>
|
|
</>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|