@anchrd/intel-ui 0.23.0 → 0.28.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.
@@ -1,5 +1,6 @@
1
1
  import { useQuery } from "@tanstack/react-query";
2
2
  import { useNavigate } from "@tanstack/react-router";
3
+ import { ResourceAccessSummary } from "@/access-summary/access-summary.tsx";
3
4
  import { treeLevelKey } from "@/app/tree-move/tree-move.tsx";
4
5
  import {
5
6
  Table,
@@ -14,7 +15,8 @@ import { useI18n } from "@/i18n/i18n-context.tsx";
14
15
  import { kindIcons } from "@/kind-icon.ts";
15
16
  import { ResourceMenu, type ResourceTarget } from "@/resource-menu/resource-menu.tsx";
16
17
  import { useIntelRouterContext } from "@/router/router-context.ts";
17
- import { useDateTime } from "@/time/time-context.tsx";
18
+ import { RelativeTime } from "@/time/relative-time.tsx";
19
+ import { TitleRowScrollArea } from "@/title-row/title-row.tsx";
18
20
 
19
21
  // The same record the menu is handed everywhere else, read off a level row. A re-labelling, never a
20
22
  // second source of truth — a rename works on the whole record, not on a summary of it.
@@ -42,7 +44,6 @@ function targetOf(entry: TreeEntry): ResourceTarget {
42
44
  export function FolderContents({ folderId }: { folderId: string }) {
43
45
  const { data } = useIntelRouterContext();
44
46
  const i18n = useI18n();
45
- const dateTime = useDateTime();
46
47
  const navigate = useNavigate();
47
48
  const level = useQuery({
48
49
  queryKey: treeLevelKey(folderId),
@@ -73,7 +74,7 @@ export function FolderContents({ folderId }: { folderId: string }) {
73
74
  }
74
75
 
75
76
  return (
76
- <div className="min-h-0 flex-1 overflow-y-auto p-6">
77
+ <TitleRowScrollArea className="min-h-0 flex-1 overflow-y-auto p-6">
77
78
  <div className="overflow-hidden rounded-lg border bg-card">
78
79
  <Table>
79
80
  <TableHeader className="[&_tr]:bg-muted/40">
@@ -84,6 +85,9 @@ export function FolderContents({ folderId }: { folderId: string }) {
84
85
  <TableHead className="h-9 px-4 text-right text-xs tracking-wide uppercase">
85
86
  {i18n.t("node.changed")}
86
87
  </TableHead>
88
+ <TableHead className="h-9 px-4 text-xs tracking-wide uppercase">
89
+ {i18n.t("node.access")}
90
+ </TableHead>
87
91
  {/* A `<th>` with nothing in it leaves the column unnamed for anyone reading the table
88
92
  by its headers. The word is there; only the eye is spared it. */}
89
93
  <TableHead className="h-9 w-14">
@@ -115,7 +119,7 @@ export function FolderContents({ folderId }: { folderId: string }) {
115
119
  column, instead of five loose glyphs at five different widths. */}
116
120
  <span
117
121
  aria-hidden="true"
118
- className="grid size-8 shrink-0 place-items-center rounded-md border bg-muted/50 text-muted-foreground transition-colors group-hover/row:bg-accent group-hover/row:text-accent-foreground"
122
+ className="grid size-8 shrink-0 place-items-center rounded-md bg-muted/50 text-muted-foreground transition-colors group-hover/row:bg-accent group-hover/row:text-accent-foreground"
119
123
  >
120
124
  <Icon className="size-4" />
121
125
  </span>
@@ -128,7 +132,15 @@ export function FolderContents({ folderId }: { folderId: string }) {
128
132
  </button>
129
133
  </TableCell>
130
134
  <TableCell className="px-4 text-right text-sm text-muted-foreground tabular-nums">
131
- {dateTime.on(changed)}
135
+ <RelativeTime value={changed} />
136
+ </TableCell>
137
+ <TableCell className="px-4">
138
+ <ResourceAccessSummary
139
+ resourceId={
140
+ entry.type === "flow" ? (entry.flow.parentId ?? folderId) : entry.id
141
+ }
142
+ ownerId={entry.type === "flow" ? entry.flow.ownerId : entry.node.ownerId}
143
+ />
132
144
  </TableCell>
133
145
  {/* ⚠️ The same menu as the title line of the open thing, not a shorter one built
134
146
  for here. #58 collected every action in one place so that place is always the
@@ -142,6 +154,6 @@ export function FolderContents({ folderId }: { folderId: string }) {
142
154
  </TableBody>
143
155
  </Table>
144
156
  </div>
145
- </div>
157
+ </TitleRowScrollArea>
146
158
  );
147
159
  }
package/src/i18n/de.json CHANGED
@@ -71,10 +71,15 @@
71
71
  "tree.move.failed.other": "Es konnte nicht verschoben werden. Prüfe deinen Zugriff und versuche es erneut.",
72
72
  "resource.menu": "Weitere Aktionen für {title}",
73
73
  "resource.rename": "Umbenennen",
74
+ "resource.move": "Verschieben",
74
75
  "resource.renameTitle": "{title} umbenennen",
75
76
  "resource.archive": "Archivieren",
76
77
  "resource.export": "Export",
77
78
  "resource.import": "Import",
79
+ "resource.downloadCsv": "CSV herunterladen",
80
+ "resource.validate": "Prüfen",
81
+ "resource.links": "Verweise",
82
+ "resource.share": "Freigeben",
78
83
  "tree.new.importZip": "ZIP-Bündel importieren",
79
84
  "tree.new.importFolder": "Ordner importieren",
80
85
  "tree.importZipInto": "ZIP-Bündel in {where} importieren",
@@ -85,16 +90,25 @@
85
90
  "archive.description": "Alles, was du archiviert hast, das Neueste zuerst. Beim Wiederherstellen kommt ein Eintrag dorthin zurück, wo er lag.",
86
91
  "archive.restore": "{title} wiederherstellen",
87
92
  "archive.restoreAction": "Wiederherstellen",
93
+ "archive.restoring": "{title} wird wiederhergestellt",
94
+ "archive.restoringAction": "Wird wiederhergestellt",
88
95
  "archive.empty": "Es ist nichts archiviert.",
89
96
  "archive.failed": "Das Archiv konnte nicht gelesen werden. Prüfe deinen Zugriff und versuche es erneut.",
90
97
  "archive.restoreFailed": "Es wurde nicht wiederhergestellt. Vielleicht hat jemand anderes es geändert — lade neu und versuche es erneut.",
91
98
  "archive.purge": "{title} endgültig löschen",
92
99
  "archive.purge.title": "Endgültig löschen?",
93
100
  "archive.purge.body": "„{title}\u201c und alles, was dazugehört — jede Version, der Inhalt und die Einträge im Suchindex — ist danach weg. Das lässt sich nicht rückgängig machen.",
101
+ "archive.purge.links.none": "Keine anderen Dokumente verweisen darauf.",
102
+ "archive.purge.links.one": "Ein anderes Dokument verweist darauf; dieser Verweis wird brechen.",
103
+ "archive.purge.links.many": "{count} andere Dokumente verweisen darauf; diese Verweise werden brechen.",
104
+ "archive.purge.grants": "Die Freigaben werden ebenfalls gelöscht.",
105
+ "archive.purge.items": "Damit werden insgesamt {count} Elemente endgültig gelöscht.",
106
+ "archive.purge.previewLoading": "Verweise werden geprüft …",
107
+ "archive.purge.previewFailed": "Die Verweise konnten nicht geprüft werden. Lade neu und versuche es vor dem Löschen erneut.",
94
108
  "archive.purge.confirm": "Endgültig löschen",
95
109
  "archive.purge.cancel": "Abbrechen",
96
- "archive.purgeFailed": "Es konnte nicht gelöscht werden. Ein Ordner muss zuerst leer sein, und was ein publizierter Flow noch benutzt, kann gar nicht weg.",
97
- "archive.archivedAt": "Archiviert {when}",
110
+ "archive.purgeFailed": "Es konnte nicht vollständig gelöscht werden. Lade neu und versuche es erneut.",
111
+ "archive.archived": "Archiviert",
98
112
  "resource.conflict": "Nicht geändert: Jemand anderes hat diesen Eintrag zuerst geändert. Lade ihn neu und versuche es erneut.",
99
113
  "resource.forbidden": "Nicht geändert: Du darfst diesen Eintrag nicht ändern.",
100
114
  "resource.failed": "Die Änderung wurde nicht gespeichert. Lade die neueste Fassung und versuche es erneut.",
@@ -119,7 +133,6 @@
119
133
  "node.share": "Freigeben",
120
134
  "node.shareAction": "Zugriff geben",
121
135
  "node.email": "E-Mail-Adresse",
122
- "node.emailHint": "Intel kann nicht prüfen, ob hinter dieser Adresse ein Konto steht. Die Freigabe greift, sobald sich jemand damit anmeldet — auch nach einer späteren Registrierung.",
123
136
  "node.shareWith": "Wer Zugriff bekommt",
124
137
  "node.shareWithEmail": "Eine E-Mail-Adresse",
125
138
  "node.shareWithOrganization": "Alle in der Organisation",
@@ -137,8 +150,13 @@
137
150
  "node.verbHint.execute": "Die Flows hier drin starten",
138
151
  "node.verbHint.share": "Anderen Zugriff geben",
139
152
  "node.organization": "Alle in der Organisation",
153
+ "node.owner": "Besitzer",
154
+ "node.access": "Zugriff",
155
+ "node.organizationAccess": "Alle in der Organisation haben Zugriff",
156
+ "node.accessDetails": "Zugriffsdetails anzeigen ({count})",
157
+ "node.revokeVerb": "{verb} entziehen",
158
+ "node.revokeVerbFor": "{verb} für {recipient} entziehen",
140
159
  "node.revokeShare": "Zugriff entziehen",
141
- "node.noGrants": "Außer der Besitzerin oder dem Besitzer hat noch niemand Zugriff.",
142
160
  "node.shareFailed": "Der Zugriff wurde nicht geändert. Prüfe deine Berechtigung und versuche es erneut.",
143
161
  "node.shareUnreadable": "Flows in diesem Ordner lesen Dokumente, die diese Freigabe nicht abdeckt: {titles}.",
144
162
  "node.shareUnreadableMore": "{count} weitere liegen ebenfalls außer Reichweite, und du kannst sie nicht sehen.",
@@ -334,5 +352,7 @@
334
352
  "common.close": "Schließen",
335
353
  "common.unavailable": "Intel ist derzeit nicht verfügbar.",
336
354
  "common.noAccess": "Das ist für dich nicht verfügbar. Es existiert nicht, oder es ist nicht mehr für dich freigegeben.",
337
- "common.noPermission": "Dir fehlt die Berechtigung, das zu sehen."
355
+ "common.noPermission": "Dir fehlt die Berechtigung, das zu sehen.",
356
+ "title.descriptionMore": "Mehr",
357
+ "title.descriptionLess": "Weniger"
338
358
  }
package/src/i18n/en.json CHANGED
@@ -71,10 +71,15 @@
71
71
  "tree.move.failed.other": "It could not be moved. Check your access and try again.",
72
72
  "resource.menu": "More actions for {title}",
73
73
  "resource.rename": "Rename",
74
+ "resource.move": "Move",
74
75
  "resource.renameTitle": "Rename {title}",
75
76
  "resource.archive": "Archive",
76
77
  "resource.export": "Export",
77
78
  "resource.import": "Import",
79
+ "resource.downloadCsv": "Download CSV",
80
+ "resource.validate": "Check",
81
+ "resource.links": "Links",
82
+ "resource.share": "Share",
78
83
  "tree.new.importZip": "Import zip bundle",
79
84
  "tree.new.importFolder": "Import folder",
80
85
  "tree.importZipInto": "Import a zip bundle into {where}",
@@ -85,16 +90,25 @@
85
90
  "archive.description": "Everything you archived, newest first. Restoring puts an entry back where it was filed.",
86
91
  "archive.restore": "Restore {title}",
87
92
  "archive.restoreAction": "Restore",
93
+ "archive.restoring": "Restoring {title}",
94
+ "archive.restoringAction": "Restoring",
88
95
  "archive.empty": "Nothing is archived.",
89
96
  "archive.failed": "The archive could not be read. Check your access and try again.",
90
97
  "archive.restoreFailed": "It was not restored. Somebody else may have changed it — reload and try again.",
91
98
  "archive.purge": "Delete {title} for good",
92
99
  "archive.purge.title": "Delete this for good?",
93
100
  "archive.purge.body": "“{title}” and everything belonging to it — every version, its content and its entries in the search index — will be gone. This cannot be undone.",
101
+ "archive.purge.links.none": "No other documents link to it.",
102
+ "archive.purge.links.one": "One other document links to it and that link will break.",
103
+ "archive.purge.links.many": "{count} other documents link to it and those links will break.",
104
+ "archive.purge.grants": "Its shares will also be deleted.",
105
+ "archive.purge.items": "This permanently deletes {count} items in total.",
106
+ "archive.purge.previewLoading": "Checking links…",
107
+ "archive.purge.previewFailed": "The links could not be checked. Reload and try again before deleting.",
94
108
  "archive.purge.confirm": "Delete for good",
95
109
  "archive.purge.cancel": "Cancel",
96
- "archive.purgeFailed": "It could not be deleted. A folder has to be empty first, and a node a published flow still uses cannot go at all.",
97
- "archive.archivedAt": "Archived {when}",
110
+ "archive.purgeFailed": "It could not be deleted completely. Reload and try again.",
111
+ "archive.archived": "Archived",
98
112
  "resource.conflict": "It was not changed: somebody else changed this entry first. Reload it and try again.",
99
113
  "resource.forbidden": "It was not changed: you may not change this entry.",
100
114
  "resource.failed": "The change was not saved. Reload the latest version and try again.",
@@ -119,7 +133,6 @@
119
133
  "node.share": "Share",
120
134
  "node.shareAction": "Grant access",
121
135
  "node.email": "Email address",
122
- "node.emailHint": "Intel cannot check whether an account exists behind this address. The grant takes effect as soon as somebody signs in with it — including after they register.",
123
136
  "node.shareWith": "Who gets access",
124
137
  "node.shareWithEmail": "One email address",
125
138
  "node.shareWithOrganization": "Everyone in the organization",
@@ -137,8 +150,13 @@
137
150
  "node.verbHint.execute": "Start the flows in here",
138
151
  "node.verbHint.share": "Give others access",
139
152
  "node.organization": "Everyone in the organization",
153
+ "node.owner": "Owner",
154
+ "node.access": "Access",
155
+ "node.organizationAccess": "Everyone in the organization has access",
156
+ "node.accessDetails": "Show access details ({count})",
157
+ "node.revokeVerb": "Revoke {verb}",
158
+ "node.revokeVerbFor": "Revoke {verb} for {recipient}",
140
159
  "node.revokeShare": "Revoke access",
141
- "node.noGrants": "Nobody outside the owner has access yet.",
142
160
  "node.shareFailed": "Access was not changed. Check your permission and try again.",
143
161
  "node.shareUnreadable": "Flows in this folder read documents this grant does not cover: {titles}.",
144
162
  "node.shareUnreadableMore": "{count} more are out of reach too, and you cannot see them.",
@@ -334,5 +352,7 @@
334
352
  "common.close": "Close",
335
353
  "common.unavailable": "Intel is currently unavailable.",
336
354
  "common.noAccess": "This is not available to you. It may not exist, or it may no longer be shared with you.",
337
- "common.noPermission": "You do not have permission to see this."
355
+ "common.noPermission": "You do not have permission to see this.",
356
+ "title.descriptionMore": "More",
357
+ "title.descriptionLess": "Less"
338
358
  }
package/src/i18n/es.json CHANGED
@@ -70,11 +70,16 @@
70
70
  "tree.move.failed.gone": "No se ha movido: esa carpeta ya no existe.",
71
71
  "tree.move.failed.other": "No se ha podido mover. Comprueba tu acceso e inténtalo de nuevo.",
72
72
  "resource.menu": "Más acciones para {title}",
73
- "resource.rename": "Cambiar el nombre",
73
+ "resource.rename": "Renombrar",
74
+ "resource.move": "Mover",
74
75
  "resource.renameTitle": "Cambiar el nombre de {title}",
75
76
  "resource.archive": "Archivar",
76
77
  "resource.export": "Exportar",
77
78
  "resource.import": "Importar",
79
+ "resource.downloadCsv": "Descargar CSV",
80
+ "resource.validate": "Comprobar",
81
+ "resource.links": "Enlaces",
82
+ "resource.share": "Compartir",
78
83
  "tree.new.importZip": "Importar un paquete zip",
79
84
  "tree.new.importFolder": "Importar una carpeta",
80
85
  "tree.importZipInto": "Importar un paquete zip en {where}",
@@ -85,16 +90,25 @@
85
90
  "archive.description": "Todo lo que has archivado, lo más reciente primero. Al restaurar, una entrada vuelve al sitio donde estaba.",
86
91
  "archive.restore": "Restaurar {title}",
87
92
  "archive.restoreAction": "Restaurar",
93
+ "archive.restoring": "Restaurando {title}",
94
+ "archive.restoringAction": "Restaurando",
88
95
  "archive.empty": "No hay nada archivado.",
89
96
  "archive.failed": "El archivo no se ha podido leer. Comprueba tu acceso e inténtalo de nuevo.",
90
97
  "archive.restoreFailed": "No se ha restaurado. Puede que otra persona lo haya cambiado — recarga e inténtalo de nuevo.",
91
98
  "archive.purge": "Eliminar «{title}» definitivamente",
92
99
  "archive.purge.title": "¿Eliminar definitivamente?",
93
100
  "archive.purge.body": "«{title}» y todo lo que le pertenece — cada versión, su contenido y sus entradas en el índice de búsqueda — desaparecerá. Esto no se puede deshacer.",
101
+ "archive.purge.links.none": "Ningún otro documento contiene un enlace a este elemento.",
102
+ "archive.purge.links.one": "Otro documento contiene un enlace a este elemento y ese enlace dejará de funcionar.",
103
+ "archive.purge.links.many": "Otros {count} documentos contienen enlaces a este elemento y dejarán de funcionar.",
104
+ "archive.purge.grants": "Sus permisos compartidos también se eliminarán.",
105
+ "archive.purge.items": "Esto elimina definitivamente {count} elementos en total.",
106
+ "archive.purge.previewLoading": "Comprobando enlaces…",
107
+ "archive.purge.previewFailed": "No se pudieron comprobar los enlaces. Recarga e inténtalo de nuevo antes de eliminar.",
94
108
  "archive.purge.confirm": "Eliminar definitivamente",
95
109
  "archive.purge.cancel": "Cancelar",
96
- "archive.purgeFailed": "No se pudo eliminar. Una carpeta debe estar vacía primero, y lo que un flujo publicado todavía usa no puede eliminarse.",
97
- "archive.archivedAt": "Archivado {when}",
110
+ "archive.purgeFailed": "No se pudo eliminar por completo. Recarga e inténtalo de nuevo.",
111
+ "archive.archived": "Archivado",
98
112
  "resource.conflict": "No se ha cambiado: otra persona ha cambiado esta entrada antes. Recárgala e inténtalo de nuevo.",
99
113
  "resource.forbidden": "No se ha cambiado: no puedes cambiar esta entrada.",
100
114
  "resource.failed": "El cambio no se ha guardado. Carga la versión más reciente e inténtalo de nuevo.",
@@ -119,7 +133,6 @@
119
133
  "node.share": "Compartir",
120
134
  "node.shareAction": "Conceder acceso",
121
135
  "node.email": "Dirección de correo",
122
- "node.emailHint": "Intel no puede comprobar si existe una cuenta detrás de esta dirección. El permiso surte efecto en cuanto alguien inicie sesión con ella, incluso si se registra más tarde.",
123
136
  "node.shareWith": "Quién obtiene acceso",
124
137
  "node.shareWithEmail": "Una dirección de correo",
125
138
  "node.shareWithOrganization": "Todos en la organización",
@@ -137,8 +150,13 @@
137
150
  "node.verbHint.execute": "Iniciar los flujos que hay aquí dentro",
138
151
  "node.verbHint.share": "Dar acceso a otras personas",
139
152
  "node.organization": "Todas las personas de la organización",
153
+ "node.owner": "Propietario",
154
+ "node.access": "Acceso",
155
+ "node.organizationAccess": "Toda la organización tiene acceso",
156
+ "node.accessDetails": "Mostrar detalles de acceso ({count})",
157
+ "node.revokeVerb": "Retirar {verb}",
158
+ "node.revokeVerbFor": "Retirar {verb} a {recipient}",
140
159
  "node.revokeShare": "Retirar el acceso",
141
- "node.noGrants": "Fuera de quien lo posee, todavía no tiene acceso nadie.",
142
160
  "node.shareFailed": "El acceso no se ha cambiado. Comprueba tu permiso e inténtalo de nuevo.",
143
161
  "node.shareUnreadable": "Los flujos de esta carpeta leen documentos que este acceso no cubre: {titles}.",
144
162
  "node.shareUnreadableMore": "{count} más también quedan fuera de alcance, y no puedes verlos.",
@@ -334,5 +352,7 @@
334
352
  "common.close": "Cerrar",
335
353
  "common.unavailable": "Intel no está disponible en este momento.",
336
354
  "common.noAccess": "Esto no está disponible para ti. Puede que no exista o que ya no esté compartido contigo.",
337
- "common.noPermission": "No tienes permiso para ver esto."
355
+ "common.noPermission": "No tienes permiso para ver esto.",
356
+ "title.descriptionMore": "Más",
357
+ "title.descriptionLess": "Menos"
338
358
  }
package/src/main.tsx CHANGED
@@ -13,6 +13,7 @@ import { createLanguages } from "@/i18n/i18n-languages/i18n-languages.ts";
13
13
  import { createIntelRouter } from "@/router/router.tsx";
14
14
  import { applyTheme, readThemeChoice, SystemThemeQuery, themeFor } from "@/theme/theme.ts";
15
15
  import { ThemeProvider } from "@/theme/theme-context.tsx";
16
+ import { RelativeClockProvider } from "@/time/relative-time.tsx";
16
17
  import { TimeZoneProvider } from "@/time/time-context.tsx";
17
18
  import "./styles.css";
18
19
  import "./theme/custom.css";
@@ -55,9 +56,11 @@ mounted.render(
55
56
  {/* ⚠️ INSIDE `I18nProvider`: the formatter needs both — the language says HOW a time is
56
57
  written, the zone WHICH time it is (#467). */}
57
58
  <TimeZoneProvider store={window.localStorage}>
58
- <QueryClientProvider client={queryClient}>
59
- <AppRoot data={data} router={router} refusal={refusal} />
60
- </QueryClientProvider>
59
+ <RelativeClockProvider>
60
+ <QueryClientProvider client={queryClient}>
61
+ <AppRoot data={data} router={router} refusal={refusal} />
62
+ </QueryClientProvider>
63
+ </RelativeClockProvider>
61
64
  </TimeZoneProvider>
62
65
  </ThemeProvider>
63
66
  </I18nProvider>
@@ -23,6 +23,7 @@ import {
23
23
  import type { I18n } from "@/i18n/i18n.types.ts";
24
24
  import { Modal } from "@/modal/modal.tsx";
25
25
  import { SaveButton, UnsavedChangesGuard } from "@/save-button/save-button.tsx";
26
+ import { TitleRowScrollArea } from "@/title-row/title-row.tsx";
26
27
 
27
28
  function storedBlocks(content: string | null): IntelEditorPartialBlock[] | undefined {
28
29
  if (!content) return undefined;
@@ -167,15 +168,7 @@ export function NodeEditor({
167
168
  because this is where `dirty` lives — moving the state up to the screen would mean the
168
169
  screen tracking every keystroke in an editor it does not own. */}
169
170
  <ActionSlot name="title-actions">
170
- {/* ⚠️ `stored` comes from the document the screen holds, not from a flag this component
171
- sets on success: it turns true when the answer that carried a version has reached the
172
- query cache, and never a moment earlier (#432). */}
173
- <SaveButton
174
- dirty={dirty}
175
- saving={save.isPending}
176
- stored={document.version !== null}
177
- onSave={() => save.mutate()}
178
- />
171
+ <SaveButton dirty={dirty} saving={save.isPending} onSave={() => save.mutate()} />
179
172
  </ActionSlot>
180
173
  <UnsavedChangesGuard dirty={dirty} />
181
174
  {save.isError && (
@@ -183,7 +176,7 @@ export function NodeEditor({
183
176
  {i18n.t("node.saveError")}
184
177
  </p>
185
178
  )}
186
- <div className="min-h-0 flex-1 overflow-y-auto py-6">
179
+ <TitleRowScrollArea className="min-h-0 flex-1 overflow-y-auto py-6">
187
180
  <DocumentLinkProvider
188
181
  value={{
189
182
  titles: new Map((resolved.data?.items ?? []).map((item) => [item.nodeId, item.title])),
@@ -219,7 +212,7 @@ export function NodeEditor({
219
212
  />
220
213
  </BlockNoteView>
221
214
  </DocumentLinkProvider>
222
- </div>
215
+ </TitleRowScrollArea>
223
216
  {picking ? (
224
217
  <DocumentPicker
225
218
  data={data}
@@ -3,6 +3,7 @@ import { useQuery } from "@tanstack/react-query";
3
3
  import { ActionSlot } from "@/app/action-slot/action-slot.tsx";
4
4
  import { useI18n } from "@/i18n/i18n-context.tsx";
5
5
  import { useIntelRouterContext } from "@/router/router-context.ts";
6
+ import { TitleRowScrollArea } from "@/title-row/title-row.tsx";
6
7
 
7
8
  /**
8
9
  * A table as a grid (#40).
@@ -59,7 +60,7 @@ export function NodeTablePanel({ node }: { node: Node }) {
59
60
  {i18n.t("node.table.undefined")}
60
61
  </p>
61
62
  ) : (
62
- <div className="min-h-0 flex-1 overflow-auto">
63
+ <TitleRowScrollArea className="min-h-0 flex-1 overflow-auto">
63
64
  <table className="w-full border-collapse text-sm">
64
65
  <caption className="sr-only">{node.title}</caption>
65
66
  <thead className="sticky top-0 bg-card">
@@ -97,7 +98,7 @@ export function NodeTablePanel({ node }: { node: Node }) {
97
98
  {table.data.rows.length === 0 ? (
98
99
  <p className="p-6 text-sm text-muted-foreground">{i18n.t("node.table.empty")}</p>
99
100
  ) : null}
100
- </div>
101
+ </TitleRowScrollArea>
101
102
  )}
102
103
  </div>
103
104
  );
@@ -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, History, Paperclip } from "lucide-react";
5
- import { lazy, Suspense, useState } from "react";
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 { useDateTime } from "@/time/time-context.tsx";
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
- <div className="flex h-full min-h-0 flex-col">
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
- </div>
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
- }