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.`}
-