@anchrd/intel-ui 0.22.0 → 0.25.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/access-summary/access-summary.tsx +282 -0
- package/src/app/app-tree/app-tree.tsx +47 -8
- package/src/app/app.tsx +7 -38
- package/src/app/settings-dialog/settings-dialog.tsx +38 -5
- package/src/app/user-footer/user-footer.tsx +31 -13
- package/src/app-root/app-root.tsx +109 -0
- package/src/app-root/app-root.types.ts +27 -0
- package/src/archive/archive.tsx +145 -12
- package/src/data/intel-data-provider/intel-data-provider.ts +33 -0
- package/src/data/intel-data-provider/intel-data-provider.types.ts +15 -0
- package/src/flow-runs/flow-runs.tsx +3 -1
- package/src/flows/flows.tsx +5 -11
- package/src/folder-contents/folder-contents.tsx +15 -1
- package/src/i18n/de.json +29 -5
- package/src/i18n/en.json +29 -5
- package/src/i18n/es.json +29 -5
- package/src/main.tsx +26 -32
- package/src/node-table/node-table.tsx +10 -50
- package/src/nodes/nodes.tsx +21 -18
- package/src/resource-menu/resource-menu.tsx +79 -75
- package/src/time/time-context.tsx +72 -0
- package/src/time/time.ts +140 -0
- package/src/title-row/title-row.tsx +4 -0
- package/src/components/ui/breadcrumb.tsx +0 -102
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import type { Flow, FlowValidation } from "@anchrd/intel-contract/flow";
|
|
2
2
|
import type { Node } from "@anchrd/intel-contract/node";
|
|
3
|
-
import type {
|
|
3
|
+
import type { ResourceVerb, UnreadableNodes } from "@anchrd/intel-contract/share";
|
|
4
4
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
5
5
|
import { useNavigate, useRouterState } from "@tanstack/react-router";
|
|
6
6
|
import {
|
|
7
7
|
Archive,
|
|
8
8
|
CornerLeftUp,
|
|
9
|
+
Download,
|
|
9
10
|
Ellipsis,
|
|
10
11
|
FileArchive,
|
|
11
12
|
FolderDown,
|
|
@@ -14,10 +15,10 @@ import {
|
|
|
14
15
|
Pencil,
|
|
15
16
|
Share2,
|
|
16
17
|
ShieldCheck,
|
|
17
|
-
Trash2,
|
|
18
18
|
} from "lucide-react";
|
|
19
19
|
import type * as React from "react";
|
|
20
20
|
import { useState } from "react";
|
|
21
|
+
import { AccessSummary } from "@/access-summary/access-summary.tsx";
|
|
21
22
|
import { moveErrorKey, useTreeMove } from "@/app/tree-move/tree-move.tsx";
|
|
22
23
|
import {
|
|
23
24
|
DropdownMenu,
|
|
@@ -36,7 +37,7 @@ import { useBundleImport } from "@/import-bundle/import-bundle.tsx";
|
|
|
36
37
|
import { Modal } from "@/modal/modal.tsx";
|
|
37
38
|
import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
38
39
|
import { selectedFrom } from "@/router/selection-search.ts";
|
|
39
|
-
import {
|
|
40
|
+
import { useDateTime } from "@/time/time-context.tsx";
|
|
40
41
|
|
|
41
42
|
/**
|
|
42
43
|
* What a resource's own actions are, at the place the resource stands (#24).
|
|
@@ -135,6 +136,7 @@ export function ResourceMenu({
|
|
|
135
136
|
}) {
|
|
136
137
|
const { data } = useIntelRouterContext();
|
|
137
138
|
const i18n = useI18n();
|
|
139
|
+
const dateTime = useDateTime();
|
|
138
140
|
const queryClient = useQueryClient();
|
|
139
141
|
const navigate = useNavigate();
|
|
140
142
|
const [renaming, setRenaming] = useState(false);
|
|
@@ -266,6 +268,37 @@ export function ResourceMenu({
|
|
|
266
268
|
},
|
|
267
269
|
});
|
|
268
270
|
|
|
271
|
+
// #454: a table's CSV export — an ENTRY, not a button of its own. It was the only control WITH
|
|
272
|
+
// TEXT beside two icon buttons in the title line, and it was needed no more often than what sits
|
|
273
|
+
// in here anyway; it was merely wider. Intel has made the same decision twice already (#363 for
|
|
274
|
+
// status and archive, #346 for the import).
|
|
275
|
+
//
|
|
276
|
+
// ⚠️ It lives HERE and no longer in `NodeTablePanel`: there it hung off the table query in order
|
|
277
|
+
// to prevent an empty export. The condition survives the move without a second query — the
|
|
278
|
+
// canonical content IS the CSV, and if it is empty nothing is downloaded, it is refused instead.
|
|
279
|
+
// A file of zero bytes is the worse answer.
|
|
280
|
+
const downloadCsv = useMutation({
|
|
281
|
+
mutationFn: async () => {
|
|
282
|
+
if (target.type !== "node") throw new Error("csv export is a node's");
|
|
283
|
+
const document_ = await data.getNode(target.node.id);
|
|
284
|
+
const content = document_.content ?? "";
|
|
285
|
+
if (content === "") throw new Error("nothing to export yet");
|
|
286
|
+
return new Blob([content], { type: "text/csv;charset=utf-8" });
|
|
287
|
+
},
|
|
288
|
+
onSuccess: (blob) => {
|
|
289
|
+
const url = URL.createObjectURL(blob);
|
|
290
|
+
const anchor = document.createElement("a");
|
|
291
|
+
anchor.href = url;
|
|
292
|
+
// No doubled `.csv` when the title already carries the extension — unchanged from #40.
|
|
293
|
+
anchor.download = title.toLowerCase().endsWith(".csv") ? title : `${title}.csv`;
|
|
294
|
+
document.body.append(anchor);
|
|
295
|
+
anchor.click();
|
|
296
|
+
anchor.remove();
|
|
297
|
+
setTimeout(() => URL.revokeObjectURL(url), 0);
|
|
298
|
+
},
|
|
299
|
+
});
|
|
300
|
+
const isTable = target.type === "node" && target.node.kind === "table";
|
|
301
|
+
|
|
269
302
|
const failure = rename.isError
|
|
270
303
|
? null // the rename dialog words its own refusal, beside the field that caused it
|
|
271
304
|
: archive.isError
|
|
@@ -291,9 +324,25 @@ export function ResourceMenu({
|
|
|
291
324
|
<Pencil aria-hidden="true" />
|
|
292
325
|
{i18n.t("resource.rename")}
|
|
293
326
|
</DropdownMenuItem>
|
|
327
|
+
{/* ⚠️ #455: the hint is the actual change of the ticket. Dragging in the tree has been
|
|
328
|
+
built and shipped since 2026-08-02 — Jack asked for it anyway, so the feature was not
|
|
329
|
+
the problem, its discoverability was. Nothing about a row says it is movable before you
|
|
330
|
+
touch it: `cursor-grab` is only visible once the pointer is already on it, and the root
|
|
331
|
+
drop target appears only while something is being carried.
|
|
332
|
+
|
|
333
|
+
A menu is where you learn what can be done with a thing — the same place an application
|
|
334
|
+
names its keyboard shortcuts. No handle and no extra icon (Jack's instruction), and
|
|
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. */}
|
|
294
340
|
<DropdownMenuItem onSelect={() => move.start(entryOf(target), null)}>
|
|
295
341
|
<CornerLeftUp aria-hidden="true" />
|
|
296
342
|
{i18n.t("tree.move.action")}
|
|
343
|
+
<span aria-hidden="true" className="ml-auto text-xs text-muted-foreground">
|
|
344
|
+
{i18n.t("tree.move.hint")}
|
|
345
|
+
</span>
|
|
297
346
|
</DropdownMenuItem>
|
|
298
347
|
{/* Every kind exports: a folder takes its subtree along, everything else is a bundle of
|
|
299
348
|
one (#136). The entry sits with rename and move because it acts on the whole thing,
|
|
@@ -302,6 +351,14 @@ export function ResourceMenu({
|
|
|
302
351
|
<FolderDown aria-hidden="true" />
|
|
303
352
|
{i18n.t("resource.export")}
|
|
304
353
|
</DropdownMenuItem>
|
|
354
|
+
{/* Only a table has a CSV — and it stands beside the bundle export because both do the
|
|
355
|
+
same thing: hand out the whole thing, not a part of its content. */}
|
|
356
|
+
{isTable ? (
|
|
357
|
+
<DropdownMenuItem onSelect={() => downloadCsv.mutate()}>
|
|
358
|
+
<Download aria-hidden="true" />
|
|
359
|
+
{i18n.t("node.table.download")}
|
|
360
|
+
</DropdownMenuItem>
|
|
361
|
+
) : null}
|
|
305
362
|
{/* The other half of the same round trip, next to it rather than in the tree's plus
|
|
306
363
|
(#346): one word in the menu, both sources under it. */}
|
|
307
364
|
{importable ? (
|
|
@@ -367,6 +424,9 @@ export function ResourceMenu({
|
|
|
367
424
|
{/* Its own sentence, not `resourceErrorKey`'s: nothing was changed, something failed to
|
|
368
425
|
arrive, and "the change was not saved" would send the reader looking for a change. */}
|
|
369
426
|
{exportBundle.isError ? <MenuFailure>{i18n.t("resource.exportFailed")}</MenuFailure> : null}
|
|
427
|
+
{downloadCsv.isError ? (
|
|
428
|
+
<MenuFailure>{i18n.t("node.table.downloadFailed")}</MenuFailure>
|
|
429
|
+
) : null}
|
|
370
430
|
{/* The import's own sentence, and its own pickers — only where the entry exists, because two
|
|
371
431
|
file inputs on every document's menu would be two elements nothing can ever open. */}
|
|
372
432
|
{bundleImport.isError ? <MenuFailure>{i18n.t("tree.importFailed")}</MenuFailure> : null}
|
|
@@ -417,7 +477,7 @@ export function ResourceMenu({
|
|
|
417
477
|
{/* A snapshot says when it was taken, or it will be read as a standing verdict. */}
|
|
418
478
|
<p className="mt-4 text-xs text-muted-foreground">
|
|
419
479
|
{i18n.t("flows.validateWhen", {
|
|
420
|
-
when:
|
|
480
|
+
when: dateTime.at(validation.checkedAt),
|
|
421
481
|
})}
|
|
422
482
|
</p>
|
|
423
483
|
</Modal>
|
|
@@ -577,54 +637,6 @@ function NodeLinksPanel({ node, close }: { node: Node; close(): void }) {
|
|
|
577
637
|
// flows, which is where a permission decision belongs (ADR-0004 §2). Which verbs it offers is the
|
|
578
638
|
// server's answer, not this component's: `execute` never appears on a document, because a document
|
|
579
639
|
// has nothing to run.
|
|
580
|
-
/**
|
|
581
|
-
* One row of the grant list.
|
|
582
|
-
*
|
|
583
|
-
* ⚠️ It is a component rather than a line inside the `map` because of `useUserName`, and that hook
|
|
584
|
-
* is the point: until #431 a `user` grant printed its raw id — `2C9lEhT82upO82abPjHFAvs24a7Vdd3J`
|
|
585
|
-
* beside the word "Read" — which tells the reader nothing about who they let in and puts an
|
|
586
|
-
* identifier on a screen that has no reason to carry one. `user-name.ts` already had the rule
|
|
587
|
-
* ("never fall back to the id, show nothing instead", #258); this row was the last place breaking it.
|
|
588
|
-
*
|
|
589
|
-
* ⚠️ Intel can resolve exactly ONE id today: the signed-in person's. A foreign one is not "a name we
|
|
590
|
-
* could probably guess" but genuinely unanswerable — Intel has no user directory (#261) — so what
|
|
591
|
-
* stands there is what is true: an account, unnamed. The day #261 lands, `useUserName` starts
|
|
592
|
-
* answering and this row needs no change.
|
|
593
|
-
*/
|
|
594
|
-
function GrantRow({ grant, onRevoke }: { grant: ResourceGrant; onRevoke(grantId: string): void }) {
|
|
595
|
-
const i18n = useI18n();
|
|
596
|
-
const userName = useUserName(grant.principal.type === "user" ? grant.principal.id : null);
|
|
597
|
-
// ⚠️ An `email` grant keeps showing the ADDRESS, even where a name is known, and that is a
|
|
598
|
-
// decision rather than an oversight (#431, review finding). The grant is bound to the address —
|
|
599
|
-
// `db-grants.ts` matches `lower(principal_id)` against whoever signs in — not to an account.
|
|
600
|
-
// Putting a name there would claim a binding that does not exist, and it would be wrong the moment
|
|
601
|
-
// somebody else verifies that address.
|
|
602
|
-
const who =
|
|
603
|
-
grant.principal.type === "email"
|
|
604
|
-
? grant.principal.email
|
|
605
|
-
: grant.principal.type === "user"
|
|
606
|
-
? (userName ?? i18n.t("node.someUser"))
|
|
607
|
-
: i18n.t("node.organization");
|
|
608
|
-
return (
|
|
609
|
-
<li className="flex items-center justify-between gap-3 rounded-md border p-3 text-sm">
|
|
610
|
-
<span className="min-w-0 truncate">
|
|
611
|
-
{who}
|
|
612
|
-
<span className="ml-2 text-xs text-muted-foreground">
|
|
613
|
-
{i18n.t(`node.verb.${grant.verb}`)}
|
|
614
|
-
</span>
|
|
615
|
-
</span>
|
|
616
|
-
<button
|
|
617
|
-
type="button"
|
|
618
|
-
onClick={() => onRevoke(grant.id)}
|
|
619
|
-
aria-label={i18n.t("node.revokeShare")}
|
|
620
|
-
className="rounded-md p-2 text-destructive outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
|
|
621
|
-
>
|
|
622
|
-
<Trash2 aria-hidden="true" className="size-4" />
|
|
623
|
-
</button>
|
|
624
|
-
</li>
|
|
625
|
-
);
|
|
626
|
-
}
|
|
627
|
-
|
|
628
640
|
/**
|
|
629
641
|
* Which of the three principals the dialog can SET (#431).
|
|
630
642
|
*
|
|
@@ -692,7 +704,10 @@ function SharePanel({ node, close }: { node: Node; close(): void }) {
|
|
|
692
704
|
// before, and the user would read a permission picture that is not the one in force. Of all the
|
|
693
705
|
// things to be silently wrong about, access is the worst.
|
|
694
706
|
onSettled: async () => {
|
|
695
|
-
await
|
|
707
|
+
await Promise.all([
|
|
708
|
+
queryClient.invalidateQueries({ queryKey: ["node-grants", node.id] }),
|
|
709
|
+
queryClient.invalidateQueries({ queryKey: ["effective-node-grants"] }),
|
|
710
|
+
]);
|
|
696
711
|
},
|
|
697
712
|
});
|
|
698
713
|
const revoke = useMutation({
|
|
@@ -703,7 +718,10 @@ function SharePanel({ node, close }: { node: Node; close(): void }) {
|
|
|
703
718
|
idempotencyKey: crypto.randomUUID(),
|
|
704
719
|
}),
|
|
705
720
|
onSettled: async () => {
|
|
706
|
-
await
|
|
721
|
+
await Promise.all([
|
|
722
|
+
queryClient.invalidateQueries({ queryKey: ["node-grants", node.id] }),
|
|
723
|
+
queryClient.invalidateQueries({ queryKey: ["effective-node-grants"] }),
|
|
724
|
+
]);
|
|
707
725
|
},
|
|
708
726
|
});
|
|
709
727
|
|
|
@@ -759,21 +777,17 @@ function SharePanel({ node, close }: { node: Node; close(): void }) {
|
|
|
759
777
|
<p className="mt-1 text-xs text-muted-foreground">{i18n.t("node.shareUnreadableHint")}</p>
|
|
760
778
|
</div>
|
|
761
779
|
)}
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
<
|
|
765
|
-
|
|
766
|
-
grant={grant}
|
|
780
|
+
{grants.data && grants.data.items.length > 0 ? (
|
|
781
|
+
<div className="mb-5">
|
|
782
|
+
<AccessSummary
|
|
783
|
+
grants={grants.data.items}
|
|
767
784
|
onRevoke={(grantId) => {
|
|
768
785
|
share.reset();
|
|
769
786
|
revoke.mutate(grantId);
|
|
770
787
|
}}
|
|
771
788
|
/>
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
<li className="text-sm text-muted-foreground">{i18n.t("node.noGrants")}</li>
|
|
775
|
-
)}
|
|
776
|
-
</ul>
|
|
789
|
+
</div>
|
|
790
|
+
) : null}
|
|
777
791
|
<form
|
|
778
792
|
onSubmit={(event) => {
|
|
779
793
|
event.preventDefault();
|
|
@@ -799,7 +813,7 @@ function SharePanel({ node, close }: { node: Node; close(): void }) {
|
|
|
799
813
|
name="share-principal"
|
|
800
814
|
checked={principalKind === kind}
|
|
801
815
|
onChange={() => setPrincipalKind(kind)}
|
|
802
|
-
aria-describedby={kind === "
|
|
816
|
+
aria-describedby={kind === "organization" ? "share-organization-hint" : undefined}
|
|
803
817
|
className="size-4 border outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
804
818
|
/>
|
|
805
819
|
<span>
|
|
@@ -821,19 +835,9 @@ function SharePanel({ node, close }: { node: Node; close(): void }) {
|
|
|
821
835
|
required
|
|
822
836
|
value={email}
|
|
823
837
|
onChange={(event) => setEmail(event.target.value)}
|
|
824
|
-
aria-describedby="share-email-hint"
|
|
825
838
|
className="mt-2 w-full rounded-md border bg-background px-3 py-2 outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
826
839
|
/>
|
|
827
840
|
</label>
|
|
828
|
-
{/* ⚠️ What this says is what Intel knows, and no more (#431). It cannot check whether an
|
|
829
|
-
address belongs to anybody — Gate owns identity and offers Intel no lookup (#261) —
|
|
830
|
-
and a dialog that stayed silent about that let a typo become a grant that is valid,
|
|
831
|
-
permanent and completely without effect, with nobody on either side to notice. Naming
|
|
832
|
-
the uncertainty is the honest half of what the ticket asks for; the other half needs
|
|
833
|
-
Gate. */}
|
|
834
|
-
<p id="share-email-hint" className="mt-1 text-xs text-muted-foreground">
|
|
835
|
-
{i18n.t("node.emailHint")}
|
|
836
|
-
</p>
|
|
837
841
|
</div>
|
|
838
842
|
) : (
|
|
839
843
|
/* ⚠️ `aria-live` on the CONTAINER, which is already mounted whenever this branch is on
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { createContext, type ReactNode, useContext, useMemo, useState } from "react";
|
|
2
|
+
import { useI18n } from "@/i18n/i18n-context.tsx";
|
|
3
|
+
import {
|
|
4
|
+
DeviceZone,
|
|
5
|
+
formatDate,
|
|
6
|
+
formatDateTime,
|
|
7
|
+
readZoneChoice,
|
|
8
|
+
rememberZoneChoice,
|
|
9
|
+
type TimeZoneStore,
|
|
10
|
+
type ZoneChoice,
|
|
11
|
+
} from "./time.ts";
|
|
12
|
+
|
|
13
|
+
// The React seam for the zone. Like language and appearance it is CLIENT state — a preference in
|
|
14
|
+
// `localStorage`, never on Intel's HTTP surface (`packages/ui/CLAUDE.md`).
|
|
15
|
+
//
|
|
16
|
+
// ⚠️ Deliberately NOT inside the i18n context, although both concern presentation: the language
|
|
17
|
+
// decides HOW a time is written, the zone WHICH time it is. Merged, the zone would hang off the
|
|
18
|
+
// catalogue and a language switch would move the clock.
|
|
19
|
+
|
|
20
|
+
export interface ZoneSelection {
|
|
21
|
+
current: ZoneChoice;
|
|
22
|
+
select(choice: ZoneChoice): void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const ZoneContext = createContext<ZoneSelection | null>(null);
|
|
26
|
+
|
|
27
|
+
export function TimeZoneProvider({
|
|
28
|
+
store,
|
|
29
|
+
children,
|
|
30
|
+
}: {
|
|
31
|
+
store?: TimeZoneStore;
|
|
32
|
+
children: ReactNode;
|
|
33
|
+
}) {
|
|
34
|
+
const [choice, setChoice] = useState<ZoneChoice>(() => readZoneChoice(store));
|
|
35
|
+
const selection = useMemo<ZoneSelection>(
|
|
36
|
+
() => ({
|
|
37
|
+
current: choice,
|
|
38
|
+
select: (next) => {
|
|
39
|
+
rememberZoneChoice(next, store);
|
|
40
|
+
setChoice(next);
|
|
41
|
+
},
|
|
42
|
+
}),
|
|
43
|
+
[choice, store],
|
|
44
|
+
);
|
|
45
|
+
return <ZoneContext value={selection}>{children}</ZoneContext>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// `null` without a provider — not a stand-in value: the settings dialog asks exactly whether there
|
|
49
|
+
// is anything to choose at all (the same rule `useTheme` follows).
|
|
50
|
+
export function useTimeZone(): ZoneSelection | null {
|
|
51
|
+
return useContext(ZoneContext);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The formatter every surface showing a time uses.
|
|
56
|
+
*
|
|
57
|
+
* ⚠️ A hook rather than a function with parameters: otherwise every cell would have to fetch locale
|
|
58
|
+
* AND zone itself, and the first one that forgets shows the device's zone again — which is the state
|
|
59
|
+
* before #467, only harder to find.
|
|
60
|
+
*/
|
|
61
|
+
export function useDateTime(): { at: (value: string) => string; on: (value: string) => string } {
|
|
62
|
+
const i18n = useI18n();
|
|
63
|
+
const zone = useContext(ZoneContext);
|
|
64
|
+
const choice = zone?.current ?? DeviceZone;
|
|
65
|
+
return useMemo(
|
|
66
|
+
() => ({
|
|
67
|
+
at: (value: string) => formatDateTime(value, i18n.locale, choice),
|
|
68
|
+
on: (value: string) => formatDate(value, i18n.locale, choice),
|
|
69
|
+
}),
|
|
70
|
+
[i18n.locale, choice],
|
|
71
|
+
);
|
|
72
|
+
}
|
package/src/time/time.ts
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// The ONE place a timestamp becomes a readable sentence (#467).
|
|
2
|
+
//
|
|
3
|
+
// Every surface used to call `toLocaleString(locale)` for itself, which silently takes the DEVICE's
|
|
4
|
+
// zone. Three things follow that are not true:
|
|
5
|
+
//
|
|
6
|
+
// 1. Whoever travels, or uses a device in another zone, reads different times for the same
|
|
7
|
+
// events. For a record of what happened that is where it stops being evidence.
|
|
8
|
+
// 2. Nowhere does it say WHICH zone applies. A time without a zone is a number, not a statement —
|
|
9
|
+
// and it looks exactly like a real one.
|
|
10
|
+
// 3. Two people looking at the same entry see different times and cannot refer to one in
|
|
11
|
+
// conversation.
|
|
12
|
+
//
|
|
13
|
+
// ⚠️ **The zone is a LENS for reading, never a change to the value.** What is stored and sent stays
|
|
14
|
+
// UTC; this module only formats.
|
|
15
|
+
//
|
|
16
|
+
// ⚠️ The comment in `settings-dialog.tsx` is what this file answers: the timezone left with the
|
|
17
|
+
// Agents (#388) because nothing read it, and "a preference that changes nothing on screen is worse
|
|
18
|
+
// than none". This exists BEFORE the preference does, and that order is the whole of #467.
|
|
19
|
+
|
|
20
|
+
export interface TimeZoneStore {
|
|
21
|
+
getItem(key: string): string | null;
|
|
22
|
+
setItem(key: string, value: string): void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const StorageKey = "intel.timezone";
|
|
26
|
+
|
|
27
|
+
// "This device's zone" as an explicit choice rather than an empty field: an empty field would look
|
|
28
|
+
// as if nothing were set, and invite somebody to set what already holds.
|
|
29
|
+
export const DeviceZone = "device";
|
|
30
|
+
export type ZoneChoice = string;
|
|
31
|
+
|
|
32
|
+
// ⚠️ Falling back to UTC rather than throwing: in an environment without Intl a time in UTC is
|
|
33
|
+
// still a time, while a thrown exception is an empty screen.
|
|
34
|
+
export function deviceZone(): string {
|
|
35
|
+
try {
|
|
36
|
+
return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
|
|
37
|
+
} catch {
|
|
38
|
+
return "UTC";
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// The stored value comes out of `localStorage` and may have been edited by hand, written by an older
|
|
43
|
+
// build, or name a zone this runtime does not know.
|
|
44
|
+
export function isKnownZone(zone: string): boolean {
|
|
45
|
+
if (zone === DeviceZone) return true;
|
|
46
|
+
try {
|
|
47
|
+
new Intl.DateTimeFormat("en-US", { timeZone: zone });
|
|
48
|
+
return true;
|
|
49
|
+
} catch {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function readZoneChoice(store?: TimeZoneStore): ZoneChoice {
|
|
55
|
+
try {
|
|
56
|
+
const stored = store?.getItem(StorageKey);
|
|
57
|
+
return stored && isKnownZone(stored) ? stored : DeviceZone;
|
|
58
|
+
} catch {
|
|
59
|
+
return DeviceZone;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function rememberZoneChoice(choice: ZoneChoice, store?: TimeZoneStore): void {
|
|
64
|
+
try {
|
|
65
|
+
store?.setItem(StorageKey, choice);
|
|
66
|
+
} catch {
|
|
67
|
+
// Without storage the choice holds for this session. Less than the reader wanted, more than an
|
|
68
|
+
// error they cannot switch off.
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function resolveZone(choice: ZoneChoice): string {
|
|
73
|
+
return choice === DeviceZone || !isKnownZone(choice) ? deviceZone() : choice;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* A timestamp as a readable sentence — WITH its zone.
|
|
78
|
+
*
|
|
79
|
+
* ⚠️ The abbreviation is not decoration: a shifted time without one is precisely the silent
|
|
80
|
+
* misstatement this ticket is written against. And it comes from the runtime rather than from a
|
|
81
|
+
* string in the code — the runtime knows daylight saving, an appended label would be wrong for half
|
|
82
|
+
* the year.
|
|
83
|
+
*
|
|
84
|
+
* ⚠️ Individual components rather than `dateStyle`/`timeStyle`: Intl refuses the two style
|
|
85
|
+
* shorthands TOGETHER with `timeZoneName` and throws `Invalid option` — inside a table cell that
|
|
86
|
+
* takes the whole list with it. Found the hard way in `anchrd/gate#286`, where it read as "the row
|
|
87
|
+
* is missing" rather than as a formatting error.
|
|
88
|
+
*/
|
|
89
|
+
export function formatDateTime(value: string, locale: string, choice: ZoneChoice): string {
|
|
90
|
+
const date = new Date(value);
|
|
91
|
+
// An unreadable timestamp travels through rather than rendering as "Invalid Date": the raw value
|
|
92
|
+
// at least says what stood there.
|
|
93
|
+
if (Number.isNaN(date.getTime())) return value;
|
|
94
|
+
return date.toLocaleString(locale, {
|
|
95
|
+
timeZone: resolveZone(choice),
|
|
96
|
+
year: "numeric",
|
|
97
|
+
month: "short",
|
|
98
|
+
day: "2-digit",
|
|
99
|
+
hour: "2-digit",
|
|
100
|
+
minute: "2-digit",
|
|
101
|
+
timeZoneName: "short",
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The date alone, for the column that only ever showed one (the folder table's "Changed").
|
|
107
|
+
*
|
|
108
|
+
* ⚠️ It still resolves through the chosen zone, and that is the point rather than a detail: near
|
|
109
|
+
* midnight a date is exactly the value that differs between two zones, and a bare date carries no
|
|
110
|
+
* abbreviation to warn anybody.
|
|
111
|
+
*/
|
|
112
|
+
export function formatDate(value: string, locale: string, choice: ZoneChoice): string {
|
|
113
|
+
const date = new Date(value);
|
|
114
|
+
if (Number.isNaN(date.getTime())) return value;
|
|
115
|
+
return date.toLocaleDateString(locale, { timeZone: resolveZone(choice) });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ⚠️ A short curated list rather than `Intl.supportedValuesOf("timeZone")` — that is over 400
|
|
119
|
+
// entries, and a dropdown of 400 rows without a search is not a choice but an imposition. A zone
|
|
120
|
+
// missing here is added once it has been missed three times; until then the device's zone is the
|
|
121
|
+
// answer, and it is the right one for almost everybody. Ordered by offset, so the list reads like a
|
|
122
|
+
// map.
|
|
123
|
+
export const ZoneOptions: readonly string[] = [
|
|
124
|
+
"UTC",
|
|
125
|
+
"Europe/Lisbon",
|
|
126
|
+
"Europe/Berlin",
|
|
127
|
+
"Europe/Athens",
|
|
128
|
+
"Europe/Moscow",
|
|
129
|
+
"Asia/Dubai",
|
|
130
|
+
"Asia/Kolkata",
|
|
131
|
+
"Asia/Singapore",
|
|
132
|
+
"Asia/Tokyo",
|
|
133
|
+
"Australia/Sydney",
|
|
134
|
+
"Pacific/Auckland",
|
|
135
|
+
"America/Sao_Paulo",
|
|
136
|
+
"America/New_York",
|
|
137
|
+
"America/Chicago",
|
|
138
|
+
"America/Denver",
|
|
139
|
+
"America/Los_Angeles",
|
|
140
|
+
];
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type * as React from "react";
|
|
2
|
+
import { ResourceAccessSummary } from "@/access-summary/access-summary.tsx";
|
|
2
3
|
import { ResourceMenu, type ResourceTarget } from "@/resource-menu/resource-menu.tsx";
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -33,6 +34,9 @@ export function TitleRow({
|
|
|
33
34
|
<div className="min-w-0">
|
|
34
35
|
<div className="flex min-w-0 flex-wrap items-baseline gap-x-3">
|
|
35
36
|
<h2 className="truncate text-lg font-semibold">{title}</h2>
|
|
37
|
+
{target.type === "node" && target.node.kind === "folder" ? (
|
|
38
|
+
<ResourceAccessSummary resourceId={target.node.id} ownerId={target.node.ownerId} />
|
|
39
|
+
) : null}
|
|
36
40
|
<span data-slot="title-meta" className="text-sm text-muted-foreground" />
|
|
37
41
|
</div>
|
|
38
42
|
{description ? <p className="mt-1 text-sm text-muted-foreground">{description}</p> : null}
|
|
@@ -1,102 +0,0 @@
|
|
|
1
|
-
import { ChevronRight, MoreHorizontal } from "lucide-react";
|
|
2
|
-
import { Slot } from "radix-ui";
|
|
3
|
-
import type * as React from "react";
|
|
4
|
-
|
|
5
|
-
import { cn } from "@/lib/utils";
|
|
6
|
-
|
|
7
|
-
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
|
|
8
|
-
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
|
|
12
|
-
return (
|
|
13
|
-
<ol
|
|
14
|
-
data-slot="breadcrumb-list"
|
|
15
|
-
className={cn(
|
|
16
|
-
"flex flex-wrap items-center gap-1.5 text-sm break-words text-muted-foreground sm:gap-2.5",
|
|
17
|
-
className,
|
|
18
|
-
)}
|
|
19
|
-
{...props}
|
|
20
|
-
/>
|
|
21
|
-
);
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
|
|
25
|
-
return (
|
|
26
|
-
<li
|
|
27
|
-
data-slot="breadcrumb-item"
|
|
28
|
-
className={cn("inline-flex items-center gap-1.5", className)}
|
|
29
|
-
{...props}
|
|
30
|
-
/>
|
|
31
|
-
);
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
function BreadcrumbLink({
|
|
35
|
-
asChild,
|
|
36
|
-
className,
|
|
37
|
-
...props
|
|
38
|
-
}: React.ComponentProps<"a"> & {
|
|
39
|
-
asChild?: boolean;
|
|
40
|
-
}) {
|
|
41
|
-
const Comp = asChild ? Slot.Root : "a";
|
|
42
|
-
|
|
43
|
-
return (
|
|
44
|
-
<Comp
|
|
45
|
-
data-slot="breadcrumb-link"
|
|
46
|
-
className={cn("transition-colors hover:text-foreground", className)}
|
|
47
|
-
{...props}
|
|
48
|
-
/>
|
|
49
|
-
);
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
|
|
53
|
-
return (
|
|
54
|
-
<span
|
|
55
|
-
data-slot="breadcrumb-page"
|
|
56
|
-
role="link"
|
|
57
|
-
aria-disabled="true"
|
|
58
|
-
aria-current="page"
|
|
59
|
-
className={cn("font-normal text-foreground", className)}
|
|
60
|
-
{...props}
|
|
61
|
-
/>
|
|
62
|
-
);
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
function BreadcrumbSeparator({ children, className, ...props }: React.ComponentProps<"li">) {
|
|
66
|
-
return (
|
|
67
|
-
<li
|
|
68
|
-
data-slot="breadcrumb-separator"
|
|
69
|
-
role="presentation"
|
|
70
|
-
aria-hidden="true"
|
|
71
|
-
className={cn("[&>svg]:size-3.5", className)}
|
|
72
|
-
{...props}
|
|
73
|
-
>
|
|
74
|
-
{children ?? <ChevronRight />}
|
|
75
|
-
</li>
|
|
76
|
-
);
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
function BreadcrumbEllipsis({ className, ...props }: React.ComponentProps<"span">) {
|
|
80
|
-
return (
|
|
81
|
-
<span
|
|
82
|
-
data-slot="breadcrumb-ellipsis"
|
|
83
|
-
role="presentation"
|
|
84
|
-
aria-hidden="true"
|
|
85
|
-
className={cn("flex size-9 items-center justify-center", className)}
|
|
86
|
-
{...props}
|
|
87
|
-
>
|
|
88
|
-
<MoreHorizontal className="size-4" />
|
|
89
|
-
<span className="sr-only">More</span>
|
|
90
|
-
</span>
|
|
91
|
-
);
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
export {
|
|
95
|
-
Breadcrumb,
|
|
96
|
-
BreadcrumbEllipsis,
|
|
97
|
-
BreadcrumbItem,
|
|
98
|
-
BreadcrumbLink,
|
|
99
|
-
BreadcrumbList,
|
|
100
|
-
BreadcrumbPage,
|
|
101
|
-
BreadcrumbSeparator,
|
|
102
|
-
};
|