feat: archived boards (#386)

* init: schema migration, isArchived added to
board table. refactor: board repo for isArchived filtering

* init: archived, unarchived API procedures. refactor: all query

* fix: migration error

* feat: add tabbed navigation for boards view

* Implemented a Listbox for mobile and a tabbed navigation for desktop to switch between "Boards" and "Archived" views.
* Introduced state management for active tab selection.
* Updated UI components to reflect the new navigation structure.

* init: frontend/boards lists & tabs

* chore: fixed font styling and spacing

* init:boardDropdown / boardView.

* chore:added translations

* Remove .cursor plan file from repo

* fix:build erros

* revert: remove locales changes

* fix:reverted changes under locales, replaced the archive and unarchive endpoints. Reorder migrations

* fix:migration issue

* fix: update journal.json

---------

Co-authored-by: Henry <henry_ball@hotmail.co.uk>
This commit is contained in:
Eliott Herbert-Byrnes
2026-02-26 21:39:15 +00:00
committed by GitHub
parent 9cb1fb5218
commit af36fb133a
24 changed files with 6899 additions and 121 deletions

View File

@@ -7,7 +7,7 @@ import {
HiOutlineStar,
HiStar,
} from "react-icons/hi2";
import { IoArchiveOutline } from "react-icons/io5";
import Dropdown from "~/components/Dropdown";
import { usePermissions } from "~/hooks/usePermissions";
import { useModal } from "~/providers/modal";
@@ -17,6 +17,7 @@ import { api } from "~/utils/api";
export default function BoardDropdown({
isTemplate,
isLoading,
isArchived,
boardPublicId,
isFavorite,
boardName,
@@ -24,28 +25,29 @@ export default function BoardDropdown({
isTemplate: boolean;
isLoading: boolean;
boardPublicId: string;
isArchived?: boolean;
isFavorite?: boolean;
boardName?: string;
}) {
const { openModal } = useModal();
const { canEditBoard, canDeleteBoard, canCreateBoard } = usePermissions();
const { showPopup } = usePopup();
const { canEditBoard, canDeleteBoard, canCreateBoard, canArchiveBoard } =
usePermissions();
const utils = api.useUtils();
const handleToggleFavorite = () => {
updateBoard.mutate({
boardPublicId,
favorite: !isFavorite,
});
};
const updateBoard = api.board.update.useMutation({
onSuccess: (data, variables) => {
onSuccess: (_data, variables) => {
void utils.board.all.invalidate();
void utils.board.byId.invalidate();
// Show popup notification
if (variables.favorite !== undefined) {
if (variables.isArchived !== undefined) {
showPopup({
header: variables.isArchived ? t`Board archived` : t`Board unarchived`,
message: variables.isArchived
? t`The board has been archived.`
: t`The board has been unarchived.`,
icon: "success",
});
} else if (variables.favorite !== undefined) {
showPopup({
header: variables.favorite
? t`Added to favorites`
@@ -65,27 +67,54 @@ export default function BoardDropdown({
});
},
});
const handleToggleFavorite = () => {
updateBoard.mutate({
boardPublicId,
favorite: !isFavorite,
});
};
const handleArchiveOrUnarchive = () => {
updateBoard.mutate({
boardPublicId,
isArchived: !isArchived,
});
};
const isArchiveActionPending = updateBoard.isPending;
const items = [
...(isTemplate && canCreateBoard
? [
{
label: t`Make template`,
action: () => openModal("CREATE_TEMPLATE"),
icon: (
<HiOutlineDocumentDuplicate className="h-[16px] w-[16px] text-dark-900" />
),
},
]
{
label: t`Make template`,
action: () => openModal("CREATE_TEMPLATE"),
icon: (
<HiOutlineDocumentDuplicate className="h-[16px] w-[16px] text-dark-900" />
),
},
]
: []),
...(!isTemplate && canEditBoard
? [
{
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" />,
},
]
: []),
...(!isTemplate && canArchiveBoard
? [
{
label: isArchived ? t`Unarchive board` : t`Archive board`,
action: handleArchiveOrUnarchive,
icon: (
<IoArchiveOutline className="h-[16px] w-[16px] text-dark-900" />
),
},
]
: []),
{
label: isFavorite
@@ -100,22 +129,26 @@ export default function BoardDropdown({
},
...(canDeleteBoard
? [
{
label: isTemplate ? t`Delete template` : t`Delete board`,
action: () => openModal("DELETE_BOARD"),
icon: <HiOutlineTrash className="h-[16px] w-[16px] text-dark-900" />,
},
]
{
label: isTemplate ? t`Delete template` : t`Delete board`,
action: () => openModal("DELETE_BOARD"),
icon: (
<HiOutlineTrash className="h-[16px] w-[16px] text-dark-900" />
),
},
]
: []),
];
if (items.length === 0) {
return null;
}
return (
<Dropdown disabled={isLoading} items={items}>
<Dropdown
disabled={isLoading || isArchiveActionPending}
items={items}
>
<HiEllipsisHorizontal className="h-5 w-5 text-dark-900" />
</Dropdown>
);

View File

@@ -494,7 +494,7 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
isTemplate={!!isTemplate}
isLoading={!boardData}
boardPublicId={boardId ?? ""}
workspacePublicId={workspace.publicId}
isArchived={boardData?.isArchived ?? false}
isFavorite={boardData?.favorite}
boardName={boardData?.name}
/>
@@ -598,13 +598,12 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
? `/templates/${boardId}/cards/${card.publicId}`
: `/cards/${card.publicId}`
}
className={`mb-2 flex !cursor-pointer flex-col ${
card.publicId.startsWith(
"PLACEHOLDER",
)
? "pointer-events-none"
: ""
}`}
className={`mb-2 flex !cursor-pointer flex-col ${card.publicId.startsWith(
"PLACEHOLDER",
)
? "pointer-events-none"
: ""
}`}
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}

View File

@@ -10,7 +10,7 @@ import { useModal } from "~/providers/modal";
import { useWorkspace } from "~/providers/workspace";
import { api } from "~/utils/api";
export function BoardsList({ isTemplate }: { isTemplate?: boolean }) {
export function BoardsList({ isTemplate, archived = false }: { isTemplate?: boolean; archived?: boolean }) {
const { workspace } = useWorkspace();
const { openModal } = useModal();
const { canCreateBoard } = usePermissions();
@@ -26,6 +26,7 @@ export function BoardsList({ isTemplate }: { isTemplate?: boolean }) {
{
workspacePublicId: workspace.publicId,
type: isTemplate ? "template" : "regular",
archived: archived,
},
{ enabled: workspace.publicId ? true : false },
);
@@ -59,10 +60,10 @@ export function BoardsList({ isTemplate }: { isTemplate?: boolean }) {
<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 ${isTemplate ? "templates" : "boards"}`}
{archived ? t`No archived 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 ${isTemplate ? "template" : "board"}`}
{archived ? t`Boards you archive will appear here.` : t`Get started by creating a new ${isTemplate ? "template" : "board"}`}
</p>
</div>
<Tooltip
@@ -109,18 +110,18 @@ export function BoardsList({ isTemplate }: { isTemplate?: boolean }) {
>
<div className="group 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 />
<button
onClick={(e) => handleToggleFavorite(e, board.publicId, board.favorite)}
className={`absolute right-3 top-3 z-10 rounded p-1 transition-all hover:bg-light-300 dark:hover:bg-dark-200 ${board.favorite ? "" : "md:opacity-0 md:group-hover:opacity-100"
}`}
aria-label={board.favorite ? "Remove from favorites" : "Add to favorites"}
>
{board.favorite ? (
<HiStar className="h-5 w-5 text-neutral-700 dark:text-dark-1000" />
) : (
<HiOutlineStar className="h-5 w-5 text-neutral-700 dark:text-dark-800" />
)}
</button>
<button
onClick={(e) => handleToggleFavorite(e, board.publicId, board.favorite)}
className={`absolute right-3 top-3 z-10 rounded p-1 transition-all hover:bg-light-300 dark:hover:bg-dark-200 ${board.favorite ? "" : "md:opacity-0 md:group-hover:opacity-100"
}`}
aria-label={board.favorite ? "Remove from favorites" : "Add to favorites"}
>
{board.favorite ? (
<HiStar className="h-5 w-5 text-neutral-700 dark:text-dark-1000" />
) : (
<HiOutlineStar className="h-5 w-5 text-neutral-700 dark:text-dark-800" />
)}
</button>
<p className="px-4 text-[14px] font-bold text-neutral-700 dark:text-dark-1000">
{board.name}
</p>

View File

@@ -1,5 +1,12 @@
import {
Listbox,
ListboxButton,
ListboxOption,
ListboxOptions,
} from "@headlessui/react";
import { t } from "@lingui/core/macro";
import { HiArrowDownTray, HiOutlinePlusSmall } from "react-icons/hi2";
import { HiArrowDownTray, HiChevronDown, HiOutlinePlusSmall } from "react-icons/hi2";
import { useState } from "react";
import Button from "~/components/Button";
import FeedbackModal from "~/components/FeedbackModal";
@@ -15,9 +22,15 @@ import { BoardsList } from "./components/BoardsList";
import { ImportBoardsForm } from "./components/ImportBoardsForm";
import { NewBoardForm } from "./components/NewBoardForm";
const boardsTabs = [
{ key: "boards" as const, label: t`Active` },
{ key: "archived" as const, label: t`Archived` },
];
export default function BoardsPage({ isTemplate }: { isTemplate?: boolean }) {
const { openModal, modalContentType, isOpen } = useModal();
const { workspace } = useWorkspace();
const [activeTab, setActiveTab] = useState<"boards" | "archived">("boards");
const { canCreateBoard } = usePermissions();
const { tooltipContent: createModalShortcutTooltipContent } =
@@ -34,7 +47,7 @@ export default function BoardsPage({ isTemplate }: { isTemplate?: boolean }) {
<PageHead
title={t`${isTemplate ? "Templates" : "Boards"} | ${workspace.name ?? t`Workspace`}`}
/>
<div className="m-auto h-full max-w-[1100px] p-6 px-5 md:px-28 md:py-12">
<div className="m-auto h-full max-w-[1100px] p-8 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`${isTemplate ? "Templates" : "Boards"}`}
@@ -115,9 +128,79 @@ export default function BoardsPage({ isTemplate }: { isTemplate?: boolean }) {
</Modal>
</>
<div className="flex h-full flex-row">
<BoardsList isTemplate={!!isTemplate} />
</div>
{!isTemplate ? (
<div className="flex h-full w-full flex-col">
<div className="focus:outline-none">
<div className="sm:hidden">
<Listbox
value={activeTab}
onChange={(tab) => setActiveTab(tab)}
>
<div className="relative mb-4">
<ListboxButton className="w-full appearance-none rounded-md border-0 bg-light-50 py-3 pl-3 pr-10 text-left text-sm font-semibold text-light-1000 shadow-sm ring-1 ring-inset ring-light-300 dark:bg-dark-50 dark:text-dark-1000 dark:ring-dark-300 dark:focus:ring-dark-500">
{boardsTabs.find((tab) => tab.key === activeTab)?.label ??
"Select a tab"}
<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-md bg-light-50 py-1 text-sm shadow-lg ring-1 ring-inset ring-light-300 dark:bg-dark-50 dark:ring-dark-300">
{boardsTabs.map((tab) => (
<ListboxOption
key={tab.key}
value={tab.key}
className={({ selected }) =>
`relative cursor-pointer select-none py-2 pl-3 pr-9 ${selected
? "font-bold text-light-1000 dark:text-dark-1000"
: "font-normal text-light-1000 dark:text-dark-1000"
}`
}
>
{tab.label}
</ListboxOption>
))}
</ListboxOptions>
</div>
</Listbox>
</div>
<div className="hidden sm:block">
<div>
<nav
aria-label="Tabs"
className="-mb-px flex space-x-8 focus:outline-none"
>
{boardsTabs.map((tab) => (
<button
key={tab.key}
type="button"
onClick={() => setActiveTab(tab.key)}
className={`whitespace-nowrap px-1 py-0 mt-2 mb-8 text-sm font-semibold transition-colors focus:outline-none ${activeTab === 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"
}`}
>
{tab.label}
</button>
))}
</nav>
</div>
</div>
</div>
<div className="flex h-full flex-row focus:outline-none">
{activeTab === "boards" && (
<BoardsList isTemplate={false} archived={false} />
)}
{activeTab === "archived" && (
<BoardsList isTemplate={false} archived={true} />
)}
</div>
</div>
) : (
<div className="flex h-full flex-row">
<BoardsList isTemplate={!!isTemplate} />
</div>
)}
</div>
</>
);