From 68cdcd970cb09d6e8ec05b9fc54fe5fe962a3317 Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Sun, 24 May 2026 13:27:25 +0100 Subject: [PATCH] feat(bases): scaffold kanban renderer (no DnD yet) --- .../components/views/kanban/base-kanban.tsx | 56 ++++++++++++ .../components/views/kanban/kanban-card.tsx | 42 +++++++++ .../views/kanban/kanban-column-header.tsx | 31 +++++++ .../components/views/kanban/kanban-column.tsx | 37 ++++++++ .../features/base/styles/kanban.module.css | 89 +++++++++++++++++++ 5 files changed, 255 insertions(+) create mode 100644 apps/client/src/features/base/components/views/kanban/base-kanban.tsx create mode 100644 apps/client/src/features/base/components/views/kanban/kanban-card.tsx create mode 100644 apps/client/src/features/base/components/views/kanban/kanban-column-header.tsx create mode 100644 apps/client/src/features/base/components/views/kanban/kanban-column.tsx create mode 100644 apps/client/src/features/base/styles/kanban.module.css diff --git a/apps/client/src/features/base/components/views/kanban/base-kanban.tsx b/apps/client/src/features/base/components/views/kanban/base-kanban.tsx new file mode 100644 index 000000000..cd76ed92c --- /dev/null +++ b/apps/client/src/features/base/components/views/kanban/base-kanban.tsx @@ -0,0 +1,56 @@ +import { useMemo } from "react"; +import { + IBase, + IBaseRow, + IBaseView, +} from "@/features/base/types/base.types"; +import { useKanbanGroups } from "@/features/base/hooks/use-kanban-groups"; +import { KanbanColumn } from "./kanban-column"; +import classes from "@/features/base/styles/kanban.module.css"; + +type BaseKanbanProps = { + base: IBase; + rows: IBaseRow[]; + effectiveView: IBaseView | undefined; + onCardClick: (rowId: string) => void; +}; + +export function BaseKanban({ + base, + rows, + effectiveView, + onCardClick, +}: BaseKanbanProps) { + const groupByPropertyId = effectiveView?.config?.groupByPropertyId; + const property = useMemo( + () => + groupByPropertyId + ? base.properties.find((p) => p.id === groupByPropertyId) + : undefined, + [groupByPropertyId, base.properties], + ); + const primaryProperty = useMemo( + () => base.properties.find((p) => p.isPrimary), + [base.properties], + ); + const isGroupable = property?.type === "select" || property?.type === "status"; + const { columns } = useKanbanGroups( + rows, + isGroupable ? property : undefined, + effectiveView?.config?.hiddenChoiceIds, + effectiveView?.config?.choiceOrder, + ); + + return ( +
+ {columns.map((column) => ( + + ))} +
+ ); +} diff --git a/apps/client/src/features/base/components/views/kanban/kanban-card.tsx b/apps/client/src/features/base/components/views/kanban/kanban-card.tsx new file mode 100644 index 000000000..dfaa12dc9 --- /dev/null +++ b/apps/client/src/features/base/components/views/kanban/kanban-card.tsx @@ -0,0 +1,42 @@ +import { IBaseProperty, IBaseRow } from "@/features/base/types/base.types"; +import classes from "@/features/base/styles/kanban.module.css"; +import { useTranslation } from "react-i18next"; + +type KanbanCardProps = { + row: IBaseRow; + primaryProperty: IBaseProperty | undefined; + onClick: (rowId: string) => void; +}; + +export function KanbanCard({ row, primaryProperty, onClick }: KanbanCardProps) { + const { t } = useTranslation(); + const titleValue = + primaryProperty + ? ((row.cells ?? {})[primaryProperty.id] as string | undefined) + : undefined; + const titleText = titleValue?.trim().length ? titleValue : t("Untitled"); + const isEmpty = !titleValue?.trim().length; + + return ( +
onClick(row.id)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onClick(row.id); + } + }} + > +
+ {titleText} +
+
+ ); +} diff --git a/apps/client/src/features/base/components/views/kanban/kanban-column-header.tsx b/apps/client/src/features/base/components/views/kanban/kanban-column-header.tsx new file mode 100644 index 000000000..cb3d7d35e --- /dev/null +++ b/apps/client/src/features/base/components/views/kanban/kanban-column-header.tsx @@ -0,0 +1,31 @@ +import { Badge, Text } from "@mantine/core"; +import classes from "@/features/base/styles/kanban.module.css"; + +type KanbanColumnHeaderProps = { + name: string; + color: string | null; + count: number; +}; + +export function KanbanColumnHeader({ + name, + color, + count, +}: KanbanColumnHeaderProps) { + return ( +
+
+ {color ? ( + + {name} + + ) : ( + + {name} + + )} + {count} +
+
+ ); +} diff --git a/apps/client/src/features/base/components/views/kanban/kanban-column.tsx b/apps/client/src/features/base/components/views/kanban/kanban-column.tsx new file mode 100644 index 000000000..4d5b0ec2d --- /dev/null +++ b/apps/client/src/features/base/components/views/kanban/kanban-column.tsx @@ -0,0 +1,37 @@ +import { KanbanColumn as KanbanColumnData } from "@/features/base/hooks/use-kanban-groups"; +import { IBaseProperty } from "@/features/base/types/base.types"; +import { KanbanCard } from "./kanban-card"; +import { KanbanColumnHeader } from "./kanban-column-header"; +import classes from "@/features/base/styles/kanban.module.css"; + +type KanbanColumnProps = { + column: KanbanColumnData; + primaryProperty: IBaseProperty | undefined; + onCardClick: (rowId: string) => void; +}; + +export function KanbanColumn({ + column, + primaryProperty, + onCardClick, +}: KanbanColumnProps) { + return ( +
+ +
+ {column.rows.map((row) => ( + + ))} +
+
+ ); +} diff --git a/apps/client/src/features/base/styles/kanban.module.css b/apps/client/src/features/base/styles/kanban.module.css new file mode 100644 index 000000000..f3261f695 --- /dev/null +++ b/apps/client/src/features/base/styles/kanban.module.css @@ -0,0 +1,89 @@ +.board { + display: flex; + flex-direction: row; + gap: 12px; + padding: 12px; + overflow-x: auto; + overflow-y: hidden; + height: 100%; + min-height: 0; + align-items: stretch; +} + +.column { + display: flex; + flex-direction: column; + flex: 0 0 280px; + width: 280px; + background: var(--mantine-color-default-hover); + border-radius: 8px; + min-height: 0; +} + +.columnHeader { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + border-bottom: 1px solid var(--mantine-color-default-border); + position: relative; /* anchor for BaseDropEdgeIndicator on column reorder */ +} + +.columnHeaderLeft { + display: flex; + align-items: center; + gap: 8px; + flex: 1; + min-width: 0; +} + +.columnCount { + font-size: 12px; + color: var(--mantine-color-dimmed); +} + +.columnBody { + flex: 1 1 auto; + overflow-y: auto; + padding: 8px; + display: flex; + flex-direction: column; + gap: 8px; + min-height: 80px; +} + +.card { + padding: 10px 12px; + background: var(--mantine-color-body); + border: 1px solid var(--mantine-color-default-border); + border-radius: 6px; + cursor: pointer; + user-select: none; + position: relative; +} + +.card[data-dragging="true"] { + opacity: 0.4; +} + +.cardTitle { + font-size: 14px; + line-height: 1.4; + word-break: break-word; +} + +.cardTitleEmpty { + color: var(--mantine-color-dimmed); +} + +.addCardButton { + margin-top: 4px; + align-self: stretch; + justify-content: flex-start; +} + +.sortHint { + padding: 6px 12px; + font-size: 12px; + color: var(--mantine-color-dimmed); +}