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 { useState, useCallback, useMemo } from "react";
import { Group, Text } from "@mantine/core"; import clsx from "clsx";
import { IBaseProperty, IBaseRow } from "@/features/base/types/base.types"; 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 { 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 = { type PropertyRowProps = {
property: IBaseProperty; property: IBaseProperty;
row: IBaseRow; row: IBaseRow;
canEdit: boolean;
onUpdate: (propertyId: string, value: unknown) => void; 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]; 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 [editing, setEditing] = useState(false);
const isReadonlyType = READONLY_TYPES.has(property.type);
const interactive = canEdit && !isReadonlyType;
const handleCommit = useCallback( const handleCommit = useCallback(
(next: unknown) => { (next: unknown) => {
setEditing(false); setEditing(false);
@@ -25,29 +40,54 @@ export function PropertyRow({ property, row, onUpdate }: PropertyRowProps) {
); );
const handleCancel = useCallback(() => setEditing(false), []); 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 ( return (
<Group <div className={classes.propertyRow}>
align="flex-start" <div className={classes.propertyLabel}>
wrap="nowrap" {Icon && <Icon size={15} className={classes.propertyLabelIcon} />}
gap="md" <span className={classes.propertyLabelText}>{property.name}</span>
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> </div>
<div style={{ flex: 1, minWidth: 0 }}> <div
<CellRenderer className={clsx(classes.propertyValueWrap, {
property={property} [classes.editing]: editing,
rowId={row.id} [classes.locked]: isReadonlyType,
value={value} [classes.readOnlyCell]: !canEdit,
isEditing={editing} })}
onCommit={handleCommit} onMouseDown={handleActivate}
onCancel={handleCancel} >
/> {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> </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 { useTranslation } from "react-i18next";
import { useEffect, useMemo } from "react"; import { useCallback, useEffect, useMemo } from "react";
import { import {
IBase, IBase,
IBaseRow, 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 { CreatePropertyPopover } from "@/features/base/components/property/create-property-popover";
import { RowDetailTitle } from "./row-detail-title"; import { RowDetailTitle } from "./row-detail-title";
import { PropertyRow } from "./property-row"; import { PropertyRow } from "./property-row";
import classes from "./row-detail-modal.module.css";
type RowDetailModalProps = { type RowDetailModalProps = {
base: IBase; base: IBase;
rows: IBaseRow[]; rows: IBaseRow[];
openRowId: string | null; openRowId: string | null;
canEdit: boolean;
onClose: () => void; onClose: () => void;
}; };
@@ -21,6 +25,7 @@ export function RowDetailModal({
base, base,
rows, rows,
openRowId, openRowId,
canEdit,
onClose, onClose,
}: RowDetailModalProps) { }: RowDetailModalProps) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -42,20 +47,77 @@ export function RowDetailModal({
if (wasOpen) onClose(); if (wasOpen) onClose();
}, [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 ( return (
<Modal <Modal
opened={!!row} opened={opened}
onClose={onClose} onClose={onClose}
size="lg" size="lg"
withCloseButton
centered centered
withCloseButton={false}
closeOnEscape={false}
padding={0}
radius="md"
title={null} title={null}
classNames={{ content: classes.modalContent }}
> >
{row ? ( {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 <RowDetailTitle
row={row} row={row}
primaryProperty={primaryProperty} primaryProperty={primaryProperty}
canEdit={canEdit}
onCommit={(value) => { onCommit={(value) => {
if (!primaryProperty) return; if (!primaryProperty) return;
updateRowMutation.mutate({ updateRowMutation.mutate({
@@ -65,33 +127,67 @@ export function RowDetailModal({
}); });
}} }}
/> />
{base.properties
.filter((p) => !p.isPrimary) <div className={classes.body}>
.map((property) => ( <div className={classes.propertyList}>
<PropertyRow {base.properties
key={property.id} .filter((p) => !p.isPrimary)
property={property} .map((property) => (
row={row} <PropertyRow
onUpdate={(propertyId, value) => { key={property.id}
updateRowMutation.mutate({ property={property}
rowId: row.id, row={row}
pageId: base.id, canEdit={canEdit}
cells: { [propertyId]: value }, 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 </div>
// appear as a new <PropertyRow /> on next render. Nothing else to do. {canEdit && (
}} <div className={classes.addPropertyWrap}>
/> <CreatePropertyPopover
</Stack> 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> </Modal>
); );
@@ -1,17 +1,20 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { TextInput } from "@mantine/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { IBaseProperty, IBaseRow } from "@/features/base/types/base.types"; 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 = { type RowDetailTitleProps = {
row: IBaseRow; row: IBaseRow;
primaryProperty: IBaseProperty | undefined; primaryProperty: IBaseProperty | undefined;
canEdit: boolean;
onCommit: (value: string) => void; onCommit: (value: string) => void;
}; };
export function RowDetailTitle({ export function RowDetailTitle({
row, row,
primaryProperty, primaryProperty,
canEdit,
onCommit, onCommit,
}: RowDetailTitleProps) { }: RowDetailTitleProps) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -25,23 +28,46 @@ export function RowDetailTitle({
setValue(initial); setValue(initial);
}, [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 ( return (
<TextInput <header className={classes.header}>
autoFocus {canEdit ? (
placeholder={t("Untitled")} <input
value={value} autoFocus
variant="unstyled" type="text"
size="xl" className={classes.titleInput}
onChange={(e) => setValue(e.currentTarget.value)} placeholder={t("Untitled")}
onBlur={() => { value={value}
if (value !== initial) onCommit(value); onChange={(e) => setValue(e.currentTarget.value)}
}} onBlur={() => {
onKeyDown={(e) => { if (value !== initial) onCommit(value);
if (e.key === "Enter") { }}
e.preventDefault(); onKeyDown={(e) => {
(e.currentTarget as HTMLInputElement).blur(); 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>
); );
} }