mirror of
https://github.com/docmost/docmost.git
synced 2026-08-30 02:25:01 +08:00
kanban - wip
This commit is contained in:
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+281
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
Submodule apps/server/src/ee updated: 9e5f64d95d...9b83049b6a
Reference in New Issue
Block a user