Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
150 changes: 150 additions & 0 deletions apps/web/src/components/ArchivedDeletedLayout.tsx
Original file line number Diff line number Diff line change
@@ -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: <HiOutlineArchiveBoxXMark />,
label: t`Archived cards`,
},
{
key: "deleted",
icon: <HiOutlineTrash />,
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 (
<div className="flex h-full w-full flex-col overflow-hidden">
<div className="h-full max-h-[calc(100vdh-3rem)] overflow-y-auto md:max-h-[calc(100vdh-4rem)]">
<div className="m-auto max-w-[1100px] px-5 py-6 md:px-28 md:py-12">
<div className="mb-8 flex w-full flex-col">
<Link
href={`/boards/${boardPublicId}`}
className="mb-4 flex w-fit items-center text-sm font-medium text-light-800 hover:text-light-1000 dark:text-dark-800 dark:hover:text-dark-1000"
>
<HiArrowLeft className="mr-2 h-4 w-4" />
{t`Back to board`}
</Link>
</div>

<div className="focus:outline-none">
<div className="sm:hidden">
{/* Mobile dropdown */}
<Listbox
value={selectedTabIndex}
onChange={(index) => {
const tabKey = tabs[index]?.key;
if (tabKey) {
void router.push(`/boards/${boardPublicId}/${tabKey}`);
}
}}
>
<div className="relative mb-4">
<ListboxButton className="w-full appearance-none rounded-lg border-0 bg-light-50 py-2 pl-3 pr-10 text-left text-sm text-light-1000 shadow-sm ring-1 ring-inset ring-light-300 focus:ring-2 focus:ring-inset focus:ring-light-400 dark:bg-dark-50 dark:text-dark-1000 dark:ring-dark-300 dark:focus:ring-dark-500">
<span className="flex items-center">
<span className="mr-2">
{tabs[selectedTabIndex]?.icon}
</span>
{tabs[selectedTabIndex]?.label ?? "Select a tab"}
</span>
<HiChevronDown
aria-hidden="true"
className="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-light-900 dark:text-dark-900"
/>
</ListboxButton>
<ListboxOptions className="absolute z-10 mt-1 w-full rounded-lg bg-light-50 py-1 text-sm shadow-lg ring-1 ring-inset ring-light-300 dark:bg-dark-50 dark:ring-dark-300">
{tabs.map((tab) => (
<ListboxOption
key={tab.key}
value={tabs.indexOf(tab)}
className="relative cursor-pointer select-none py-2 pl-3 pr-9 text-light-1000 dark:text-dark-1000"
>
<span className="flex items-center">
<span className="mr-2 text-light-800 dark:text-dark-800">
{tab.icon}
</span>
{tab.label}
</span>
</ListboxOption>
))}
</ListboxOptions>
</div>
</Listbox>
</div>
<div className="hidden sm:block">
<div className="border-b border-gray-200 dark:border-white/10">
<nav
aria-label="Tabs"
className="-mb-px flex space-x-8 focus:outline-none"
>
{tabs.map((tab) => (
<Link
key={tab.key}
href={`/boards/${boardPublicId}/${tab.key}`}
className={`flex items-center whitespace-nowrap border-b-2 px-1 py-4 text-sm font-medium transition-colors focus:outline-none ${isTabActive(tab.key)
? "border-light-1000 text-light-1000 dark:border-dark-1000 dark:text-dark-1000"
: "border-transparent text-light-900 hover:border-light-950 hover:text-light-950 dark:text-dark-900 dark:hover:border-white/20 dark:hover:text-dark-950"
}`}
>
<span
className={`mr-2 ${isTabActive(tab.key)
? "text-light-1000 dark:text-dark-1000"
: "text-light-800 dark:text-dark-800 group-hover:text-light-950 dark:group-hover:text-dark-950"
}`}
>
{tab.icon}
</span>
{tab.label}
</Link>
))}
</nav>
</div>
</div>
<div className="focus:outline-none pt-4">{children}</div>
</div>
</div>
</div>
</div>
);
}
3 changes: 3 additions & 0 deletions apps/web/src/hooks/usePermissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ interface UsePermissionsResult {
canCreateCard: boolean;
canEditCard: boolean;
canDeleteCard: boolean;
canArchiveCard: boolean;
canCreateList: boolean;
canEditList: boolean;
canDeleteList: boolean;
Expand Down Expand Up @@ -45,6 +46,7 @@ export function usePermissions(): UsePermissionsResult {
canCreateCard: false,
canEditCard: false,
canDeleteCard: false,
canArchiveCard: false,
canCreateList: false,
canEditList: false,
canDeleteList: false,
Expand Down Expand Up @@ -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"),
Expand Down
20 changes: 20 additions & 0 deletions apps/web/src/pages/boards/[boardPublicId]/archived.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<ArchivedDeletedLayout currentTab="archived" boardPublicId={boardPublicId}>
{boardPublicId && <ArchivedCardsView boardPublicId={boardPublicId} />}
</ArchivedDeletedLayout>
);
};

ArchivedCardsPage.getLayout = (page) => getDashboardLayout(page);

export default ArchivedCardsPage;
20 changes: 20 additions & 0 deletions apps/web/src/pages/boards/[boardPublicId]/deleted.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<ArchivedDeletedLayout currentTab="deleted" boardPublicId={boardPublicId}>
{boardPublicId && <DeletedCardsView boardPublicId={boardPublicId} />}
</ArchivedDeletedLayout>
);
};

DeletedCardsPage.getLayout = (page) => getDashboardLayout(page);

export default DeletedCardsPage;
82 changes: 82 additions & 0 deletions apps/web/src/views/board/ArchivedCardsView.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex w-full flex-col">
<div className="flex-1 pb-5">
{isLoading ? (
<div className="flex h-full items-center justify-center p-10">
<div className="h-8 w-8 animate-spin rounded-full border-b-2 border-t-2 border-primary-500"></div>
</div>
) : !archivedCards || archivedCards.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center text-center p-20">
<HiOutlineArchiveBoxXMark className="mb-4 h-12 w-12 text-neutral-800 dark:text-dark-900" />
<p className="text-sm font-medium text-neutral-800 dark:text-dark-900">{t`No archived cards`}</p>
</div>
) : (
<div className="space-y-3">
{archivedCards.map((card) => (
<div key={card.id} className="flex flex-col sm:flex-row sm:items-center justify-between rounded-md border-[1px] border-light-200 bg-white p-4 shadow-sm dark:border-dark-300 dark:bg-dark-100">
<div className="mb-3 sm:mb-0 max-w-[70%]">
<p className="truncate font-semibold text-neutral-900 dark:text-dark-1000" title={card.title}>{card.title}</p>
<div className="mt-1 flex items-center text-xs text-neutral-500 dark:text-dark-800">
<span className="truncate max-w-[120px]" title={card.listName}>{t`in ${card.listName}`}</span>
{card.archivedAt && (
<>
<span className="mx-1">•</span>
<span>{t`Archived ${formatDistanceToNow(new Date(card.archivedAt), { addSuffix: true })}`}</span>
</>
)}
</div>
</div>
<div>
{canArchiveCard && (
<Button
size="sm"
variant="secondary"
onClick={() => handleUnarchive(card.publicId)}
disabled={unarchiveMutation.isPending}
isLoading={unarchiveMutation.isPending && unarchiveMutation.variables?.cardPublicId === card.publicId}
>
{t`Unarchive`}
</Button>
)}
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}
Loading