@anchrd/intel-ui 0.21.0 → 0.23.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.
@@ -12,6 +12,8 @@ import type {
12
12
  ListFlowsInput,
13
13
  PreviewFlowPublishInput,
14
14
  PublishFlowInput,
15
+ PurgeFlowInput,
16
+ PurgeFlowResult,
15
17
  RelationGraph,
16
18
  RelationGraphScope,
17
19
  SaveFlowVersionInput,
@@ -37,6 +39,8 @@ import type {
37
39
  NodeList,
38
40
  NodeTable,
39
41
  NodeVersionList,
42
+ PurgeNodeInput,
43
+ PurgeNodeResult,
40
44
  ReindexResult,
41
45
  ResolveNodeLinksInput,
42
46
  ResolveNodeLinksResult,
@@ -125,6 +129,10 @@ export interface IntelDataProvider {
125
129
  listNodeVersions(nodeId: string): Promise<NodeVersionList>;
126
130
  updateNode(input: UpdateNodeInput): Promise<Node>;
127
131
  archiveNode(input: ArchiveNodeInput): Promise<Node>;
132
+ // ⚠️ The one way across this seam after which nothing is really left (#457). The title comes back
133
+ // because nothing can look it up afterwards.
134
+ purgeNode(input: PurgeNodeInput): Promise<PurgeNodeResult>;
135
+ purgeFlow(input: PurgeFlowInput): Promise<PurgeFlowResult>;
128
136
  searchNodes(input: SearchInput): Promise<SearchResult>;
129
137
  listGrants(resourceId: string): Promise<ResourceGrantList>;
130
138
  // The grant, and what the grant does not cover: the documents the flows in this folder read that
@@ -5,6 +5,7 @@ import { useState } from "react";
5
5
  import type { I18n } from "@/i18n/i18n.types.ts";
6
6
  import { useI18n } from "@/i18n/i18n-context.tsx";
7
7
  import { useIntelRouterContext } from "@/router/router-context.ts";
8
+ import { useDateTime } from "@/time/time-context.tsx";
8
9
 
9
10
  // One page is what a person reads before deciding, not what a database can return. The server caps
10
11
  // it at fifty; twenty is what fits on a screen without scrolling past the answer.
@@ -115,6 +116,7 @@ export function FlowRuns({ flowId }: { flowId: string }) {
115
116
 
116
117
  function RunRow({ run, open, toggle }: { run: FlowRunSummary; open: boolean; toggle(): void }) {
117
118
  const i18n = useI18n();
119
+ const dateTime = useDateTime();
118
120
  const failed = run.status === "failed";
119
121
  const Chevron = open ? ChevronDown : ChevronRight;
120
122
  return (
@@ -133,7 +135,7 @@ function RunRow({ run, open, toggle }: { run: FlowRunSummary; open: boolean; tog
133
135
  >
134
136
  {i18n.t(`runs.status.${run.status}`)}
135
137
  </span>
136
- <span>{new Date(run.startedAt).toLocaleString(i18n.locale)}</span>
138
+ <span>{dateTime.at(run.startedAt)}</span>
137
139
  <span className="text-muted-foreground">
138
140
  {run.durationMs === null
139
141
  ? i18n.t("runs.stillRunning")
@@ -24,7 +24,6 @@ import {
24
24
  } from "@xyflow/react";
25
25
  import { FileSearch, Info, Send, Wrench } from "lucide-react";
26
26
  import { useEffect, useId, useMemo, useState } from "react";
27
- import { ActionSlot } from "@/app/action-slot/action-slot.tsx";
28
27
  import { TreeEntryMediaType } from "@/app/app-tree/app-tree.tsx";
29
28
  import { ViewToggle } from "@/app/view-toggle/view-toggle.tsx";
30
29
  import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
@@ -457,16 +456,6 @@ function FlowsEditor() {
457
456
 
458
457
  return (
459
458
  <div className="flex h-full min-h-0 flex-col">
460
- {/* Only the view switch belongs to the area: it says how the thing the breadcrumb names is
461
- being shown, and it is mounted only where there is a flow to draw — a switch with nothing
462
- to point at is what #19 rules out for the header. A flow is the one level that has runs,
463
- so it is the one that offers the third view (#35). Everything that acts on the flow itself
464
- left this bar for the flow's own title line below (#24). */}
465
- {selectedFlowId ? (
466
- <ActionSlot>
467
- <ViewToggle views={["editor", "graph", "runs"]} />
468
- </ActionSlot>
469
- ) : null}
470
459
  {selectedFlowId && document.data ? (
471
460
  <FlowTitle
472
461
  flow={document.data.flow}
@@ -736,6 +725,11 @@ function FlowTitle({
736
725
  // hit without looking is worth more than the primary button being outermost. `TitleRow` is what
737
726
  // enforces that — nothing passed in here can get past the menu.
738
727
  <TitleRow title={flow.title} description={flow.description} target={{ type: "flow", flow }}>
728
+ {/* ⚠️ #454: the toggle used to stand in the global header, beside the search — where what
729
+ applies EVERYWHERE stands. But it says how THIS flow is shown, and so belongs in the line
730
+ that names this flow. A flow is the only level with runs, and therefore the only one with
731
+ three views (#35). */}
732
+ <ViewToggle views={["editor", "graph", "runs"]} />
739
733
  <TooltipProvider delayDuration={300}>
740
734
  <Tooltip>
741
735
  <TooltipTrigger asChild>
@@ -14,6 +14,7 @@ import { useI18n } from "@/i18n/i18n-context.tsx";
14
14
  import { kindIcons } from "@/kind-icon.ts";
15
15
  import { ResourceMenu, type ResourceTarget } from "@/resource-menu/resource-menu.tsx";
16
16
  import { useIntelRouterContext } from "@/router/router-context.ts";
17
+ import { useDateTime } from "@/time/time-context.tsx";
17
18
 
18
19
  // The same record the menu is handed everywhere else, read off a level row. A re-labelling, never a
19
20
  // second source of truth — a rename works on the whole record, not on a summary of it.
@@ -41,6 +42,7 @@ function targetOf(entry: TreeEntry): ResourceTarget {
41
42
  export function FolderContents({ folderId }: { folderId: string }) {
42
43
  const { data } = useIntelRouterContext();
43
44
  const i18n = useI18n();
45
+ const dateTime = useDateTime();
44
46
  const navigate = useNavigate();
45
47
  const level = useQuery({
46
48
  queryKey: treeLevelKey(folderId),
@@ -126,7 +128,7 @@ export function FolderContents({ folderId }: { folderId: string }) {
126
128
  </button>
127
129
  </TableCell>
128
130
  <TableCell className="px-4 text-right text-sm text-muted-foreground tabular-nums">
129
- {new Date(changed).toLocaleDateString(i18n.locale)}
131
+ {dateTime.on(changed)}
130
132
  </TableCell>
131
133
  {/* ⚠️ The same menu as the title line of the open thing, not a shorter one built
132
134
  for here. #58 collected every action in one place so that place is always the
package/src/i18n/de.json CHANGED
@@ -2,8 +2,6 @@
2
2
  "$locale": "de-DE",
3
3
  "app.name": "Intel",
4
4
  "nav.primary": "Hauptnavigation",
5
- "nav.nodes": "Wissen",
6
- "nav.flows": "Flows",
7
5
  "nav.tools": "Werkzeuge",
8
6
  "shell.toggleSidebar": "Seitenleiste ein- oder ausklappen",
9
7
  "shell.resizeSidebar": "Breite der Seitenleiste",
@@ -33,7 +31,8 @@
33
31
  "tree.expandCalls": "Die Flows zeigen, die {title} aufruft",
34
32
  "tree.collapseCalls": "Die Flows verbergen, die {title} aufruft",
35
33
  "tree.add": "Zu {title} hinzufügen",
36
- "tree.addRoot": "Auf oberster Ebene anlegen",
34
+ "tree.addRoot": "Element",
35
+ "tree.addRoot.long": "Auf oberster Ebene anlegen",
37
36
  "tree.new.folder": "Neuer Ordner",
38
37
  "tree.new.document": "Neues Dokument",
39
38
  "tree.new.table": "Neue Tabelle",
@@ -50,6 +49,7 @@
50
49
  "tree.kind.table": "Tabelle",
51
50
  "tree.kind.flow": "Flow",
52
51
  "tree.move.action": "Verschieben nach …",
52
+ "tree.move.hint": "oder ziehen",
53
53
  "tree.move.title": "{title} verschieben",
54
54
  "tree.move.root": "Oberste Ebene",
55
55
  "tree.move.dropRoot": "Hier ablegen, um auf die oberste Ebene zu verschieben",
@@ -88,6 +88,12 @@
88
88
  "archive.empty": "Es ist nichts archiviert.",
89
89
  "archive.failed": "Das Archiv konnte nicht gelesen werden. Prüfe deinen Zugriff und versuche es erneut.",
90
90
  "archive.restoreFailed": "Es wurde nicht wiederhergestellt. Vielleicht hat jemand anderes es geändert — lade neu und versuche es erneut.",
91
+ "archive.purge": "{title} endgültig löschen",
92
+ "archive.purge.title": "Endgültig löschen?",
93
+ "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.",
94
+ "archive.purge.confirm": "Endgültig löschen",
95
+ "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.",
91
97
  "archive.archivedAt": "Archiviert {when}",
92
98
  "resource.conflict": "Nicht geändert: Jemand anderes hat diesen Eintrag zuerst geändert. Lade ihn neu und versuche es erneut.",
93
99
  "resource.forbidden": "Nicht geändert: Du darfst diesen Eintrag nicht ändern.",
@@ -97,6 +103,7 @@
97
103
  "node.attachmentHelp": "Die kanonische Datei liegt privat in Intel. Ihre KI-lesbare Projektion wird getrennt indexiert.",
98
104
  "node.table.summary": "{rows} Zeilen, {columns} Spalten",
99
105
  "node.table.download": "CSV herunterladen",
106
+ "node.table.downloadFailed": "Die CSV konnte nicht erzeugt werden. Möglicherweise ist die Tabelle noch leer.",
100
107
  "node.table.empty": "Noch keine Zeilen. Zeilen werden von Flows angehängt.",
101
108
  "node.table.undefined": "Diese Tabelle hat noch keine Spalten.",
102
109
  "node.select": "Wähle ein Dokument oder einen Ordner, um damit zu arbeiten.",
@@ -166,7 +173,7 @@
166
173
  "node.saveError": "Dieses Dokument konnte nicht gespeichert werden. Lade neu und versuche es erneut.",
167
174
  "tools.signingIn": "Du wirst am Firmenportal angemeldet …",
168
175
  "tools.noAccess": "Kein Zugriff auf das Firmenportal",
169
- "tools.noAccessHelp": "Dein Intel-Konto erreicht das MCP-Portal der Firma nicht, deshalb gibt es hier nichts zu zeigen. Wer welchen Server nutzen darf, entscheidet eine Administration im Portal; bitte darum, aufgenommen zu werden.",
176
+ "tools.noAccessHelp": "Dein Konto darf das MCP-Portal der Firma nicht erreichen, deshalb gibt es hier nichts zu zeigen. Diese Erlaubnis wird nicht in Intel vergeben bitte die Stelle darum, die in deiner Organisation die Konten verwaltet.",
170
177
  "tools.empty": "Keine Werkzeuge für dich verfügbar",
171
178
  "tools.emptyHelp": "Das Portal hat geantwortet, und es bietet deinem Konto keine Werkzeuge an. Welche Server es gibt und wer sie erreicht, entscheidet eine Administration im Portal.",
172
179
  "tools.unreachable": "Portal nicht erreichbar",
@@ -290,6 +297,9 @@
290
297
  "settings.description": "Sprache, Erscheinungsbild und Zeitzone. Alle drei gelten sofort und werden auf diesem Gerät gemerkt.",
291
298
  "settings.language": "Sprache",
292
299
  "settings.appearance": "Erscheinungsbild",
300
+ "settings.timezone": "Zeitzone",
301
+ "settings.timezone.device": "Dieses Gerät ({zone})",
302
+ "settings.timezone.hint": "Zeiten werden nur in dieser Zone GELESEN. Gespeichert und übertragen wird weiterhin UTC.",
293
303
  "settings.appearance.system": "Dem System folgen",
294
304
  "settings.appearance.light": "Hell",
295
305
  "settings.appearance.dark": "Dunkel",
@@ -303,6 +313,7 @@
303
313
  "reindex.failed": "Der Neuaufbau wurde nicht gestartet. Prüfe deine Berechtigung und versuche es noch einmal.",
304
314
  "auth.signOut": "Abmelden",
305
315
  "auth.signOutFailed": "Das Abmelden ist fehlgeschlagen. Prüfe deine Verbindung und versuche es erneut.",
316
+ "signIn.unreachable": "Intel war nicht erreichbar. Prüfe deine Verbindung und versuche es erneut.",
306
317
  "signIn.refusedTitle": "Angemeldet, aber nicht angenommen",
307
318
  "signIn.refusedBody": "Gate hat dich angemeldet, und diese Installation hat den Ausweis nicht angenommen. Du wurdest nicht zurück zum Login geschickt, denn dort beginnt die Schleife.",
308
319
  "signIn.refusedLikely": "Meistens ist die Sitzung zwischen den beiden Schritten abgelaufen, oder der Ausweis wurde für eine andere Installation ausgestellt. Ein Neuladen ist einen Versuch wert; danach muss sich anschauen, wer diese Installation betreibt.",
package/src/i18n/en.json CHANGED
@@ -2,8 +2,6 @@
2
2
  "$locale": "en-US",
3
3
  "app.name": "Intel",
4
4
  "nav.primary": "Primary navigation",
5
- "nav.nodes": "Intelligence",
6
- "nav.flows": "Flows",
7
5
  "nav.tools": "Tools",
8
6
  "shell.toggleSidebar": "Collapse or expand the sidebar",
9
7
  "shell.resizeSidebar": "Sidebar width",
@@ -33,7 +31,8 @@
33
31
  "tree.expandCalls": "Show the flows {title} calls",
34
32
  "tree.collapseCalls": "Hide the flows {title} calls",
35
33
  "tree.add": "Add to {title}",
36
- "tree.addRoot": "Add at the top level",
34
+ "tree.addRoot": "Element",
35
+ "tree.addRoot.long": "Add at the top level",
37
36
  "tree.new.folder": "New folder",
38
37
  "tree.new.document": "New document",
39
38
  "tree.new.table": "New table",
@@ -50,6 +49,7 @@
50
49
  "tree.kind.table": "Table",
51
50
  "tree.kind.flow": "Flow",
52
51
  "tree.move.action": "Move to…",
52
+ "tree.move.hint": "or drag",
53
53
  "tree.move.title": "Move {title}",
54
54
  "tree.move.root": "Top level",
55
55
  "tree.move.dropRoot": "Drop here to move to the top level",
@@ -88,6 +88,12 @@
88
88
  "archive.empty": "Nothing is archived.",
89
89
  "archive.failed": "The archive could not be read. Check your access and try again.",
90
90
  "archive.restoreFailed": "It was not restored. Somebody else may have changed it — reload and try again.",
91
+ "archive.purge": "Delete {title} for good",
92
+ "archive.purge.title": "Delete this for good?",
93
+ "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.",
94
+ "archive.purge.confirm": "Delete for good",
95
+ "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.",
91
97
  "archive.archivedAt": "Archived {when}",
92
98
  "resource.conflict": "It was not changed: somebody else changed this entry first. Reload it and try again.",
93
99
  "resource.forbidden": "It was not changed: you may not change this entry.",
@@ -97,6 +103,7 @@
97
103
  "node.attachmentHelp": "The canonical file is stored privately in Intel. Its AI-readable projection is indexed separately.",
98
104
  "node.table.summary": "{rows} rows, {columns} columns",
99
105
  "node.table.download": "Download CSV",
106
+ "node.table.downloadFailed": "The CSV could not be created. The table may still be empty.",
100
107
  "node.table.empty": "No rows yet. Rows are appended by flows.",
101
108
  "node.table.undefined": "This table has no columns yet.",
102
109
  "node.select": "Select a document or folder to work with it.",
@@ -166,7 +173,7 @@
166
173
  "node.saveError": "This document could not be saved. Reload and try again.",
167
174
  "tools.signingIn": "Signing you in to the company portal…",
168
175
  "tools.noAccess": "No access to the company portal",
169
- "tools.noAccessHelp": "Your Intel account does not reach the company MCP portal, so there is nothing to show here. An administrator decides in the portal who may use which server; ask them to include you.",
176
+ "tools.noAccessHelp": "Your account is not permitted to reach the company MCP portal, so there is nothing to show here. That permission is not given in Intel ask whoever administers the accounts of your organisation to grant it.",
170
177
  "tools.empty": "No tools available to you",
171
178
  "tools.emptyHelp": "The portal answered, and it offers your account no tools. An administrator decides in the portal which servers exist and who may reach them.",
172
179
  "tools.unreachable": "Portal not reachable",
@@ -290,6 +297,9 @@
290
297
  "settings.description": "Language, appearance and timezone. All three take effect immediately and are remembered on this device.",
291
298
  "settings.language": "Language",
292
299
  "settings.appearance": "Appearance",
300
+ "settings.timezone": "Time zone",
301
+ "settings.timezone.device": "This device ({zone})",
302
+ "settings.timezone.hint": "Times are only READ in this zone. What is stored and sent stays UTC.",
293
303
  "settings.appearance.system": "Follow the system",
294
304
  "settings.appearance.light": "Light",
295
305
  "settings.appearance.dark": "Dark",
@@ -303,6 +313,7 @@
303
313
  "reindex.failed": "The rebuild was not started. Check your access and try again.",
304
314
  "auth.signOut": "Sign out",
305
315
  "auth.signOutFailed": "Signing out failed. Check your connection and try again.",
316
+ "signIn.unreachable": "Intel could not be reached. Check your connection and try again.",
306
317
  "signIn.refusedTitle": "Signed in, but not accepted",
307
318
  "signIn.refusedBody": "Gate signed you in, and this installation did not accept the credential. You have not been sent back to the login, because that is where the loop starts.",
308
319
  "signIn.refusedLikely": "Most often the session expired between the two steps, or the credential was issued for a different installation. Reloading is worth one try; after that, whoever runs this installation has to look.",
package/src/i18n/es.json CHANGED
@@ -2,8 +2,6 @@
2
2
  "$locale": "es-ES",
3
3
  "app.name": "Intel",
4
4
  "nav.primary": "Navegación principal",
5
- "nav.nodes": "Conocimiento",
6
- "nav.flows": "Flujos",
7
5
  "nav.tools": "Herramientas",
8
6
  "shell.toggleSidebar": "Plegar o desplegar la barra lateral",
9
7
  "shell.resizeSidebar": "Ancho de la barra lateral",
@@ -33,7 +31,8 @@
33
31
  "tree.expandCalls": "Mostrar los flujos a los que llama {title}",
34
32
  "tree.collapseCalls": "Ocultar los flujos a los que llama {title}",
35
33
  "tree.add": "Añadir a {title}",
36
- "tree.addRoot": "Añadir en el nivel superior",
34
+ "tree.addRoot": "Elemento",
35
+ "tree.addRoot.long": "Añadir en el nivel superior",
37
36
  "tree.new.folder": "Carpeta nueva",
38
37
  "tree.new.document": "Documento nuevo",
39
38
  "tree.new.table": "Tabla nueva",
@@ -50,6 +49,7 @@
50
49
  "tree.kind.table": "Tabla",
51
50
  "tree.kind.flow": "Flujo",
52
51
  "tree.move.action": "Mover a…",
52
+ "tree.move.hint": "o arrastra",
53
53
  "tree.move.title": "Mover {title}",
54
54
  "tree.move.root": "Nivel superior",
55
55
  "tree.move.dropRoot": "Suelta aquí para mover al nivel superior",
@@ -88,6 +88,12 @@
88
88
  "archive.empty": "No hay nada archivado.",
89
89
  "archive.failed": "El archivo no se ha podido leer. Comprueba tu acceso e inténtalo de nuevo.",
90
90
  "archive.restoreFailed": "No se ha restaurado. Puede que otra persona lo haya cambiado — recarga e inténtalo de nuevo.",
91
+ "archive.purge": "Eliminar «{title}» definitivamente",
92
+ "archive.purge.title": "¿Eliminar definitivamente?",
93
+ "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.",
94
+ "archive.purge.confirm": "Eliminar definitivamente",
95
+ "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.",
91
97
  "archive.archivedAt": "Archivado {when}",
92
98
  "resource.conflict": "No se ha cambiado: otra persona ha cambiado esta entrada antes. Recárgala e inténtalo de nuevo.",
93
99
  "resource.forbidden": "No se ha cambiado: no puedes cambiar esta entrada.",
@@ -97,6 +103,7 @@
97
103
  "node.attachmentHelp": "El archivo canónico se guarda en privado en Intel. Su proyección legible por la IA se indexa por separado.",
98
104
  "node.table.summary": "{rows} filas, {columns} columnas",
99
105
  "node.table.download": "Descargar CSV",
106
+ "node.table.downloadFailed": "No se pudo crear el CSV. Puede que la tabla todavía esté vacía.",
100
107
  "node.table.empty": "Todavía no hay filas. Las filas las añaden los flujos.",
101
108
  "node.table.undefined": "Esta tabla todavía no tiene columnas.",
102
109
  "node.select": "Elige un documento o una carpeta para trabajar con ello.",
@@ -166,7 +173,7 @@
166
173
  "node.saveError": "Este documento no se ha podido guardar. Recarga e inténtalo de nuevo.",
167
174
  "tools.signingIn": "Iniciando tu sesión en el portal de la empresa…",
168
175
  "tools.noAccess": "Sin acceso al portal de la empresa",
169
- "tools.noAccessHelp": "Tu cuenta de Intel no llega al portal MCP de la empresa, así que aquí no hay nada que mostrar. Quién puede usar qué servidor lo decide la administración en el portal; pídeles que te incluyan.",
176
+ "tools.noAccessHelp": "Tu cuenta no tiene permiso para llegar al portal MCP de la empresa, así que aquí no hay nada que mostrar. Ese permiso no se concede en Intel pídeselo a quien administre las cuentas de tu organización.",
170
177
  "tools.empty": "No tienes herramientas disponibles",
171
178
  "tools.emptyHelp": "El portal ha respondido, y no ofrece ninguna herramienta a tu cuenta. Qué servidores existen y quién llega a ellos lo decide la administración en el portal.",
172
179
  "tools.unreachable": "Portal no accesible",
@@ -290,6 +297,9 @@
290
297
  "settings.description": "Idioma, apariencia y zona horaria. Las tres se aplican de inmediato y se recuerdan en este dispositivo.",
291
298
  "settings.language": "Idioma",
292
299
  "settings.appearance": "Apariencia",
300
+ "settings.timezone": "Zona horaria",
301
+ "settings.timezone.device": "Este dispositivo ({zone})",
302
+ "settings.timezone.hint": "Las horas solo se LEEN en esta zona. Lo que se guarda y se envía sigue siendo UTC.",
293
303
  "settings.appearance.system": "Seguir al sistema",
294
304
  "settings.appearance.light": "Claro",
295
305
  "settings.appearance.dark": "Oscuro",
@@ -303,6 +313,7 @@
303
313
  "reindex.failed": "No se inició la reconstrucción. Comprueba tu acceso e inténtalo de nuevo.",
304
314
  "auth.signOut": "Cerrar sesión",
305
315
  "auth.signOutFailed": "El cierre de sesión ha fallado. Comprueba tu conexión e inténtalo de nuevo.",
316
+ "signIn.unreachable": "No se pudo contactar con Intel. Revisa tu conexión e inténtalo de nuevo.",
306
317
  "signIn.refusedTitle": "Sesión iniciada, pero no aceptada",
307
318
  "signIn.refusedBody": "Gate ha iniciado tu sesión, y esta instalación no ha aceptado la credencial. No se te ha devuelto al inicio de sesión, porque ahí es donde empieza el bucle.",
308
319
  "signIn.refusedLikely": "Lo más habitual es que la sesión caducara entre los dos pasos, o que la credencial se emitiera para otra instalación. Recargar merece un intento; después de eso, tiene que mirarlo quien gestiona esta instalación.",
package/src/main.tsx CHANGED
@@ -1,7 +1,7 @@
1
1
  import { QueryClientProvider } from "@tanstack/react-query";
2
- import { RouterProvider } from "@tanstack/react-router";
3
2
  import { StrictMode } from "react";
4
3
  import { createRoot } from "react-dom/client";
4
+ import { AppRoot, createRefusalStore } from "@/app-root/app-root.tsx";
5
5
  import {
6
6
  createIntelDataProvider,
7
7
  loginPath,
@@ -11,9 +11,9 @@ import { createBrowserSignIn, createSignInGuard } from "@/data/sign-in/sign-in.t
11
11
  import { I18nProvider } from "@/i18n/i18n-context.tsx";
12
12
  import { createLanguages } from "@/i18n/i18n-languages/i18n-languages.ts";
13
13
  import { createIntelRouter } from "@/router/router.tsx";
14
- import { SignInRefused } from "@/sign-in-refused/sign-in-refused.tsx";
15
14
  import { applyTheme, readThemeChoice, SystemThemeQuery, themeFor } from "@/theme/theme.ts";
16
15
  import { ThemeProvider } from "@/theme/theme-context.tsx";
16
+ import { TimeZoneProvider } from "@/time/time-context.tsx";
17
17
  import "./styles.css";
18
18
  import "./theme/custom.css";
19
19
 
@@ -35,37 +35,31 @@ const signIn = createBrowserSignIn(loginPath);
35
35
  const mounted = createRoot(root);
36
36
  const queryClient = createIntelQueryClient();
37
37
 
38
- // The shell is painted from a function because a refused sign-in has to replace it, and that
39
- // refusal arrives from inside a fetch rather than from React.
40
- let refused = false;
41
-
38
+ // ⚠️ The refusal arrives from inside a fetch rather than from React, and it used to be answered by
39
+ // a module-level `let` plus a hand-written repaint. Both are gone (#456): the flag is a store React
40
+ // can subscribe to, and everything that decides what is on screen lives in `AppRoot` — a component
41
+ // a test can render. `main.tsx` is wiring again, and only wiring.
42
+ const refusal = createRefusalStore();
42
43
  const data = createIntelDataProvider({
43
- onUnauthorized: createSignInGuard(signIn, () => {
44
- refused = true;
45
- paint();
46
- }),
44
+ onUnauthorized: createSignInGuard(signIn, () => refusal.refuse()),
47
45
  });
48
46
  const router = createIntelRouter({ data });
49
47
 
50
- function paint(): void {
51
- mounted.render(
52
- <StrictMode>
53
- {/* The refusal screen sits INSIDE the providers too: it is text like any other, and it is the
54
- one screen a reader sees when nothing else works — English-only there would be the worst
55
- place for it. */}
56
- <I18nProvider languages={languages}>
57
- <ThemeProvider store={window.localStorage}>
58
- {refused ? (
59
- <SignInRefused />
60
- ) : (
61
- <QueryClientProvider client={queryClient}>
62
- <RouterProvider router={router} />
63
- </QueryClientProvider>
64
- )}
65
- </ThemeProvider>
66
- </I18nProvider>
67
- </StrictMode>,
68
- );
69
- }
70
-
71
- paint();
48
+ mounted.render(
49
+ <StrictMode>
50
+ {/* The refusal screen sits INSIDE the providers too: it is text like any other, and it is the
51
+ one screen a reader sees when nothing else works English-only there would be the worst
52
+ place for it. */}
53
+ <I18nProvider languages={languages}>
54
+ <ThemeProvider store={window.localStorage}>
55
+ {/* ⚠️ INSIDE `I18nProvider`: the formatter needs both — the language says HOW a time is
56
+ written, the zone WHICH time it is (#467). */}
57
+ <TimeZoneProvider store={window.localStorage}>
58
+ <QueryClientProvider client={queryClient}>
59
+ <AppRoot data={data} router={router} refusal={refusal} />
60
+ </QueryClientProvider>
61
+ </TimeZoneProvider>
62
+ </ThemeProvider>
63
+ </I18nProvider>
64
+ </StrictMode>,
65
+ );
@@ -1,6 +1,5 @@
1
1
  import type { Node } from "@anchrd/intel-contract/node";
2
- import { useMutation, useQuery } from "@tanstack/react-query";
3
- import { Download } from "lucide-react";
2
+ import { useQuery } from "@tanstack/react-query";
4
3
  import { ActionSlot } from "@/app/action-slot/action-slot.tsx";
5
4
  import { useI18n } from "@/i18n/i18n-context.tsx";
6
5
  import { useIntelRouterContext } from "@/router/router-context.ts";
@@ -23,35 +22,17 @@ export function NodeTablePanel({ node }: { node: Node }) {
23
22
  queryKey: ["node-table", node.id],
24
23
  queryFn: () => data.getNodeTable(node.id),
25
24
  });
26
- // CSV is the export. The canonical body is already CSV, so downloading is handing over the bytes
27
- // that are stored rather than rendering a second format that could drift from them.
28
- const download = useMutation({
29
- mutationFn: async () => {
30
- const document_ = await data.getNode(node.id);
31
- return new Blob([document_.content ?? ""], { type: "text/csv;charset=utf-8" });
32
- },
33
- onSuccess: (blob) => {
34
- const url = URL.createObjectURL(blob);
35
- const anchor = document.createElement("a");
36
- anchor.href = url;
37
- anchor.download = node.title.toLowerCase().endsWith(".csv")
38
- ? node.title
39
- : `${node.title}.csv`;
40
- document.body.append(anchor);
41
- anchor.click();
42
- anchor.remove();
43
- setTimeout(() => URL.revokeObjectURL(url), 0);
44
- },
45
- });
46
-
47
25
  return (
48
26
  <div className="flex min-h-0 flex-1 flex-col">
49
- {/* ⚠️ Both of these used to be a second bar of their own, directly under the title line (#54):
50
- two headers stacked, and the table starting a whole row lower for it. A table is one
51
- node kind among four, not a screen with its own chrome — so the count goes beside the
52
- title as a quiet word and the export joins the buttons in the title line. They are rendered
53
- from here rather than from the screen because this is where the query lives; the screen
54
- would otherwise have to load a table it does not show. */}
27
+ {/* ⚠️ This used to be a second bar of its own, directly under the title line (#54): two
28
+ headers stacked, and the table starting a whole row lower for it. A table is one node kind
29
+ among four, not a screen with its own chrome — so the count goes beside the title as a
30
+ quiet word. It is rendered from here rather than from the screen because this is where the
31
+ query lives; the screen would otherwise have to load a table it does not show.
32
+
33
+ ⚠️ The CSV export used to stand here too, as a button WITH TEXT beside two icon buttons.
34
+ Since #454 it is an entry in the title line's three-dot menu (`resource-menu.tsx`) — where
35
+ the bundle export sits as well, because both do the same: hand out the whole thing. */}
55
36
  <ActionSlot name="title-meta">
56
37
  {table.data
57
38
  ? i18n.t("node.table.summary", {
@@ -60,27 +41,6 @@ export function NodeTablePanel({ node }: { node: Node }) {
60
41
  })
61
42
  : ""}
62
43
  </ActionSlot>
63
- <ActionSlot name="title-actions">
64
- <button
65
- type="button"
66
- onClick={() => download.mutate()}
67
- // Unchanged: nothing to export before the header is known, and a button that answers with
68
- // an empty file is worse than one that says it is not ready.
69
- disabled={download.isPending || !table.data?.columns.length}
70
- className="inline-flex h-8 items-center gap-2 rounded-md border px-3 text-sm outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
71
- >
72
- <Download aria-hidden="true" className="size-4" />
73
- {i18n.t("node.table.download")}
74
- </button>
75
- </ActionSlot>
76
- {/* ⚠️ The refusal stays down here, in the body, where a sentence has room to be read. The
77
- title line has none — a failed export that only greyed a button in a header would be a
78
- failure nobody is told about. */}
79
- {download.isError ? (
80
- <p role="alert" className="mx-6 mt-4 text-sm text-destructive">
81
- {i18n.t("node.downloadFailed")}
82
- </p>
83
- ) : null}
84
44
  {table.isPending ? (
85
45
  <p className="p-6 text-sm text-muted-foreground">{i18n.t("common.loading")}</p>
86
46
  ) : table.isError ? (
@@ -3,7 +3,6 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
3
3
  import { useNavigate, useRouterState } from "@tanstack/react-router";
4
4
  import { Download, History, Paperclip } from "lucide-react";
5
5
  import { lazy, Suspense, useState } from "react";
6
- import { ActionSlot } from "@/app/action-slot/action-slot.tsx";
7
6
  import { ViewToggle } from "@/app/view-toggle/view-toggle.tsx";
8
7
  import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
9
8
  import { refusalOf } from "@/data/request-refusal/request-refusal.ts";
@@ -13,6 +12,7 @@ import { useI18n } from "@/i18n/i18n-context.tsx";
13
12
  import { NodeTablePanel } from "@/node-table/node-table.tsx";
14
13
  import { useIntelRouterContext } from "@/router/router-context.ts";
15
14
  import { graphViewFrom, selectedFrom } from "@/router/selection-search.ts";
15
+ import { useDateTime } from "@/time/time-context.tsx";
16
16
  import { TitleRow } from "@/title-row/title-row.tsx";
17
17
 
18
18
  const NodeEditor = lazy(async () => ({
@@ -54,21 +54,24 @@ export function Nodes() {
54
54
  });
55
55
  return (
56
56
  <div className="flex h-full min-h-0 flex-col">
57
- {/* The screen owns the one action that belongs to the tree as a whole. Creating things is
58
- the tree's, in the sidebar, at the place a new thing is meant to go.
59
-
60
- ⚠️ Mounted only where there is a graph to switch to. A switch on a document would point at
61
- nothing, and the header is where that gets noticed first (#19). */}
62
- {graphable ? (
63
- <ActionSlot>
64
- <ViewToggle />
65
- </ActionSlot>
66
- ) : null}
67
57
  <section className="relative flex min-h-0 min-w-0 flex-1 flex-col bg-card">
68
58
  {/* ⚠️ Only the root draws its graph on its own. A folder's graph hangs *under* the folder's
69
59
  title line rather than in place of it (#58): the line is the one place its menu lives,
70
60
  and a folder that could only be renamed while the graph happened to be switched off
71
61
  would be a folder one cannot rename. */}
62
+ {/* ⚠️ #454 took the toggle out of the GLOBAL header — there it applied to the whole
63
+ application although it is a view onto the selected item. For an item it now stands in
64
+ that item's title line.
65
+
66
+ ⚠️ The root has no title line, and that is exactly why it gets a row of its own here.
67
+ Without it the graph of the WHOLE tree would have been unreachable after the move — the
68
+ toggle was its only way in and out. Moving a control must not abolish a view; that would
69
+ be a decision the ticket did not contain. */}
70
+ {selectedId === null ? (
71
+ <div className="flex shrink-0 items-center justify-end gap-2 border-b px-6 py-2">
72
+ <ViewToggle />
73
+ </div>
74
+ ) : null}
72
75
  {selectedId === null && selection.graph ? (
73
76
  <GraphPane
74
77
  query={relations}
@@ -122,14 +125,16 @@ export function Nodes() {
122
125
  ⚠️ The order of the right-hand group is `TitleRow`'s (#53), not this screen's: the
123
126
  version button is handed over as a child and lands before the menu whatever else
124
127
  shows up later.
125
- ⚠️ The view switch is deliberately NOT here: it changes how the current area is
126
- shown, not the document, and it stays beside the breadcrumb where the area is
127
- named. */}
128
+ ⚠️ #454: the toggle now stands HERE. It used to stand in the global header, beside
129
+ the search where what applies everywhere stands. But a graph view does not apply
130
+ everywhere, it is a view onto THIS item. It is only drawn where there is a graph: on
131
+ a document it pointed at nothing. */}
128
132
  <TitleRow
129
133
  title={selected.title}
130
134
  description={selected.description}
131
135
  target={{ type: "node", node: selected }}
132
136
  >
137
+ {graphable && <ViewToggle />}
133
138
  {selected.kind !== "folder" && (
134
139
  <TooltipProvider delayDuration={300}>
135
140
  <Tooltip>
@@ -234,6 +239,7 @@ function AttachmentPanel({ node }: { node: Node }) {
234
239
  function VersionHistory({ nodeId, close }: { nodeId: string; close(): void }) {
235
240
  const { data } = useIntelRouterContext();
236
241
  const i18n = useI18n();
242
+ const dateTime = useDateTime();
237
243
  const versions = useQuery({
238
244
  queryKey: ["node-versions", nodeId],
239
245
  queryFn: () => data.listNodeVersions(nodeId),
@@ -269,10 +275,7 @@ function VersionHistory({ nodeId, close }: { nodeId: string; close(): void }) {
269
275
  {i18n.t("node.version", { sequence: version.sequence })}
270
276
  </span>
271
277
  <time className="mt-1 block text-xs text-muted-foreground" dateTime={version.createdAt}>
272
- {new Intl.DateTimeFormat(i18n.locale, {
273
- dateStyle: "medium",
274
- timeStyle: "short",
275
- }).format(new Date(version.createdAt))}
278
+ {dateTime.at(version.createdAt)}
276
279
  </time>
277
280
  </li>
278
281
  ))}