diff --git a/apps/web/src/components/ArchivedDeletedLayout.tsx b/apps/web/src/components/ArchivedDeletedLayout.tsx new file mode 100644 index 000000000..2dd269273 --- /dev/null +++ b/apps/web/src/components/ArchivedDeletedLayout.tsx @@ -0,0 +1,150 @@ +import Link from "next/link"; +import { useRouter } from "next/router"; +import { + Listbox, + ListboxButton, + ListboxOption, + ListboxOptions, +} from "@headlessui/react"; +import { t } from "@lingui/core/macro"; +import { useEffect, useState } from "react"; +import { + HiChevronDown, + HiOutlineArchiveBoxXMark, + HiOutlineTrash, + HiArrowLeft, +} from "react-icons/hi2"; + +interface ArchivedDeletedLayoutProps { + children: React.ReactNode; + currentTab: string; + boardPublicId: string; +} + +export function ArchivedDeletedLayout({ + children, + currentTab, + boardPublicId, +}: ArchivedDeletedLayoutProps) { + const router = useRouter(); + const [selectedTabIndex, setSelectedTabIndex] = useState(0); + + const tabs = [ + { + key: "archived", + icon: , + label: t`Archived cards`, + }, + { + key: "deleted", + icon: , + label: t`Deleted cards`, + }, + ]; + + // Update selected tab when currentTab prop changes + useEffect(() => { + const tabIndex = tabs.findIndex((tab) => tab.key === currentTab); + if (tabIndex !== -1) { + setSelectedTabIndex(tabIndex); + } + }, [currentTab, tabs]); + + const isTabActive = (tabKey: string) => { + return currentTab === tabKey; + }; + + return ( +
+
+
+
+ + + {t`Back to board`} + +
+ +
+
+ {/* Mobile dropdown */} + { + const tabKey = tabs[index]?.key; + if (tabKey) { + void router.push(`/boards/${boardPublicId}/${tabKey}`); + } + }} + > +
+ + + + {tabs[selectedTabIndex]?.icon} + + {tabs[selectedTabIndex]?.label ?? "Select a tab"} + + + + {tabs.map((tab) => ( + + + + {tab.icon} + + {tab.label} + + + ))} + +
+
+
+
+
+ +
+
+
{children}
+
+
+
+
+ ); +} diff --git a/apps/web/src/hooks/usePermissions.ts b/apps/web/src/hooks/usePermissions.ts index ed282b3aa..729e4dd1a 100644 --- a/apps/web/src/hooks/usePermissions.ts +++ b/apps/web/src/hooks/usePermissions.ts @@ -13,6 +13,7 @@ interface UsePermissionsResult { canCreateCard: boolean; canEditCard: boolean; canDeleteCard: boolean; + canArchiveCard: boolean; canCreateList: boolean; canEditList: boolean; canDeleteList: boolean; @@ -45,6 +46,7 @@ export function usePermissions(): UsePermissionsResult { canCreateCard: false, canEditCard: false, canDeleteCard: false, + canArchiveCard: false, canCreateList: false, canEditList: false, canDeleteList: false, @@ -89,6 +91,7 @@ export function usePermissions(): UsePermissionsResult { canCreateCard: hasPermission("card:create"), canEditCard: hasPermission("card:edit"), canDeleteCard: hasPermission("card:delete"), + canArchiveCard: hasPermission("card:archive"), canCreateList: hasPermission("list:create"), canEditList: hasPermission("list:edit"), canDeleteList: hasPermission("list:delete"), diff --git a/apps/web/src/pages/boards/[boardPublicId]/archived.tsx b/apps/web/src/pages/boards/[boardPublicId]/archived.tsx new file mode 100644 index 000000000..addb177e4 --- /dev/null +++ b/apps/web/src/pages/boards/[boardPublicId]/archived.tsx @@ -0,0 +1,20 @@ +import { useRouter } from "next/router"; +import { getDashboardLayout } from "~/components/Dashboard"; +import { ArchivedDeletedLayout } from "~/components/ArchivedDeletedLayout"; +import ArchivedCardsView from "~/views/board/ArchivedCardsView"; +import type { NextPageWithLayout } from "~/pages/_app"; + +const ArchivedCardsPage: NextPageWithLayout = () => { + const router = useRouter(); + const boardPublicId = router.query.boardPublicId as string; + + return ( + + {boardPublicId && } + + ); +}; + +ArchivedCardsPage.getLayout = (page) => getDashboardLayout(page); + +export default ArchivedCardsPage; diff --git a/apps/web/src/pages/boards/[boardPublicId]/deleted.tsx b/apps/web/src/pages/boards/[boardPublicId]/deleted.tsx new file mode 100644 index 000000000..9c5be1f1f --- /dev/null +++ b/apps/web/src/pages/boards/[boardPublicId]/deleted.tsx @@ -0,0 +1,20 @@ +import { useRouter } from "next/router"; +import { getDashboardLayout } from "~/components/Dashboard"; +import { ArchivedDeletedLayout } from "~/components/ArchivedDeletedLayout"; +import DeletedCardsView from "~/views/board/DeletedCardsView"; +import type { NextPageWithLayout } from "~/pages/_app"; + +const DeletedCardsPage: NextPageWithLayout = () => { + const router = useRouter(); + const boardPublicId = router.query.boardPublicId as string; + + return ( + + {boardPublicId && } + + ); +}; + +DeletedCardsPage.getLayout = (page) => getDashboardLayout(page); + +export default DeletedCardsPage; diff --git a/apps/web/src/views/board/ArchivedCardsView.tsx b/apps/web/src/views/board/ArchivedCardsView.tsx new file mode 100644 index 000000000..fe850eab4 --- /dev/null +++ b/apps/web/src/views/board/ArchivedCardsView.tsx @@ -0,0 +1,82 @@ +import { t } from "@lingui/core/macro"; +import { formatDistanceToNow } from "date-fns"; +import * as React from "react"; +import { HiOutlineArchiveBoxXMark } from "react-icons/hi2"; + +import { usePermissions } from "~/hooks/usePermissions"; +import { api } from "~/utils/api"; +import Button from "~/components/Button"; + +interface Props { + boardPublicId: string; +} + +export default function ArchivedCardsView({ boardPublicId }: Props) { + const utils = api.useUtils(); + const { canArchiveCard } = usePermissions(); + + const { data: archivedCards, isLoading } = api.board.archivedCards.useQuery( + { boardPublicId }, + { enabled: !!boardPublicId } + ); + + const unarchiveMutation = api.card.unarchive.useMutation({ + onSuccess: () => { + utils.board.archivedCards.invalidate({ boardPublicId }); + utils.board.byId.invalidate({ boardPublicId }); + }, + }); + + const handleUnarchive = (cardPublicId: string) => { + unarchiveMutation.mutate({ cardPublicId }); + }; + + return ( +
+
+ {isLoading ? ( +
+
+
+ ) : !archivedCards || archivedCards.length === 0 ? ( +
+ +

{t`No archived cards`}

+
+ ) : ( +
+ {archivedCards.map((card) => ( +
+
+

{card.title}

+
+ {t`in ${card.listName}`} + {card.archivedAt && ( + <> + + {t`Archived ${formatDistanceToNow(new Date(card.archivedAt), { addSuffix: true })}`} + + )} +
+
+
+ {canArchiveCard && ( + + )} +
+
+ ))} +
+ )} +
+
+ ); +} diff --git a/apps/web/src/views/board/DeletedCardsView.tsx b/apps/web/src/views/board/DeletedCardsView.tsx new file mode 100644 index 000000000..8952d49db --- /dev/null +++ b/apps/web/src/views/board/DeletedCardsView.tsx @@ -0,0 +1,100 @@ +import { t } from "@lingui/core/macro"; +import { formatDistanceToNow } from "date-fns"; +import * as React from "react"; +import { HiOutlineTrash } from "react-icons/hi2"; + +import { api } from "~/utils/api"; +import Button from "~/components/Button"; + +interface Props { + boardPublicId: string; +} + +export default function DeletedCardsView({ boardPublicId }: Props) { + const utils = api.useUtils(); + + const { data: deletedcards, isLoading } = api.board.deletedCards.useQuery( + { boardPublicId }, + { enabled: !!boardPublicId } + ); + + const restoreMutation = api.card.restore.useMutation({ + onSuccess: () => { + utils.board.deletedCards.invalidate({ boardPublicId }); + utils.board.byId.invalidate({ boardPublicId }); + }, + }); + + const hardDeleteMutation = api.card.hardDelete.useMutation({ + onSuccess: () => { + utils.board.deletedCards.invalidate({ boardPublicId }); + }, + }); + + const handleRestore = (cardPublicId: string) => { + restoreMutation.mutate({ cardPublicId }); + }; + + const handleHardDelete = (cardPublicId: string) => { + if (confirm(t`Are you sure you want to permanently delete this card? This action cannot be undone.`)) { + hardDeleteMutation.mutate({ cardPublicId }); + } + }; + + return ( +
+
+ {isLoading ? ( +
+
+
+ ) : !deletedcards || deletedcards.length === 0 ? ( +
+ +

{t`No deleted cards`}

+

{t`Cards you delete will appear here.`}

+
+ ) : ( +
+ {deletedcards.map((card) => ( +
+
+

{card.title}

+
+ {t`in ${card.listName}`} + {card.deletedAt && ( + <> + + {t`Deleted ${formatDistanceToNow(new Date(card.deletedAt), { addSuffix: true })}`} + + )} +
+
+
+ + +
+
+ ))} +
+ )} +
+
+ ); +} diff --git a/apps/web/src/views/board/components/BoardDropdown.tsx b/apps/web/src/views/board/components/BoardDropdown.tsx index e04dd4117..63e76dc96 100644 --- a/apps/web/src/views/board/components/BoardDropdown.tsx +++ b/apps/web/src/views/board/components/BoardDropdown.tsx @@ -1,4 +1,5 @@ import { t } from "@lingui/core/macro"; +import { useRouter } from "next/router"; import { HiEllipsisHorizontal, HiLink, @@ -6,6 +7,7 @@ import { HiOutlineTrash, HiOutlineStar, HiStar, + HiOutlineRectangleStack, } from "react-icons/hi2"; import Dropdown from "~/components/Dropdown"; @@ -27,6 +29,7 @@ export default function BoardDropdown({ isFavorite?: boolean; boardName?: string; }) { + const router = useRouter(); const { openModal } = useModal(); const { canEditBoard, canDeleteBoard, canCreateBoard } = usePermissions(); const { showPopup } = usePopup(); @@ -107,6 +110,16 @@ export default function BoardDropdown({ }, ] : []), + { + label: t`Archived cards`, + action: () => router.push(`/boards/${boardPublicId}/archived`), + icon: , + }, + { + label: t`Deleted cards`, + action: () => router.push(`/boards/${boardPublicId}/deleted`), + icon: , + }, ]; diff --git a/apps/web/src/views/board/index.tsx b/apps/web/src/views/board/index.tsx index 0de78204e..b360e8c13 100644 --- a/apps/web/src/views/board/index.tsx +++ b/apps/web/src/views/board/index.tsx @@ -383,13 +383,13 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) { sourceBoardName={boardData?.name ?? ""} /> - + ); }; diff --git a/apps/web/src/views/card/components/ActivityList.tsx b/apps/web/src/views/card/components/ActivityList.tsx index 3cfc397a1..b35c7f683 100644 --- a/apps/web/src/views/card/components/ActivityList.tsx +++ b/apps/web/src/views/card/components/ActivityList.tsx @@ -15,6 +15,8 @@ import { HiOutlineTrash, HiOutlineUserMinus, HiOutlineUserPlus, + HiOutlineArchiveBoxXMark, + HiOutlineArrowUturnUp, } from "react-icons/hi2"; import type { @@ -142,6 +144,10 @@ const getActivityText = ({ "card.updated.dueDate.added": t`set the due date`, "card.updated.dueDate.updated": t`updated the due date`, "card.updated.dueDate.removed": t`removed the due date`, + "card.archived": t`archived the card`, + "card.unarchived": t`unarchived the card`, + "card.restored": t`restored the card from deleted cards`, + "card.deleted": t`deleted the card`, } as const; if (!(type in ACTIVITY_TYPE_MAP)) return null; @@ -349,6 +355,10 @@ const ACTIVITY_ICON_MAP: Partial> = "card.updated.dueDate.added": , "card.updated.dueDate.updated": , "card.updated.dueDate.removed": , + "card.archived": , + "card.unarchived": , + "card.restored": , + "card.deleted": , } as const; const getActivityIcon = ( diff --git a/apps/web/src/views/card/components/ArchiveCardConfirmation.tsx b/apps/web/src/views/card/components/ArchiveCardConfirmation.tsx new file mode 100644 index 000000000..44268d3a3 --- /dev/null +++ b/apps/web/src/views/card/components/ArchiveCardConfirmation.tsx @@ -0,0 +1,69 @@ +import { useRouter } from "next/navigation"; +import { t } from "@lingui/core/macro"; + +import Button from "~/components/Button"; +import { useModal } from "~/providers/modal"; +import { usePopup } from "~/providers/popup"; +import { api } from "~/utils/api"; + +interface ArchiveCardConfirmationProps { + cardPublicId: string; + boardPublicId?: string; +} + +export function ArchiveCardConfirmation({ + cardPublicId, + boardPublicId, +}: ArchiveCardConfirmationProps) { + const { closeModal } = useModal(); + const utils = api.useUtils(); + const router = useRouter(); + const { showPopup } = usePopup(); + + const archiveCardMutation = api.card.archive.useMutation({ + onSuccess: () => { + if (boardPublicId) { + router.push(`/boards/${boardPublicId}`); + utils.board.byId.invalidate({ boardPublicId }); + } + closeModal(); + }, + onError: () => { + showPopup({ + header: t`Unable to archive card`, + message: t`Please try again.`, + icon: "error", + }); + }, + }); + + const handleArchiveCard = () => { + archiveCardMutation.mutate({ + cardPublicId, + }); + }; + + return ( +
+
+

+ {t`Are you sure you want to archive this card?`} +

+
+
+ + +
+
+ ); +} diff --git a/apps/web/src/views/card/components/DeleteCardConfirmation.tsx b/apps/web/src/views/card/components/DeleteCardConfirmation.tsx index 235091695..89f3bd6ed 100644 --- a/apps/web/src/views/card/components/DeleteCardConfirmation.tsx +++ b/apps/web/src/views/card/components/DeleteCardConfirmation.tsx @@ -74,9 +74,6 @@ export function DeleteCardConfirmation({

{t`Are you sure you want to delete this card?`}

-

- {t`This action can't be undone.`} -