@anchrd/intel-ui 0.25.0 → 0.29.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/app/app-tree/app-tree.tsx +90 -40
- package/src/app/tree-move/tree-move.tsx +119 -22
- package/src/app/view-toggle/view-toggle.tsx +1 -1
- package/src/archive/archive.tsx +7 -6
- package/src/data/intel-data-provider/intel-data-provider.ts +4 -0
- package/src/data/intel-data-provider/intel-data-provider.types.ts +2 -0
- package/src/flow-runs/flow-runs.tsx +5 -5
- package/src/flows/flows.tsx +50 -102
- package/src/folder-contents/folder-contents.tsx +8 -8
- package/src/i18n/de.json +9 -5
- package/src/i18n/en.json +9 -5
- package/src/i18n/es.json +10 -6
- package/src/import-bundle/import-bundle.tsx +5 -2
- package/src/main.tsx +6 -3
- package/src/node-editor/node-editor.tsx +4 -11
- package/src/node-table/node-table.tsx +3 -2
- package/src/nodes/nodes.tsx +5 -78
- package/src/resource-menu/resource-menu.tsx +92 -37
- package/src/save-button/save-button.tsx +12 -31
- package/src/time/relative-time.tsx +41 -0
- package/src/time/time-context.tsx +7 -1
- package/src/time/time.ts +25 -0
- package/src/title-row/title-row.tsx +124 -4
package/src/nodes/nodes.tsx
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import type { Node } from "@anchrd/intel-contract/node";
|
|
2
2
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
3
3
|
import { useNavigate, useRouterState } from "@tanstack/react-router";
|
|
4
|
-
import { Download,
|
|
5
|
-
import { lazy, Suspense
|
|
4
|
+
import { Download, Paperclip } from "lucide-react";
|
|
5
|
+
import { lazy, Suspense } from "react";
|
|
6
6
|
import { ViewToggle } from "@/app/view-toggle/view-toggle.tsx";
|
|
7
|
-
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
|
8
7
|
import { refusalOf } from "@/data/request-refusal/request-refusal.ts";
|
|
9
8
|
import { FolderContents } from "@/folder-contents/folder-contents.tsx";
|
|
10
9
|
import { GraphPane } from "@/graph-pane/graph-pane.tsx";
|
|
@@ -12,8 +11,7 @@ import { useI18n } from "@/i18n/i18n-context.tsx";
|
|
|
12
11
|
import { NodeTablePanel } from "@/node-table/node-table.tsx";
|
|
13
12
|
import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
14
13
|
import { graphViewFrom, selectedFrom } from "@/router/selection-search.ts";
|
|
15
|
-
import {
|
|
16
|
-
import { TitleRow } from "@/title-row/title-row.tsx";
|
|
14
|
+
import { TitleRow, TitleRowFrame } from "@/title-row/title-row.tsx";
|
|
17
15
|
|
|
18
16
|
const NodeEditor = lazy(async () => ({
|
|
19
17
|
default: (await import("@/node-editor/node-editor.tsx")).NodeEditor,
|
|
@@ -33,7 +31,6 @@ export function Nodes() {
|
|
|
33
31
|
}),
|
|
34
32
|
});
|
|
35
33
|
const selectedId = selection.id;
|
|
36
|
-
const [versionsOpen, setVersionsOpen] = useState(false);
|
|
37
34
|
const document = useQuery({
|
|
38
35
|
queryKey: ["node", selectedId],
|
|
39
36
|
queryFn: () => data.getNode(selectedId ?? ""),
|
|
@@ -53,7 +50,7 @@ export function Nodes() {
|
|
|
53
50
|
enabled: graphable && selection.graph,
|
|
54
51
|
});
|
|
55
52
|
return (
|
|
56
|
-
<
|
|
53
|
+
<TitleRowFrame className="flex h-full min-h-0 flex-col">
|
|
57
54
|
<section className="relative flex min-h-0 min-w-0 flex-1 flex-col bg-card">
|
|
58
55
|
{/* ⚠️ Only the root draws its graph on its own. A folder's graph hangs *under* the folder's
|
|
59
56
|
title line rather than in place of it (#58): the line is the one place its menu lives,
|
|
@@ -122,9 +119,6 @@ export function Nodes() {
|
|
|
122
119
|
document. What the four loose icons used to do is now one menu (#24) — and since #58
|
|
123
120
|
this line is the only place that menu appears at all, for every kind including a
|
|
124
121
|
folder, whose whole screen is this line.
|
|
125
|
-
⚠️ The order of the right-hand group is `TitleRow`'s (#53), not this screen's: the
|
|
126
|
-
version button is handed over as a child and lands before the menu whatever else
|
|
127
|
-
shows up later.
|
|
128
122
|
⚠️ #454: the toggle now stands HERE. It used to stand in the global header, beside
|
|
129
123
|
the search — where what applies everywhere stands. But a graph view does not apply
|
|
130
124
|
everywhere, it is a view onto THIS item. It is only drawn where there is a graph: on
|
|
@@ -135,22 +129,6 @@ export function Nodes() {
|
|
|
135
129
|
target={{ type: "node", node: selected }}
|
|
136
130
|
>
|
|
137
131
|
{graphable && <ViewToggle />}
|
|
138
|
-
{selected.kind !== "folder" && (
|
|
139
|
-
<TooltipProvider delayDuration={300}>
|
|
140
|
-
<Tooltip>
|
|
141
|
-
<TooltipTrigger
|
|
142
|
-
type="button"
|
|
143
|
-
onClick={() => setVersionsOpen((value) => !value)}
|
|
144
|
-
aria-label={i18n.t("node.versions")}
|
|
145
|
-
aria-expanded={versionsOpen}
|
|
146
|
-
className="inline-flex size-8 items-center justify-center rounded-md border bg-background outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring"
|
|
147
|
-
>
|
|
148
|
-
<History aria-hidden="true" className="size-4" />
|
|
149
|
-
</TooltipTrigger>
|
|
150
|
-
<TooltipContent>{i18n.t("node.versions")}</TooltipContent>
|
|
151
|
-
</Tooltip>
|
|
152
|
-
</TooltipProvider>
|
|
153
|
-
)}
|
|
154
132
|
</TitleRow>
|
|
155
133
|
{graphable && selection.graph ? (
|
|
156
134
|
<GraphPane
|
|
@@ -185,13 +163,10 @@ export function Nodes() {
|
|
|
185
163
|
/>
|
|
186
164
|
</Suspense>
|
|
187
165
|
) : null}
|
|
188
|
-
{versionsOpen && selected.kind !== "folder" && (
|
|
189
|
-
<VersionHistory nodeId={selected.id} close={() => setVersionsOpen(false)} />
|
|
190
|
-
)}
|
|
191
166
|
</>
|
|
192
167
|
)}
|
|
193
168
|
</section>
|
|
194
|
-
</
|
|
169
|
+
</TitleRowFrame>
|
|
195
170
|
);
|
|
196
171
|
}
|
|
197
172
|
|
|
@@ -235,51 +210,3 @@ function AttachmentPanel({ node }: { node: Node }) {
|
|
|
235
210
|
</div>
|
|
236
211
|
);
|
|
237
212
|
}
|
|
238
|
-
|
|
239
|
-
function VersionHistory({ nodeId, close }: { nodeId: string; close(): void }) {
|
|
240
|
-
const { data } = useIntelRouterContext();
|
|
241
|
-
const i18n = useI18n();
|
|
242
|
-
const dateTime = useDateTime();
|
|
243
|
-
const versions = useQuery({
|
|
244
|
-
queryKey: ["node-versions", nodeId],
|
|
245
|
-
queryFn: () => data.listNodeVersions(nodeId),
|
|
246
|
-
});
|
|
247
|
-
return (
|
|
248
|
-
<aside
|
|
249
|
-
aria-label={i18n.t("node.versions")}
|
|
250
|
-
className="absolute bottom-0 right-0 top-0 z-10 w-80 overflow-y-auto border-l bg-card p-5 shadow-xl"
|
|
251
|
-
>
|
|
252
|
-
<div className="flex items-start justify-between gap-4">
|
|
253
|
-
<h3 className="font-semibold">{i18n.t("node.versions")}</h3>
|
|
254
|
-
<button
|
|
255
|
-
type="button"
|
|
256
|
-
onClick={close}
|
|
257
|
-
aria-label={i18n.t("common.close")}
|
|
258
|
-
className="rounded-md px-2 py-1 text-muted-foreground outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
|
|
259
|
-
>
|
|
260
|
-
×
|
|
261
|
-
</button>
|
|
262
|
-
</div>
|
|
263
|
-
{versions.isPending ? (
|
|
264
|
-
<p className="mt-4 text-sm text-muted-foreground">{i18n.t("common.loading")}</p>
|
|
265
|
-
) : null}
|
|
266
|
-
{versions.isError ? (
|
|
267
|
-
<p role="alert" className="mt-4 text-sm text-destructive">
|
|
268
|
-
{i18n.t("node.operationFailed")}
|
|
269
|
-
</p>
|
|
270
|
-
) : null}
|
|
271
|
-
<ol className="mt-4 space-y-3">
|
|
272
|
-
{versions.data?.items.map((version) => (
|
|
273
|
-
<li key={version.id} className="rounded-md border p-3 text-sm">
|
|
274
|
-
<span className="font-medium">
|
|
275
|
-
{i18n.t("node.version", { sequence: version.sequence })}
|
|
276
|
-
</span>
|
|
277
|
-
<time className="mt-1 block text-xs text-muted-foreground" dateTime={version.createdAt}>
|
|
278
|
-
{dateTime.at(version.createdAt)}
|
|
279
|
-
</time>
|
|
280
|
-
</li>
|
|
281
|
-
))}
|
|
282
|
-
</ol>
|
|
283
|
-
</aside>
|
|
284
|
-
);
|
|
285
|
-
}
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
FileArchive,
|
|
12
12
|
FolderDown,
|
|
13
13
|
FolderUp,
|
|
14
|
+
History,
|
|
14
15
|
Link2,
|
|
15
16
|
Pencil,
|
|
16
17
|
Share2,
|
|
@@ -19,12 +20,11 @@ import {
|
|
|
19
20
|
import type * as React from "react";
|
|
20
21
|
import { useState } from "react";
|
|
21
22
|
import { AccessSummary } from "@/access-summary/access-summary.tsx";
|
|
22
|
-
import { moveErrorKey, useTreeMove } from "@/app/tree-move/tree-move.tsx";
|
|
23
|
+
import { allTreeLevelsKey, moveErrorKey, useTreeMove } from "@/app/tree-move/tree-move.tsx";
|
|
23
24
|
import {
|
|
24
25
|
DropdownMenu,
|
|
25
26
|
DropdownMenuContent,
|
|
26
27
|
DropdownMenuItem,
|
|
27
|
-
DropdownMenuSeparator,
|
|
28
28
|
DropdownMenuSub,
|
|
29
29
|
DropdownMenuSubContent,
|
|
30
30
|
DropdownMenuSubTrigger,
|
|
@@ -37,6 +37,7 @@ import { useBundleImport } from "@/import-bundle/import-bundle.tsx";
|
|
|
37
37
|
import { Modal } from "@/modal/modal.tsx";
|
|
38
38
|
import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
39
39
|
import { selectedFrom } from "@/router/selection-search.ts";
|
|
40
|
+
import { RelativeTime } from "@/time/relative-time.tsx";
|
|
40
41
|
import { useDateTime } from "@/time/time-context.tsx";
|
|
41
42
|
|
|
42
43
|
/**
|
|
@@ -79,10 +80,6 @@ function titleOf(target: ResourceTarget): string {
|
|
|
79
80
|
return target.type === "node" ? target.node.title : target.flow.title;
|
|
80
81
|
}
|
|
81
82
|
|
|
82
|
-
function parentOfTarget(target: ResourceTarget): string | null {
|
|
83
|
-
return target.type === "node" ? target.node.parentId : target.flow.parentId;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
83
|
/**
|
|
87
84
|
* Which sentence explains a verb.
|
|
88
85
|
*
|
|
@@ -142,6 +139,7 @@ export function ResourceMenu({
|
|
|
142
139
|
const [renaming, setRenaming] = useState(false);
|
|
143
140
|
const [sharing, setSharing] = useState(false);
|
|
144
141
|
const [linksOpen, setLinksOpen] = useState(false);
|
|
142
|
+
const [versionsOpen, setVersionsOpen] = useState(false);
|
|
145
143
|
const selected = useRouterState({ select: (state) => selectedFrom(state.location.search) });
|
|
146
144
|
const pathname = useRouterState({ select: (state) => state.location.pathname });
|
|
147
145
|
// ⚠️ Dragging is a pointer gesture and nothing else: no keyboard, no screen reader, no touch worth
|
|
@@ -173,9 +171,14 @@ export function ResourceMenu({
|
|
|
173
171
|
// reads the record, and the flat collections behind the search, the link picker and the relation
|
|
174
172
|
// view all carry the title as well — a rename that only reached the tree would leave yesterday's
|
|
175
173
|
// name standing on three other screens.
|
|
174
|
+
//
|
|
175
|
+
// ⚠️ Every level rather than the one this row sits in: archiving the last row of a folder takes
|
|
176
|
+
// that folder's arrow away, and the arrow is drawn from the folder's own row one level higher
|
|
177
|
+
// (`allTreeLevelsKey`, #519). Renaming needs no more than the one level — but two invalidations
|
|
178
|
+
// that differ per entry are how one of them goes missing.
|
|
176
179
|
async function refresh(): Promise<void> {
|
|
177
180
|
await Promise.all([
|
|
178
|
-
queryClient.invalidateQueries({ queryKey:
|
|
181
|
+
queryClient.invalidateQueries({ queryKey: allTreeLevelsKey }),
|
|
179
182
|
queryClient.invalidateQueries({
|
|
180
183
|
queryKey: target.type === "flow" ? ["flow", id] : ["node", id],
|
|
181
184
|
}),
|
|
@@ -314,35 +317,26 @@ export function ResourceMenu({
|
|
|
314
317
|
className={
|
|
315
318
|
variant === "row"
|
|
316
319
|
? "inline-flex size-8 shrink-0 items-center justify-center rounded-md text-muted-foreground outline-none transition hover:bg-accent hover:text-accent-foreground focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-ring data-[state=open]:opacity-100 group-focus-within/row:opacity-100 group-hover/row:opacity-100 md:opacity-0"
|
|
317
|
-
: "inline-flex size-8 shrink-0 items-center justify-center rounded-md
|
|
320
|
+
: "inline-flex size-8 shrink-0 items-center justify-center rounded-md bg-muted outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring"
|
|
318
321
|
}
|
|
319
322
|
>
|
|
320
323
|
<Ellipsis aria-hidden="true" className="size-4" />
|
|
321
324
|
</DropdownMenuTrigger>
|
|
322
|
-
<DropdownMenuContent align="start" className="w-
|
|
325
|
+
<DropdownMenuContent align="start" className="min-w-40 whitespace-nowrap">
|
|
323
326
|
<DropdownMenuItem onSelect={() => setRenaming(true)}>
|
|
324
327
|
<Pencil aria-hidden="true" />
|
|
325
328
|
{i18n.t("resource.rename")}
|
|
326
329
|
</DropdownMenuItem>
|
|
327
|
-
{/*
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
readable without the pointer ever touching the row.
|
|
336
|
-
|
|
337
|
-
⚠️ The dialog behind it stays the main path, not the fallback: touch has no
|
|
338
|
-
`dragstart`, and nothing can be dragged by keyboard at all. Dragging is the shortcut
|
|
339
|
-
for the mouse — the hint says exactly that, in the place that is reachable both ways. */}
|
|
340
|
-
<DropdownMenuItem onSelect={() => move.start(entryOf(target), null)}>
|
|
330
|
+
{/* The dialog stays the complete keyboard and touch path. Dragging is only a mouse
|
|
331
|
+
shortcut; it does not need a second label beside this action. */}
|
|
332
|
+
{/* ⚠️ `undefined`, and that is the honest answer rather than a missing one: this menu hangs
|
|
333
|
+
on a resource's own title line, so there is no rendered tree level to name. Passing the
|
|
334
|
+
record's parent instead would claim to know a level — and for a shared row it would
|
|
335
|
+
name the sharer's folder, which is exactly the wrong one (#446). `useTreeMove` reads
|
|
336
|
+
the absence and cleans up both levels such a row can be standing in. */}
|
|
337
|
+
<DropdownMenuItem onSelect={() => move.start(entryOf(target), undefined, null)}>
|
|
341
338
|
<CornerLeftUp aria-hidden="true" />
|
|
342
|
-
{i18n.t("
|
|
343
|
-
<span aria-hidden="true" className="ml-auto text-xs text-muted-foreground">
|
|
344
|
-
{i18n.t("tree.move.hint")}
|
|
345
|
-
</span>
|
|
339
|
+
{i18n.t("resource.move")}
|
|
346
340
|
</DropdownMenuItem>
|
|
347
341
|
{/* Every kind exports: a folder takes its subtree along, everything else is a bundle of
|
|
348
342
|
one (#136). The entry sits with rename and move because it acts on the whole thing,
|
|
@@ -356,7 +350,7 @@ export function ResourceMenu({
|
|
|
356
350
|
{isTable ? (
|
|
357
351
|
<DropdownMenuItem onSelect={() => downloadCsv.mutate()}>
|
|
358
352
|
<Download aria-hidden="true" />
|
|
359
|
-
{i18n.t("
|
|
353
|
+
{i18n.t("resource.downloadCsv")}
|
|
360
354
|
</DropdownMenuItem>
|
|
361
355
|
) : null}
|
|
362
356
|
{/* The other half of the same round trip, next to it rather than in the tree's plus
|
|
@@ -384,14 +378,13 @@ export function ResourceMenu({
|
|
|
384
378
|
{node === null ? (
|
|
385
379
|
<DropdownMenuItem onSelect={() => validate.mutate()}>
|
|
386
380
|
<ShieldCheck aria-hidden="true" />
|
|
387
|
-
{i18n.t("
|
|
381
|
+
{i18n.t("resource.validate")}
|
|
388
382
|
</DropdownMenuItem>
|
|
389
383
|
) : null}
|
|
390
|
-
{retrievable || linkable || node ? <DropdownMenuSeparator /> : null}
|
|
391
384
|
{linkable ? (
|
|
392
385
|
<DropdownMenuItem onSelect={() => setLinksOpen(true)}>
|
|
393
386
|
<Link2 aria-hidden="true" />
|
|
394
|
-
{i18n.t("
|
|
387
|
+
{i18n.t("resource.links")}
|
|
395
388
|
</DropdownMenuItem>
|
|
396
389
|
) : null}
|
|
397
390
|
{/* A flow has no share of its own: it is reached through the folder it is filed in, and a
|
|
@@ -400,17 +393,19 @@ export function ResourceMenu({
|
|
|
400
393
|
{node ? (
|
|
401
394
|
<DropdownMenuItem onSelect={() => setSharing(true)}>
|
|
402
395
|
<Share2 aria-hidden="true" />
|
|
403
|
-
{i18n.t("
|
|
396
|
+
{i18n.t("resource.share")}
|
|
404
397
|
</DropdownMenuItem>
|
|
405
398
|
) : null}
|
|
399
|
+
{(node === null || node.kind !== "folder") && (
|
|
400
|
+
<DropdownMenuItem onSelect={() => setVersionsOpen(true)}>
|
|
401
|
+
<History aria-hidden="true" />
|
|
402
|
+
{i18n.t("node.versions")}
|
|
403
|
+
</DropdownMenuItem>
|
|
404
|
+
)}
|
|
406
405
|
{/* A flow is archived like a document (#109). It keeps its versions and its runs; what it
|
|
407
406
|
loses is reach — it leaves the tree, cannot be started, and stops resolving as another
|
|
408
407
|
flow's callee. */}
|
|
409
|
-
<
|
|
410
|
-
<DropdownMenuItem
|
|
411
|
-
onSelect={() => archive.mutate()}
|
|
412
|
-
className="text-destructive focus:text-destructive"
|
|
413
|
-
>
|
|
408
|
+
<DropdownMenuItem onSelect={() => archive.mutate()}>
|
|
414
409
|
<Archive aria-hidden="true" />
|
|
415
410
|
{i18n.t("resource.archive")}
|
|
416
411
|
</DropdownMenuItem>
|
|
@@ -497,10 +492,70 @@ export function ResourceMenu({
|
|
|
497
492
|
) : null}
|
|
498
493
|
{sharing && node ? <SharePanel node={node} close={() => setSharing(false)} /> : null}
|
|
499
494
|
{linksOpen && node ? <NodeLinksPanel node={node} close={() => setLinksOpen(false)} /> : null}
|
|
495
|
+
{versionsOpen ? (
|
|
496
|
+
<VersionHistory target={target} close={() => setVersionsOpen(false)} />
|
|
497
|
+
) : null}
|
|
500
498
|
</>
|
|
501
499
|
);
|
|
502
500
|
}
|
|
503
501
|
|
|
502
|
+
function VersionHistory({ target, close }: { target: ResourceTarget; close(): void }) {
|
|
503
|
+
const { data } = useIntelRouterContext();
|
|
504
|
+
const i18n = useI18n();
|
|
505
|
+
const id = idOf(target);
|
|
506
|
+
const versions = useQuery({
|
|
507
|
+
queryKey: [target.type === "flow" ? "flow-versions" : "node-versions", id],
|
|
508
|
+
queryFn: async () => {
|
|
509
|
+
const result =
|
|
510
|
+
target.type === "flow" ? await data.listFlowVersions(id) : await data.listNodeVersions(id);
|
|
511
|
+
return result.items.map((version) => ({
|
|
512
|
+
id: version.id,
|
|
513
|
+
sequence: version.sequence,
|
|
514
|
+
createdAt: version.createdAt,
|
|
515
|
+
}));
|
|
516
|
+
},
|
|
517
|
+
});
|
|
518
|
+
return (
|
|
519
|
+
<aside
|
|
520
|
+
aria-label={i18n.t("node.versions")}
|
|
521
|
+
className="absolute bottom-0 right-0 top-0 z-10 w-80 overflow-y-auto border-l bg-card p-5 shadow-xl"
|
|
522
|
+
>
|
|
523
|
+
<div className="flex items-start justify-between gap-4">
|
|
524
|
+
<h3 className="font-semibold">{i18n.t("node.versions")}</h3>
|
|
525
|
+
<button
|
|
526
|
+
type="button"
|
|
527
|
+
onClick={close}
|
|
528
|
+
aria-label={i18n.t("common.close")}
|
|
529
|
+
className="rounded-md px-2 py-1 text-muted-foreground outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
|
|
530
|
+
>
|
|
531
|
+
×
|
|
532
|
+
</button>
|
|
533
|
+
</div>
|
|
534
|
+
{versions.isPending ? (
|
|
535
|
+
<p className="mt-4 text-sm text-muted-foreground">{i18n.t("common.loading")}</p>
|
|
536
|
+
) : null}
|
|
537
|
+
{versions.isError ? (
|
|
538
|
+
<p role="alert" className="mt-4 text-sm text-destructive">
|
|
539
|
+
{i18n.t(target.type === "flow" ? "flows.operationFailed" : "node.operationFailed")}
|
|
540
|
+
</p>
|
|
541
|
+
) : null}
|
|
542
|
+
<ol className="mt-4 space-y-3">
|
|
543
|
+
{versions.data?.map((version) => (
|
|
544
|
+
<li key={version.id} className="rounded-md border p-3 text-sm">
|
|
545
|
+
<span className="font-medium">
|
|
546
|
+
{i18n.t("node.version", { sequence: version.sequence })}
|
|
547
|
+
</span>
|
|
548
|
+
<RelativeTime
|
|
549
|
+
value={version.createdAt}
|
|
550
|
+
className="mt-1 block text-xs text-muted-foreground"
|
|
551
|
+
/>
|
|
552
|
+
</li>
|
|
553
|
+
))}
|
|
554
|
+
</ol>
|
|
555
|
+
</aside>
|
|
556
|
+
);
|
|
557
|
+
}
|
|
558
|
+
|
|
504
559
|
// One refusal, over the screen rather than in the menu that is already gone by the time it arrives.
|
|
505
560
|
function MenuFailure({ children }: { children: React.ReactNode }) {
|
|
506
561
|
return (
|
|
@@ -8,61 +8,42 @@ import { Modal } from "@/modal/modal.tsx";
|
|
|
8
8
|
* Saving, in the title line beside the name of the thing being saved (#24).
|
|
9
9
|
*
|
|
10
10
|
* ⚠️ Unsaved is a state that must survive colour blindness, so it is never carried by colour alone.
|
|
11
|
-
*
|
|
12
|
-
*
|
|
11
|
+
* The button only exists while there is work to save; it fills, grows a dot and spells the word
|
|
12
|
+
* out. Three signals at once, of which two are readable without seeing any colour at all.
|
|
13
13
|
*
|
|
14
|
-
* ⚠️ The accessible name names the state as well
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* ⚠️ At rest there are TWO states, not one, and `stored` is what tells them apart (#432). A
|
|
19
|
-
* document that has never been written has nothing to save either — and calling that "Saved" is the
|
|
20
|
-
* one sentence an editor must never say untruthfully: whoever reads it closes the tab. The button
|
|
21
|
-
* was already unclickable in that state, which is exactly why it went unnoticed for so long; the
|
|
22
|
-
* tooltip and the accessible name kept claiming a version that does not exist. "Nothing to save"
|
|
23
|
-
* says less and stays true in both readings.
|
|
14
|
+
* ⚠️ The accessible name names the state as well. A screen reader gets neither the fill nor the
|
|
15
|
+
* dot, and saving in progress must not sound like another available action. The visible word stays
|
|
16
|
+
* a prefix of the name, so speech input still reaches the button by what it reads.
|
|
24
17
|
*/
|
|
25
18
|
export function SaveButton({
|
|
26
19
|
dirty,
|
|
27
20
|
saving,
|
|
28
|
-
stored,
|
|
29
21
|
onSave,
|
|
30
22
|
}: {
|
|
31
23
|
dirty: boolean;
|
|
32
24
|
saving: boolean;
|
|
33
|
-
/** Whether what is being edited has content on the server at all. */
|
|
34
|
-
stored: boolean;
|
|
35
25
|
onSave(): void;
|
|
36
26
|
}) {
|
|
37
27
|
const i18n = useI18n();
|
|
38
|
-
|
|
28
|
+
if (!dirty && !saving) return null;
|
|
29
|
+
const label = i18n.t(saving ? "common.saving" : "common.saveDirty");
|
|
39
30
|
return (
|
|
40
31
|
<TooltipProvider delayDuration={300}>
|
|
41
32
|
<Tooltip>
|
|
42
|
-
{/*
|
|
43
|
-
disabled control fires no pointer events — a tooltip hung on it would stay shut in the
|
|
44
|
-
one state where the button carries no words at all. */}
|
|
33
|
+
{/* The wrapper remains the trigger while saving, when the button is deliberately disabled. */}
|
|
45
34
|
<TooltipTrigger asChild>
|
|
46
35
|
<span className="inline-flex">
|
|
47
36
|
<button
|
|
48
37
|
type="button"
|
|
49
|
-
disabled={
|
|
38
|
+
disabled={saving}
|
|
50
39
|
onClick={onSave}
|
|
51
40
|
aria-label={label}
|
|
52
41
|
data-dirty={dirty}
|
|
53
|
-
className=
|
|
54
|
-
dirty
|
|
55
|
-
? "inline-flex h-8 items-center gap-2 rounded-md bg-primary px-2.5 text-sm font-medium text-primary-foreground outline-none hover:bg-primary/90 focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-70"
|
|
56
|
-
: "inline-flex size-8 items-center justify-center rounded-md border bg-background text-muted-foreground outline-none disabled:opacity-60"
|
|
57
|
-
}
|
|
42
|
+
className="inline-flex h-8 items-center gap-2 rounded-md bg-primary px-2.5 text-sm font-medium text-primary-foreground outline-none hover:bg-primary/90 focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-70"
|
|
58
43
|
>
|
|
59
|
-
|
|
60
|
-
<span aria-hidden="true" className="size-1.5 shrink-0 rounded-full bg-current" />
|
|
61
|
-
) : null}
|
|
44
|
+
<span aria-hidden="true" className="size-1.5 shrink-0 rounded-full bg-current" />
|
|
62
45
|
<Save aria-hidden="true" className="size-4 shrink-0" />
|
|
63
|
-
{
|
|
64
|
-
<span>{saving ? i18n.t("common.saving") : i18n.t("common.save")}</span>
|
|
65
|
-
) : null}
|
|
46
|
+
<span>{saving ? i18n.t("common.saving") : i18n.t("common.save")}</span>
|
|
66
47
|
</button>
|
|
67
48
|
</span>
|
|
68
49
|
</TooltipTrigger>
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { createContext, type ReactNode, useContext, useEffect, useState } from "react";
|
|
2
|
+
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
|
3
|
+
import { cn } from "@/lib/utils";
|
|
4
|
+
import { useDateTime } from "./time-context.tsx";
|
|
5
|
+
|
|
6
|
+
const ClockContext = createContext<number | null>(null);
|
|
7
|
+
|
|
8
|
+
export function RelativeClockProvider({ children }: { children: ReactNode }) {
|
|
9
|
+
const [now, setNow] = useState(Date.now);
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
const timer = window.setInterval(() => setNow(Date.now()), 1_000);
|
|
12
|
+
return () => window.clearInterval(timer);
|
|
13
|
+
}, []);
|
|
14
|
+
return <ClockContext value={now}>{children}</ClockContext>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function RelativeTime({ value, className }: { value: string; className?: string }) {
|
|
18
|
+
const clock = useContext(ClockContext);
|
|
19
|
+
const dateTime = useDateTime();
|
|
20
|
+
const now = clock ?? Date.now();
|
|
21
|
+
return (
|
|
22
|
+
<TooltipProvider delayDuration={300}>
|
|
23
|
+
<Tooltip>
|
|
24
|
+
<TooltipTrigger asChild>
|
|
25
|
+
<time
|
|
26
|
+
dateTime={value}
|
|
27
|
+
// biome-ignore lint/a11y/noNoninteractiveTabindex: The exact timestamp tooltip must be keyboard reachable, including inside clickable rows.
|
|
28
|
+
tabIndex={0}
|
|
29
|
+
className={cn(
|
|
30
|
+
"rounded-sm text-left outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
31
|
+
className,
|
|
32
|
+
)}
|
|
33
|
+
>
|
|
34
|
+
{dateTime.relative(value, now)}
|
|
35
|
+
</time>
|
|
36
|
+
</TooltipTrigger>
|
|
37
|
+
<TooltipContent>{dateTime.at(value)}</TooltipContent>
|
|
38
|
+
</Tooltip>
|
|
39
|
+
</TooltipProvider>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
DeviceZone,
|
|
5
5
|
formatDate,
|
|
6
6
|
formatDateTime,
|
|
7
|
+
formatRelativeTime,
|
|
7
8
|
readZoneChoice,
|
|
8
9
|
rememberZoneChoice,
|
|
9
10
|
type TimeZoneStore,
|
|
@@ -58,7 +59,11 @@ export function useTimeZone(): ZoneSelection | null {
|
|
|
58
59
|
* AND zone itself, and the first one that forgets shows the device's zone again — which is the state
|
|
59
60
|
* before #467, only harder to find.
|
|
60
61
|
*/
|
|
61
|
-
export function useDateTime(): {
|
|
62
|
+
export function useDateTime(): {
|
|
63
|
+
at: (value: string) => string;
|
|
64
|
+
on: (value: string) => string;
|
|
65
|
+
relative: (value: string, now: number) => string;
|
|
66
|
+
} {
|
|
62
67
|
const i18n = useI18n();
|
|
63
68
|
const zone = useContext(ZoneContext);
|
|
64
69
|
const choice = zone?.current ?? DeviceZone;
|
|
@@ -66,6 +71,7 @@ export function useDateTime(): { at: (value: string) => string; on: (value: stri
|
|
|
66
71
|
() => ({
|
|
67
72
|
at: (value: string) => formatDateTime(value, i18n.locale, choice),
|
|
68
73
|
on: (value: string) => formatDate(value, i18n.locale, choice),
|
|
74
|
+
relative: (value: string, now: number) => formatRelativeTime(value, i18n.locale, choice, now),
|
|
69
75
|
}),
|
|
70
76
|
[i18n.locale, choice],
|
|
71
77
|
);
|
package/src/time/time.ts
CHANGED
|
@@ -115,6 +115,31 @@ export function formatDate(value: string, locale: string, choice: ZoneChoice): s
|
|
|
115
115
|
return date.toLocaleDateString(locale, { timeZone: resolveZone(choice) });
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
+
const Minute = 60_000;
|
|
119
|
+
const Hour = 60 * Minute;
|
|
120
|
+
const Day = 24 * Hour;
|
|
121
|
+
const Month = 30 * Day;
|
|
122
|
+
|
|
123
|
+
/** A compact list value. Thirty days is the explicit month boundary; calendar labels stay in the tooltip. */
|
|
124
|
+
export function formatRelativeTime(
|
|
125
|
+
value: string,
|
|
126
|
+
locale: string,
|
|
127
|
+
choice: ZoneChoice,
|
|
128
|
+
now: number,
|
|
129
|
+
): string {
|
|
130
|
+
const date = new Date(value);
|
|
131
|
+
const target = date.getTime();
|
|
132
|
+
if (Number.isNaN(target)) return value;
|
|
133
|
+
const difference = target - now;
|
|
134
|
+
const distance = Math.abs(difference);
|
|
135
|
+
const relative = new Intl.RelativeTimeFormat(locale, { numeric: "auto", style: "short" });
|
|
136
|
+
if (distance < Minute) return relative.format(0, "second");
|
|
137
|
+
if (distance < Hour) return relative.format(Math.trunc(difference / Minute), "minute");
|
|
138
|
+
if (distance < Day) return relative.format(Math.trunc(difference / Hour), "hour");
|
|
139
|
+
if (distance < Month) return relative.format(Math.trunc(difference / Day), "day");
|
|
140
|
+
return formatDate(value, locale, choice);
|
|
141
|
+
}
|
|
142
|
+
|
|
118
143
|
// ⚠️ A short curated list rather than `Intl.supportedValuesOf("timeZone")` — that is over 400
|
|
119
144
|
// entries, and a dropdown of 400 rows without a search is not a choice but an imposition. A zone
|
|
120
145
|
// missing here is added once it has been missed three times; until then the device's zone is the
|