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);
+}