mirror of
https://github.com/docmost/docmost.git
synced 2026-07-15 03:46:47 +10:00
kanban - wip
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
+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 { 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>
|
||||
);
|
||||
}
|
||||
|
||||
+1
-1
Submodule apps/server/src/ee updated: 9e5f64d95d...9b83049b6a
Reference in New Issue
Block a user