diff --git a/apps/client/src/features/base/components/row-detail-modal/property-row.tsx b/apps/client/src/features/base/components/row-detail-modal/property-row.tsx index 0acf10bd8..3a4065713 100644 --- a/apps/client/src/features/base/components/row-detail-modal/property-row.tsx +++ b/apps/client/src/features/base/components/row-detail-modal/property-row.tsx @@ -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([ + "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 ( - setEditing(true)} - style={{ cursor: "text" }} - > -
- - {property.name} - +
+
+ {Icon && } + {property.name}
-
- +
+ {isReadonlyType && ( + + )} +
+ +
- +
); } diff --git a/apps/client/src/features/base/components/row-detail-modal/row-detail-modal.module.css b/apps/client/src/features/base/components/row-detail-modal/row-detail-modal.module.css new file mode 100644 index 000000000..80d48be89 --- /dev/null +++ b/apps/client/src/features/base/components/row-detail-modal/row-detail-modal.module.css @@ -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)); +} diff --git a/apps/client/src/features/base/components/row-detail-modal/row-detail-modal.tsx b/apps/client/src/features/base/components/row-detail-modal/row-detail-modal.tsx index 25186854d..1cdd1815d 100644 --- a/apps/client/src/features/base/components/row-detail-modal/row-detail-modal.tsx +++ b/apps/client/src/features/base/components/row-detail-modal/row-detail-modal.tsx @@ -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 ( {row ? ( - + <> +
+ + +
+ { if (!primaryProperty) return; updateRowMutation.mutate({ @@ -65,33 +127,67 @@ export function RowDetailModal({ }); }} /> - {base.properties - .filter((p) => !p.isPrimary) - .map((property) => ( - { - updateRowMutation.mutate({ - rowId: row.id, - pageId: base.id, - cells: { [propertyId]: value }, - }); - }} - /> - ))} - { - // The base query invalidates on success — the new property will - // appear as a new on next render. Nothing else to do. - }} - /> -
+ +
+
+ {base.properties + .filter((p) => !p.isPrimary) + .map((property) => ( + { + updateRowMutation.mutate({ + rowId: row.id, + pageId: base.id, + cells: { [propertyId]: value }, + }); + }} + /> + ))} +
+ {canEdit && ( +
+ { + // The base query invalidates on success — the new + // property will appear as a new on + // next render. Nothing else to do. + }} + /> +
+ )} +
+ +
+
+ {!canEdit ? ( + + + {t("Read-only")} + + ) : isSaving ? ( + <> + + {t("Saving…")} + + ) : null} +
+
+ {t("Press")} + Esc + {t("to close")} +
+
+ ) : ( - {t("Loading…")} + + {t("Loading…")} + )}
); diff --git a/apps/client/src/features/base/components/row-detail-modal/row-detail-title.tsx b/apps/client/src/features/base/components/row-detail-modal/row-detail-title.tsx index 114d6bfa5..7466b5ab5 100644 --- a/apps/client/src/features/base/components/row-detail-modal/row-detail-title.tsx +++ b/apps/client/src/features/base/components/row-detail-modal/row-detail-title.tsx @@ -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 ( - setValue(e.currentTarget.value)} - onBlur={() => { - if (value !== initial) onCommit(value); - }} - onKeyDown={(e) => { - if (e.key === "Enter") { - e.preventDefault(); - (e.currentTarget as HTMLInputElement).blur(); - } - }} - /> +
+ {canEdit ? ( + setValue(e.currentTarget.value)} + onBlur={() => { + if (value !== initial) onCommit(value); + }} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + (e.currentTarget as HTMLInputElement).blur(); + } + }} + /> + ) : ( +

+ {value || t("Untitled")} +

+ )} +
+ {updatedAgo && ( + {t("Updated {{when}}", { when: updatedAgo })} + )} + {updatedAgo && idToken && } + {idToken && {idToken}} +
+
); } diff --git a/apps/server/src/ee b/apps/server/src/ee index 9e5f64d95..9b83049b6 160000 --- a/apps/server/src/ee +++ b/apps/server/src/ee @@ -1 +1 @@ -Subproject commit 9e5f64d95d679406f160687804b73d9ca245a842 +Subproject commit 9b83049b6ab2efed713015376a9aee344223a76b