kanban - wip

This commit is contained in:
Philipinho
2026-05-28 17:03:52 +01:00
parent 6935a73e4f
commit 237bbe1f6c
5 changed files with 521 additions and 78 deletions
@@ -1,21 +1,36 @@
import { useState, useCallback } from "react";
import { Group, Text } from "@mantine/core";
import { IBaseProperty, IBaseRow } from "@/features/base/types/base.types";
import { useState, useCallback, useMemo } from "react";
import clsx from "clsx";
import { IconLock } from "@tabler/icons-react";
import { BasePropertyType, IBaseProperty, IBaseRow } from "@/features/base/types/base.types";
import { CellRenderer } from "@/features/base/components/cells/cell-renderer";
import { propertyTypes } from "@/features/base/components/property/property-type-picker";
import classes from "./row-detail-modal.module.css";
type PropertyRowProps = {
property: IBaseProperty;
row: IBaseRow;
canEdit: boolean;
onUpdate: (propertyId: string, value: unknown) => void;
};
export function PropertyRow({ property, row, onUpdate }: PropertyRowProps) {
// Cell types that are derived/read-only — clicking shouldn't switch them
// into edit mode and the row gets a tiny lock glyph in front of the value.
const READONLY_TYPES = new Set<BasePropertyType>([
"formula",
"createdAt",
"lastEditedAt",
"lastEditedBy",
]);
const ICON_BY_TYPE = new Map(propertyTypes.map((p) => [p.type, p.icon] as const));
export function PropertyRow({ property, row, canEdit, onUpdate }: PropertyRowProps) {
const value = (row.cells ?? {})[property.id];
// The cell components key their edit state off `isEditing`. In the
// modal we treat the cell as always-active: click to commit a new
// value, blur/escape to commit-or-cancel the same way the grid does.
const [editing, setEditing] = useState(false);
const isReadonlyType = READONLY_TYPES.has(property.type);
const interactive = canEdit && !isReadonlyType;
const handleCommit = useCallback(
(next: unknown) => {
setEditing(false);
@@ -25,29 +40,54 @@ export function PropertyRow({ property, row, onUpdate }: PropertyRowProps) {
);
const handleCancel = useCallback(() => setEditing(false), []);
// Activate on `mousedown`, not `click`. Mantine's `useClickOutside`
// (the one wired up by every child `Popover`) also fires on mousedown,
// and React batches setState calls within the same DOM event. By
// riding the same event we get:
// 1. mousedown on a wrapper whose child Popover is open →
// useClickOutside calls setEditing(false), our handler reads the
// same render's `editing === true` so it bails on `!editing` and
// doesn't queue setEditing(true). React flushes → popover closes.
// 2. mousedown on a non-editing wrapper → `!editing` is true →
// setEditing(true) → popover opens.
// Using `onClick` would split steps 1a/1b across two DOM events; React
// would re-render between them with the popover closed, my click
// closure would then see `editing === false`, and the popover would
// re-open on the same gesture that was meant to dismiss it.
const handleActivate = useCallback(() => {
if (interactive && !editing) setEditing(true);
}, [interactive, editing]);
const Icon = useMemo(() => ICON_BY_TYPE.get(property.type), [property.type]);
return (
<Group
align="flex-start"
wrap="nowrap"
gap="md"
onClick={() => setEditing(true)}
style={{ cursor: "text" }}
>
<div style={{ width: 140, flex: "0 0 140px", paddingTop: 6 }}>
<Text size="sm" c="dimmed">
{property.name}
</Text>
<div className={classes.propertyRow}>
<div className={classes.propertyLabel}>
{Icon && <Icon size={15} className={classes.propertyLabelIcon} />}
<span className={classes.propertyLabelText}>{property.name}</span>
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<CellRenderer
property={property}
rowId={row.id}
value={value}
isEditing={editing}
onCommit={handleCommit}
onCancel={handleCancel}
/>
<div
className={clsx(classes.propertyValueWrap, {
[classes.editing]: editing,
[classes.locked]: isReadonlyType,
[classes.readOnlyCell]: !canEdit,
})}
onMouseDown={handleActivate}
>
{isReadonlyType && (
<IconLock size={13} className={classes.lockIcon} />
)}
<div className={classes.valueInner}>
<CellRenderer
property={property}
rowId={row.id}
value={value}
isEditing={editing}
onCommit={handleCommit}
onCancel={handleCancel}
/>
</div>
</div>
</Group>
</div>
);
}
@@ -0,0 +1,281 @@
/* ---------- Modal shell ---------- */
.modalContent {
display: flex;
flex-direction: column;
max-height: min(86vh, 820px);
background-color: light-dark(var(--mantine-color-white), var(--mantine-color-dark-7));
}
.closeButtonWrap {
position: absolute;
top: 14px;
right: 18px;
z-index: 2;
display: flex;
align-items: center;
gap: 2px;
}
.iconButton {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
border-radius: 6px;
background: transparent;
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
cursor: pointer;
transition: background-color 100ms ease, color 100ms ease;
}
.iconButton:hover {
background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5));
color: light-dark(var(--mantine-color-gray-9), var(--mantine-color-dark-0));
}
/* ---------- Header ---------- */
.header {
padding: 32px 36px 4px;
flex-shrink: 0;
}
.titleInput {
width: 100%;
border: none;
outline: none;
background: transparent;
padding: 0;
margin: 0;
font-family: inherit;
font-size: 28px;
font-weight: 700;
line-height: 1.2;
letter-spacing: -0.012em;
color: light-dark(var(--mantine-color-gray-9), var(--mantine-color-dark-0));
}
.titleInput::placeholder {
color: light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-3));
font-weight: 700;
}
.titleStatic {
font-size: 28px;
font-weight: 700;
line-height: 1.2;
letter-spacing: -0.012em;
color: light-dark(var(--mantine-color-gray-9), var(--mantine-color-dark-0));
word-break: break-word;
}
.metaRow {
display: flex;
align-items: center;
gap: 10px;
margin-top: 10px;
font-size: 12.5px;
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
flex-wrap: wrap;
}
.metaDot {
width: 2.5px;
height: 2.5px;
border-radius: 50%;
background-color: light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-3));
flex-shrink: 0;
}
/* ---------- Body / properties list ---------- */
.body {
padding: 24px 36px 16px;
overflow-y: auto;
flex: 1;
min-height: 0;
}
.propertyList {
display: flex;
flex-direction: column;
gap: 2px;
}
.propertyRow {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 6px 0;
border-radius: 8px;
transition: background-color 80ms ease;
}
.propertyLabel {
display: flex;
align-items: center;
gap: 10px;
width: 168px;
flex: 0 0 168px;
height: 34px;
padding-left: 4px;
font-size: 13.5px;
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
}
.propertyLabelIcon {
flex-shrink: 0;
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-2));
}
.propertyLabelText {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.propertyValueWrap {
flex: 1;
min-width: 0;
min-height: 34px;
display: flex;
align-items: center;
padding: 4px 10px;
border: 1px solid transparent;
border-radius: 7px;
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
transition: background-color 100ms ease, border-color 100ms ease, box-shadow 100ms ease;
cursor: text;
}
.propertyValueWrap:hover {
background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5));
}
.propertyValueWrap.editing {
background-color: light-dark(var(--mantine-color-white), var(--mantine-color-dark-7));
border-color: light-dark(var(--mantine-color-blue-5), var(--mantine-color-blue-5));
box-shadow: 0 0 0 3px light-dark(
rgba(34, 139, 230, 0.12),
rgba(34, 139, 230, 0.22)
);
}
.propertyValueWrap.readOnlyCell {
cursor: default;
background-color: transparent;
padding-left: 4px;
}
.propertyValueWrap.readOnlyCell:hover {
background-color: transparent;
}
.propertyValueWrap.locked {
cursor: default;
}
.lockIcon {
flex-shrink: 0;
margin-right: 6px;
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
}
.valueInner {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
}
/* Trim Mantine cell paddings inside the modal value pill — the wrapper
already provides the horizontal padding. Without this, text/numbers
would sit 8px to the right of where they should. */
.valueInner :global(.mantine-Input-input),
.valueInner input.cellInput,
.valueInner input[type="text"],
.valueInner input[type="number"] {
padding-left: 0;
padding-right: 0;
background: transparent;
}
/* Add property button */
.addPropertyWrap {
margin-top: 12px;
padding-left: 4px;
}
/* ---------- Footer ---------- */
.footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 36px 16px;
border-top: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
font-size: 12px;
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
flex-shrink: 0;
min-height: 44px;
}
.footerStatus {
display: flex;
align-items: center;
gap: 8px;
}
.savingDot {
width: 6px;
height: 6px;
border-radius: 50%;
background-color: var(--mantine-color-yellow-5);
box-shadow: 0 0 0 0 rgba(250, 176, 5, 0.5);
animation: savingPulse 1.4s ease-in-out infinite;
}
@keyframes savingPulse {
0%, 100% {
opacity: 0.6;
box-shadow: 0 0 0 0 rgba(250, 176, 5, 0.45);
}
50% {
opacity: 1;
box-shadow: 0 0 0 4px rgba(250, 176, 5, 0);
}
}
.lockedHint {
display: inline-flex;
align-items: center;
gap: 6px;
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
}
.kbdHint {
display: inline-flex;
align-items: center;
gap: 6px;
}
.kbd {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 26px;
height: 20px;
padding: 0 6px;
border-radius: 4px;
border: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
background-color: light-dark(var(--mantine-color-white), var(--mantine-color-dark-6));
font-family:
ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
font-size: 11px;
font-weight: 500;
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
box-shadow: 0 1px 0 light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5));
}
@@ -1,6 +1,8 @@
import { Modal, Stack, Text } from "@mantine/core";
import { Modal, Text } from "@mantine/core";
import { useWindowEvent } from "@mantine/hooks";
import { IconDotsVertical, IconX, IconLock } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { useEffect, useMemo } from "react";
import { useCallback, useEffect, useMemo } from "react";
import {
IBase,
IBaseRow,
@@ -9,11 +11,13 @@ import { useUpdateRowMutation } from "@/features/base/queries/base-row-query";
import { CreatePropertyPopover } from "@/features/base/components/property/create-property-popover";
import { RowDetailTitle } from "./row-detail-title";
import { PropertyRow } from "./property-row";
import classes from "./row-detail-modal.module.css";
type RowDetailModalProps = {
base: IBase;
rows: IBaseRow[];
openRowId: string | null;
canEdit: boolean;
onClose: () => void;
};
@@ -21,6 +25,7 @@ export function RowDetailModal({
base,
rows,
openRowId,
canEdit,
onClose,
}: RowDetailModalProps) {
const { t } = useTranslation();
@@ -42,20 +47,77 @@ export function RowDetailModal({
if (wasOpen) onClose();
}, [wasOpen, onClose]);
const isSaving = updateRowMutation.isPending;
// Esc handling: Mantine v8 Modal's built-in `closeOnEscape` runs a
// *capture-phase* `window` keydown listener (see
// `@mantine/core/.../use-modal.mjs`), which fires before any inner
// popover or cell input gets the keypress. The result: pressing Esc
// inside an open cell popover (or any editable cell) would close the
// whole modal instead of dismissing the popover.
//
// We turn the Modal's listener off and run our own, which yields to
// anything that's currently editing: an open Mantine Popover dropdown
// (it carries the `data-position` attribute Mantine sets on every
// popover) or a native editable element (input, textarea,
// contenteditable). Only when nothing inner claims Esc do we close
// the modal.
const opened = !!row;
const handleEscape = useCallback(
(event: KeyboardEvent) => {
if (event.key !== "Escape" || event.isComposing || !opened) return;
const target = event.target as HTMLElement | null;
if (target) {
if (
target.closest("[data-position]") ||
target.matches("input, textarea, select, [contenteditable='true']")
) {
return;
}
}
onClose();
},
[opened, onClose],
);
useWindowEvent("keydown", handleEscape, { capture: true });
return (
<Modal
opened={!!row}
opened={opened}
onClose={onClose}
size="lg"
withCloseButton
centered
withCloseButton={false}
closeOnEscape={false}
padding={0}
radius="md"
title={null}
classNames={{ content: classes.modalContent }}
>
{row ? (
<Stack gap="md">
<>
<div className={classes.closeButtonWrap}>
<button
type="button"
className={classes.iconButton}
aria-label={t("More")}
>
<IconDotsVertical size={16} />
</button>
<button
type="button"
className={classes.iconButton}
onClick={onClose}
aria-label={t("Close")}
>
<IconX size={16} />
</button>
</div>
<RowDetailTitle
row={row}
primaryProperty={primaryProperty}
canEdit={canEdit}
onCommit={(value) => {
if (!primaryProperty) return;
updateRowMutation.mutate({
@@ -65,33 +127,67 @@ export function RowDetailModal({
});
}}
/>
{base.properties
.filter((p) => !p.isPrimary)
.map((property) => (
<PropertyRow
key={property.id}
property={property}
row={row}
onUpdate={(propertyId, value) => {
updateRowMutation.mutate({
rowId: row.id,
pageId: base.id,
cells: { [propertyId]: value },
});
}}
/>
))}
<CreatePropertyPopover
pageId={base.id}
properties={base.properties}
onPropertyCreated={() => {
// The base query invalidates on success — the new property will
// appear as a new <PropertyRow /> on next render. Nothing else to do.
}}
/>
</Stack>
<div className={classes.body}>
<div className={classes.propertyList}>
{base.properties
.filter((p) => !p.isPrimary)
.map((property) => (
<PropertyRow
key={property.id}
property={property}
row={row}
canEdit={canEdit}
onUpdate={(propertyId, value) => {
updateRowMutation.mutate({
rowId: row.id,
pageId: base.id,
cells: { [propertyId]: value },
});
}}
/>
))}
</div>
{canEdit && (
<div className={classes.addPropertyWrap}>
<CreatePropertyPopover
pageId={base.id}
properties={base.properties}
onPropertyCreated={() => {
// The base query invalidates on success — the new
// property will appear as a new <PropertyRow /> on
// next render. Nothing else to do.
}}
/>
</div>
)}
</div>
<footer className={classes.footer}>
<div className={classes.footerStatus}>
{!canEdit ? (
<span className={classes.lockedHint}>
<IconLock size={12} />
{t("Read-only")}
</span>
) : isSaving ? (
<>
<span className={classes.savingDot} />
<span>{t("Saving…")}</span>
</>
) : null}
</div>
<div className={classes.kbdHint}>
<span>{t("Press")}</span>
<kbd className={classes.kbd}>Esc</kbd>
<span>{t("to close")}</span>
</div>
</footer>
</>
) : (
<Text c="dimmed">{t("Loading…")}</Text>
<Text c="dimmed" p="md">
{t("Loading…")}
</Text>
)}
</Modal>
);
@@ -1,17 +1,20 @@
import { useEffect, useState } from "react";
import { TextInput } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { IBaseProperty, IBaseRow } from "@/features/base/types/base.types";
import { timeAgo } from "@/lib/time.ts";
import classes from "./row-detail-modal.module.css";
type RowDetailTitleProps = {
row: IBaseRow;
primaryProperty: IBaseProperty | undefined;
canEdit: boolean;
onCommit: (value: string) => void;
};
export function RowDetailTitle({
row,
primaryProperty,
canEdit,
onCommit,
}: RowDetailTitleProps) {
const { t } = useTranslation();
@@ -25,23 +28,46 @@ export function RowDetailTitle({
setValue(initial);
}, [initial]);
const updatedAgo = row.updatedAt ? timeAgo(new Date(row.updatedAt)) : "";
// UUID7-derived display token: the last 4 hex chars are the random
// tail of the UUID, so they distinguish rows that were created close
// together better than the time prefix would.
const idToken = row.id
? `#${row.id.replace(/-/g, "").slice(-4).toUpperCase()}`
: "";
return (
<TextInput
autoFocus
placeholder={t("Untitled")}
value={value}
variant="unstyled"
size="xl"
onChange={(e) => setValue(e.currentTarget.value)}
onBlur={() => {
if (value !== initial) onCommit(value);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
(e.currentTarget as HTMLInputElement).blur();
}
}}
/>
<header className={classes.header}>
{canEdit ? (
<input
autoFocus
type="text"
className={classes.titleInput}
placeholder={t("Untitled")}
value={value}
onChange={(e) => setValue(e.currentTarget.value)}
onBlur={() => {
if (value !== initial) onCommit(value);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
(e.currentTarget as HTMLInputElement).blur();
}
}}
/>
) : (
<h1 className={classes.titleStatic}>
{value || t("Untitled")}
</h1>
)}
<div className={classes.metaRow}>
{updatedAgo && (
<span>{t("Updated {{when}}", { when: updatedAgo })}</span>
)}
{updatedAgo && idToken && <span className={classes.metaDot} />}
{idToken && <span>{idToken}</span>}
</div>
</header>
);
}