@anchrd/intel 0.59.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/README.md +496 -0
- package/bin/intel.mjs +134 -0
- package/dist/adapters/cloudflare/cloudflare-flow-workflow.d.ts +42 -0
- package/dist/adapters/cloudflare/cloudflare-flow-workflow.js +89 -0
- package/dist/adapters/cloudflare/cloudflare.d.ts +29 -0
- package/dist/adapters/cloudflare/cloudflare.js +416 -0
- package/dist/adapters/cloudflare/cloudflare.types.d.ts +31 -0
- package/dist/adapters/cloudflare/cloudflare.types.js +1 -0
- package/dist/adapters/content/content.d.ts +3 -0
- package/dist/adapters/content/content.js +128 -0
- package/dist/adapters/content/content.types.d.ts +38 -0
- package/dist/adapters/content/content.types.js +1 -0
- package/dist/adapters/db/db-audit.d.ts +62 -0
- package/dist/adapters/db/db-audit.js +150 -0
- package/dist/adapters/db/db-boards.d.ts +6 -0
- package/dist/adapters/db/db-boards.js +324 -0
- package/dist/adapters/db/db-feed.d.ts +30 -0
- package/dist/adapters/db/db-feed.js +276 -0
- package/dist/adapters/db/db-flows.d.ts +42 -0
- package/dist/adapters/db/db-flows.js +1311 -0
- package/dist/adapters/db/db-grants.d.ts +133 -0
- package/dist/adapters/db/db-grants.js +269 -0
- package/dist/adapters/db/db-indexing.d.ts +3 -0
- package/dist/adapters/db/db-indexing.js +200 -0
- package/dist/adapters/db/db-oauth.d.ts +3 -0
- package/dist/adapters/db/db-oauth.js +26 -0
- package/dist/adapters/db/db-prompts.d.ts +46 -0
- package/dist/adapters/db/db-prompts.js +144 -0
- package/dist/adapters/db/db.d.ts +6 -0
- package/dist/adapters/db/db.js +1687 -0
- package/dist/adapters/db/db.types.d.ts +16 -0
- package/dist/adapters/db/db.types.js +1 -0
- package/dist/adapters/document-converter/document-converter.d.ts +2 -0
- package/dist/adapters/document-converter/document-converter.js +26 -0
- package/dist/adapters/document-converter/document-converter.types.d.ts +17 -0
- package/dist/adapters/document-converter/document-converter.types.js +1 -0
- package/dist/adapters/flow-runtime/flow-runtime.d.ts +3 -0
- package/dist/adapters/flow-runtime/flow-runtime.js +22 -0
- package/dist/adapters/flow-runtime/flow-runtime.types.d.ts +19 -0
- package/dist/adapters/flow-runtime/flow-runtime.types.js +1 -0
- package/dist/adapters/index-queue/index-queue.d.ts +10 -0
- package/dist/adapters/index-queue/index-queue.js +12 -0
- package/dist/adapters/index-queue/index-queue.types.d.ts +11 -0
- package/dist/adapters/index-queue/index-queue.types.js +1 -0
- package/dist/adapters/json-schema/json-schema.d.ts +2 -0
- package/dist/adapters/json-schema/json-schema.js +77 -0
- package/dist/adapters/openid/openid.d.ts +5 -0
- package/dist/adapters/openid/openid.js +301 -0
- package/dist/adapters/portal-tokens/portal-tokens.d.ts +8 -0
- package/dist/adapters/portal-tokens/portal-tokens.js +79 -0
- package/dist/adapters/remote-tools/remote-tools.d.ts +5 -0
- package/dist/adapters/remote-tools/remote-tools.js +129 -0
- package/dist/adapters/semantic-index/semantic-index.d.ts +2 -0
- package/dist/adapters/semantic-index/semantic-index.js +139 -0
- package/dist/adapters/semantic-index/semantic-index.types.d.ts +56 -0
- package/dist/adapters/semantic-index/semantic-index.types.js +1 -0
- package/dist/adapters/session-cookie/session-cookie.d.ts +5 -0
- package/dist/adapters/session-cookie/session-cookie.js +87 -0
- package/dist/adapters/tool-source-policy/tool-source-policy.d.ts +1 -0
- package/dist/adapters/tool-source-policy/tool-source-policy.js +22 -0
- package/dist/audit/audit.d.ts +2 -0
- package/dist/audit/audit.js +87 -0
- package/dist/audit/audit.types.d.ts +23 -0
- package/dist/audit/audit.types.js +1 -0
- package/dist/auth/auth.d.ts +2 -0
- package/dist/auth/auth.js +284 -0
- package/dist/auth/auth.types.d.ts +108 -0
- package/dist/auth/auth.types.js +1 -0
- package/dist/boards/boards.d.ts +44 -0
- package/dist/boards/boards.js +447 -0
- package/dist/boards/boards.types.d.ts +117 -0
- package/dist/boards/boards.types.js +1 -0
- package/dist/build/build.d.ts +2 -0
- package/dist/build/build.js +192 -0
- package/dist/build/build.types.d.ts +17 -0
- package/dist/build/build.types.js +1 -0
- package/dist/bundle/bundle.d.ts +4 -0
- package/dist/bundle/bundle.js +1194 -0
- package/dist/bundle/bundle.types.d.ts +33 -0
- package/dist/bundle/bundle.types.js +1 -0
- package/dist/cli/cli.d.ts +4 -0
- package/dist/cli/cli.js +262 -0
- package/dist/cli/cli.types.d.ts +14 -0
- package/dist/cli/cli.types.js +1 -0
- package/dist/contract/audit.d.ts +50 -0
- package/dist/contract/audit.js +72 -0
- package/dist/contract/board.d.ts +150 -0
- package/dist/contract/board.js +262 -0
- package/dist/contract/bundle.d.ts +147 -0
- package/dist/contract/bundle.js +139 -0
- package/dist/contract/contract.d.ts +112 -0
- package/dist/contract/contract.js +143 -0
- package/dist/contract/feed.d.ts +126 -0
- package/dist/contract/feed.js +149 -0
- package/dist/contract/flow-run.d.ts +346 -0
- package/dist/contract/flow-run.js +194 -0
- package/dist/contract/flow.d.ts +1057 -0
- package/dist/contract/flow.js +552 -0
- package/dist/contract/node.d.ts +551 -0
- package/dist/contract/node.js +456 -0
- package/dist/contract/share.d.ts +216 -0
- package/dist/contract/share.js +134 -0
- package/dist/contract/table.d.ts +171 -0
- package/dist/contract/table.js +117 -0
- package/dist/contract/tool.d.ts +122 -0
- package/dist/contract/tool.js +172 -0
- package/dist/feed/feed.d.ts +2 -0
- package/dist/feed/feed.js +77 -0
- package/dist/feed/feed.types.d.ts +23 -0
- package/dist/feed/feed.types.js +1 -0
- package/dist/flows/flows.d.ts +41 -0
- package/dist/flows/flows.js +2216 -0
- package/dist/flows/flows.types.d.ts +339 -0
- package/dist/flows/flows.types.js +1 -0
- package/dist/http/http.d.ts +9 -0
- package/dist/http/http.js +874 -0
- package/dist/http/http.types.d.ts +22 -0
- package/dist/http/http.types.js +1 -0
- package/dist/indexing/indexing.d.ts +6 -0
- package/dist/indexing/indexing.js +239 -0
- package/dist/indexing/indexing.types.d.ts +11 -0
- package/dist/indexing/indexing.types.js +1 -0
- package/dist/intel/intel.d.ts +3 -0
- package/dist/intel/intel.js +194 -0
- package/dist/intel/intel.types.d.ts +24 -0
- package/dist/intel/intel.types.js +1 -0
- package/dist/mcp/mcp.d.ts +2 -0
- package/dist/mcp/mcp.js +1292 -0
- package/dist/mcp/mcp.types.d.ts +25 -0
- package/dist/mcp/mcp.types.js +1 -0
- package/dist/nodes/document-links/document-links.d.ts +20 -0
- package/dist/nodes/document-links/document-links.js +57 -0
- package/dist/nodes/nodes.d.ts +2 -0
- package/dist/nodes/nodes.js +1667 -0
- package/dist/nodes/nodes.types.d.ts +511 -0
- package/dist/nodes/nodes.types.js +1 -0
- package/dist/prepare/prepare.d.ts +4 -0
- package/dist/prepare/prepare.js +16 -0
- package/dist/prepare/prepare.types.d.ts +9 -0
- package/dist/prepare/prepare.types.js +1 -0
- package/dist/prompts/prompts.d.ts +2 -0
- package/dist/prompts/prompts.js +65 -0
- package/dist/prompts/prompts.types.d.ts +71 -0
- package/dist/prompts/prompts.types.js +1 -0
- package/dist/shared/callers/callers.d.ts +28 -0
- package/dist/shared/callers/callers.js +30 -0
- package/dist/shared/csv/csv.d.ts +13 -0
- package/dist/shared/csv/csv.js +85 -0
- package/dist/shared/document-text/document-text.d.ts +21 -0
- package/dist/shared/document-text/document-text.js +31 -0
- package/dist/shared/gate-authorization/gate-authorization.d.ts +5 -0
- package/dist/shared/gate-authorization/gate-authorization.js +26 -0
- package/dist/shared/grant-expiry/grant-expiry.d.ts +19 -0
- package/dist/shared/grant-expiry/grant-expiry.js +26 -0
- package/dist/shared/intel-error/intel-error.d.ts +22 -0
- package/dist/shared/intel-error/intel-error.js +27 -0
- package/dist/shared/plain-title/plain-title.d.ts +16 -0
- package/dist/shared/plain-title/plain-title.js +45 -0
- package/dist/shared/problem-details/problem-details.d.ts +30 -0
- package/dist/shared/problem-details/problem-details.js +18 -0
- package/dist/shared/report-unexpected-error/report-unexpected-error.d.ts +1 -0
- package/dist/shared/report-unexpected-error/report-unexpected-error.js +37 -0
- package/dist/shared/safe-return-path/safe-return-path.d.ts +2 -0
- package/dist/shared/safe-return-path/safe-return-path.js +11 -0
- package/dist/shared/sha256/sha256.d.ts +2 -0
- package/dist/shared/sha256/sha256.js +14 -0
- package/dist/tools/tool-servers/tool-servers.d.ts +46 -0
- package/dist/tools/tool-servers/tool-servers.js +177 -0
- package/dist/tools/tools.d.ts +2 -0
- package/dist/tools/tools.js +207 -0
- package/dist/tools/tools.types.d.ts +56 -0
- package/dist/tools/tools.types.js +1 -0
- package/examples/branding/favicon.svg +5 -0
- package/examples/branding/logo.svg +5 -0
- package/examples/branding/theme.css +16 -0
- package/examples/dev.vars.example +7 -0
- package/examples/intel.json +9 -0
- package/migrations/0000_intel_foundation.sql +203 -0
- package/migrations/0001_portal_native_tools.sql +21 -0
- package/migrations/0002_flows_in_the_knowledge_tree.sql +34 -0
- package/migrations/0003_folder_permissions.sql +211 -0
- package/migrations/0004_subflow_runs.sql +14 -0
- package/migrations/0005_flow_node_cleanup.sql +28 -0
- package/migrations/0005_tables_in_the_knowledge_tree.sql +68 -0
- package/migrations/0006_links_are_written_in_the_text.sql +20 -0
- package/migrations/0007_no_node_waits.sql +69 -0
- package/migrations/0008_three_layers.sql +130 -0
- package/migrations/0009_no_context_policy.sql +48 -0
- package/migrations/0010_no_run_waits_a_year.sql +27 -0
- package/migrations/0011_one_name_for_the_tree.sql +53 -0
- package/migrations/0012_table_snapshots.sql +29 -0
- package/migrations/0017_a_vector_per_card.sql +38 -0
- package/migrations/0018_no_context_policy_at_last.sql +97 -0
- package/migrations/0019_one_name_for_the_grants.sql +52 -0
- package/migrations/0020_cascade_purge_replay.sql +11 -0
- package/migrations/0021_a_flow_carries_its_own_grant.sql +44 -0
- package/migrations/0022_a_cursor_over_the_journal.sql +14 -0
- package/migrations/0023_a_board_and_its_tasks.sql +116 -0
- package/migrations/0024_the_archive_is_a_column.sql +33 -0
- package/migrations/0025_archiving_takes_the_subtree.sql +29 -0
- package/migrations/0026_one_feed_over_two_kinds.sql +23 -0
- package/migrations/0027_the_runs_of_every_flow.sql +27 -0
- package/migrations/0028_a_prompt_name_over_two_kinds.sql +70 -0
- package/package.json +118 -0
- package/src/contract/CLAUDE.md +152 -0
- package/src/contract/README.md +102 -0
- package/src/contract/audit.ts +85 -0
- package/src/contract/board.ts +306 -0
- package/src/contract/bundle.ts +157 -0
- package/src/contract/contract.ts +161 -0
- package/src/contract/feed.ts +165 -0
- package/src/contract/flow-run.ts +241 -0
- package/src/contract/flow.ts +675 -0
- package/src/contract/node.ts +583 -0
- package/src/contract/share.ts +196 -0
- package/src/contract/table.ts +178 -0
- package/src/contract/tool.ts +202 -0
- package/ui/README.md +110 -0
- package/ui/components.json +21 -0
- package/ui/index.html +13 -0
- package/ui/package.json +11 -0
- package/ui/scripts/lint-tokens.mjs +187 -0
- package/ui/src/access-summary/access-summary.tsx +385 -0
- package/ui/src/app/action-slot/action-slot.tsx +27 -0
- package/ui/src/app/app-sidebar/app-sidebar.tsx +71 -0
- package/ui/src/app/app-tree/app-tree.tsx +1175 -0
- package/ui/src/app/app.tsx +87 -0
- package/ui/src/app/header-search/header-search.tsx +294 -0
- package/ui/src/app/reindex-dialog/reindex-dialog.tsx +79 -0
- package/ui/src/app/settings-dialog/settings-dialog.tsx +135 -0
- package/ui/src/app/sidebar-preferences/sidebar-preferences.ts +68 -0
- package/ui/src/app/sidebar-preferences/sidebar-preferences.types.ts +15 -0
- package/ui/src/app/sidebar-resize-handle/sidebar-resize-handle.tsx +86 -0
- package/ui/src/app/tree-expansion/tree-expansion.ts +107 -0
- package/ui/src/app/tree-expansion/tree-expansion.types.ts +31 -0
- package/ui/src/app/tree-move/tree-move.tsx +427 -0
- package/ui/src/app/tree-reveal/tree-reveal.ts +64 -0
- package/ui/src/app/tree-reveal/tree-reveal.types.ts +16 -0
- package/ui/src/app/user-footer/user-footer.tsx +188 -0
- package/ui/src/app/view-menu/view-menu.tsx +115 -0
- package/ui/src/app-root/app-root.tsx +109 -0
- package/ui/src/app-root/app-root.types.ts +27 -0
- package/ui/src/archive/archive.tsx +417 -0
- package/ui/src/attachment-detail/attachment-detail.ts +52 -0
- package/ui/src/attachment-viewer/attachment-viewer.tsx +166 -0
- package/ui/src/blocknote-view/blocknote-view.tsx +30 -0
- package/ui/src/board/board-assignee/board-assignee-picker.tsx +132 -0
- package/ui/src/board/board-assignee/board-assignee.ts +48 -0
- package/ui/src/board/board-assignee/board-assignee.tsx +75 -0
- package/ui/src/board/board-chip/board-chip.tsx +46 -0
- package/ui/src/board/board-crumbs/board-crumbs.ts +56 -0
- package/ui/src/board/board-crumbs/board-crumbs.tsx +111 -0
- package/ui/src/board/board-data/board-data.ts +195 -0
- package/ui/src/board/board-data/board-data.types.ts +38 -0
- package/ui/src/board/board-dates/board-dates.ts +40 -0
- package/ui/src/board/board-dates/board-dates.tsx +105 -0
- package/ui/src/board/board-kanban/board-kanban.ts +162 -0
- package/ui/src/board/board-kanban/board-kanban.tsx +680 -0
- package/ui/src/board/board-open-task/board-open-task.ts +32 -0
- package/ui/src/board/board-panel/board-panel.tsx +259 -0
- package/ui/src/board/board-settings/board-settings.tsx +226 -0
- package/ui/src/board/board-status/board-status.tsx +60 -0
- package/ui/src/board/board-stripes/board-stripes.ts +128 -0
- package/ui/src/board/board-table/board-table.ts +53 -0
- package/ui/src/board/board-table/board-table.tsx +610 -0
- package/ui/src/board/board-task/board-task.ts +108 -0
- package/ui/src/board/board-task/board-task.tsx +335 -0
- package/ui/src/board/board-title-row/board-title-row.tsx +66 -0
- package/ui/src/branding/branding.tsx +17 -0
- package/ui/src/branding/custom-logo.ts +1 -0
- package/ui/src/branding/favicon.default.svg +5 -0
- package/ui/src/branding/favicon.svg +5 -0
- package/ui/src/components/ui/avatar.tsx +39 -0
- package/ui/src/components/ui/button.tsx +64 -0
- package/ui/src/components/ui/collapsible.tsx +20 -0
- package/ui/src/components/ui/command.tsx +160 -0
- package/ui/src/components/ui/dialog.tsx +143 -0
- package/ui/src/components/ui/dropdown-menu.tsx +187 -0
- package/ui/src/components/ui/input.tsx +21 -0
- package/ui/src/components/ui/popover.tsx +41 -0
- package/ui/src/components/ui/select.tsx +163 -0
- package/ui/src/components/ui/separator.tsx +26 -0
- package/ui/src/components/ui/sheet.tsx +136 -0
- package/ui/src/components/ui/sidebar.tsx +693 -0
- package/ui/src/components/ui/skeleton.tsx +13 -0
- package/ui/src/components/ui/switch.tsx +25 -0
- package/ui/src/components/ui/table.tsx +82 -0
- package/ui/src/components/ui/tabs.tsx +52 -0
- package/ui/src/components/ui/tooltip.tsx +51 -0
- package/ui/src/data/intel-data-provider/intel-data-provider.ts +685 -0
- package/ui/src/data/intel-data-provider/intel-data-provider.types.ts +266 -0
- package/ui/src/data/query-client.ts +18 -0
- package/ui/src/data/request-refusal/refusal-notice.tsx +36 -0
- package/ui/src/data/request-refusal/request-refusal.ts +51 -0
- package/ui/src/data/sign-in/sign-in.ts +97 -0
- package/ui/src/data/sign-in/sign-in.types.ts +15 -0
- package/ui/src/document-link/document-link.tsx +114 -0
- package/ui/src/document-markdown/document-markdown.ts +195 -0
- package/ui/src/editor-schema/editor-schema.ts +23 -0
- package/ui/src/entry-picker/entry-picker.tsx +238 -0
- package/ui/src/feed/feed.tsx +344 -0
- package/ui/src/file-preview/file-preview-view.tsx +496 -0
- package/ui/src/file-preview/file-preview.tsx +28 -0
- package/ui/src/file-preview/pdf-file-preview.tsx +5 -0
- package/ui/src/file-preview/presentation-file-preview.tsx +21 -0
- package/ui/src/file-preview/spreadsheet-file-preview.tsx +5 -0
- package/ui/src/file-preview/word-file-preview.tsx +5 -0
- package/ui/src/flow-runs/flow-runs.tsx +238 -0
- package/ui/src/flows/flows.tsx +1646 -0
- package/ui/src/flows/node-icon/node-icon.ts +34 -0
- package/ui/src/flows/node-palette/node-palette.tsx +214 -0
- package/ui/src/flows/node-palette/node-palette.types.ts +15 -0
- package/ui/src/folder-contents/folder-contents.tsx +321 -0
- package/ui/src/folder-index/folder-index.ts +55 -0
- package/ui/src/frontmatter/frontmatter.tsx +193 -0
- package/ui/src/graph-pane/graph-pane.tsx +53 -0
- package/ui/src/hooks/use-mobile.ts +19 -0
- package/ui/src/i18n/custom.json +4 -0
- package/ui/src/i18n/de.json +609 -0
- package/ui/src/i18n/en.json +609 -0
- package/ui/src/i18n/es.json +609 -0
- package/ui/src/i18n/i18n-context.tsx +65 -0
- package/ui/src/i18n/i18n-languages/i18n-languages.ts +83 -0
- package/ui/src/i18n/i18n.ts +32 -0
- package/ui/src/i18n/i18n.types.ts +49 -0
- package/ui/src/kind-icon.ts +97 -0
- package/ui/src/lib/utils.ts +6 -0
- package/ui/src/main.tsx +68 -0
- package/ui/src/modal/modal.tsx +53 -0
- package/ui/src/node-details/node-details.tsx +545 -0
- package/ui/src/node-editor/node-editor.tsx +304 -0
- package/ui/src/node-graph/graph-notice.tsx +21 -0
- package/ui/src/node-graph/node-graph.ts +214 -0
- package/ui/src/node-graph/node-graph.tsx +295 -0
- package/ui/src/node-import/node-import.tsx +418 -0
- package/ui/src/node-save-error.ts +46 -0
- package/ui/src/node-source/node-source.tsx +128 -0
- package/ui/src/node-table/node-table.tsx +107 -0
- package/ui/src/nodes/nodes.tsx +240 -0
- package/ui/src/rename-resource/rename-resource.ts +80 -0
- package/ui/src/resolved-names/resolved-names.tsx +67 -0
- package/ui/src/resource-error.ts +33 -0
- package/ui/src/resource-menu/resource-menu.tsx +1156 -0
- package/ui/src/router/router-context.ts +6 -0
- package/ui/src/router/router.tsx +67 -0
- package/ui/src/router/router.types.ts +8 -0
- package/ui/src/router/selection-search.ts +88 -0
- package/ui/src/save-button/save-button.tsx +94 -0
- package/ui/src/section-hint/section-hint.tsx +40 -0
- package/ui/src/sign-in-refused/sign-in-refused.tsx +35 -0
- package/ui/src/styles.css +189 -0
- package/ui/src/table-columns/table-columns.tsx +379 -0
- package/ui/src/theme/custom.css +1 -0
- package/ui/src/theme/theme-context.tsx +89 -0
- package/ui/src/theme/theme.ts +70 -0
- package/ui/src/time/relative-time.tsx +41 -0
- package/ui/src/time/time-context.tsx +78 -0
- package/ui/src/time/time.ts +165 -0
- package/ui/src/title-row/title-row.tsx +185 -0
- package/ui/src/tools/tools.tsx +546 -0
- package/ui/src/user-name/user-name.ts +61 -0
- package/ui/src/verbatim/verbatim.tsx +68 -0
- package/ui/tsconfig.json +17 -0
- package/ui/vite.config.ts +129 -0
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
import type { Flow } from "@contract/flow.ts";
|
|
2
|
+
import type { Node } from "@contract/node.ts";
|
|
3
|
+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
4
|
+
import { ArchiveRestore, LoaderCircle, Trash2 } from "lucide-react";
|
|
5
|
+
import { useState } from "react";
|
|
6
|
+
import { allTreeLevelsKey } from "@/app/tree-move/tree-move.tsx";
|
|
7
|
+
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
|
8
|
+
import { IntelRequestError } from "@/data/intel-data-provider/intel-data-provider.ts";
|
|
9
|
+
import { useI18n } from "@/i18n/i18n-context.tsx";
|
|
10
|
+
import { kindIcons } from "@/kind-icon.ts";
|
|
11
|
+
import { cn } from "@/lib/utils.ts";
|
|
12
|
+
import { Modal } from "@/modal/modal.tsx";
|
|
13
|
+
import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
14
|
+
import { RelativeTime } from "@/time/relative-time.tsx";
|
|
15
|
+
|
|
16
|
+
// One archived thing, whichever side of the tree it came from. The two records stay apart
|
|
17
|
+
// everywhere else (ADR-0004); here they are one list because "what did I throw away" is one
|
|
18
|
+
// question, and an archive split in two would make somebody ask it twice.
|
|
19
|
+
interface ArchivedEntry {
|
|
20
|
+
id: string;
|
|
21
|
+
title: string;
|
|
22
|
+
kind: Node["kind"] | "flow";
|
|
23
|
+
archivedAt: string;
|
|
24
|
+
updatedAt: string;
|
|
25
|
+
restore(): Promise<unknown>;
|
|
26
|
+
// Both sides of the tree can be purged (#457) — each through its own door, because node and flow
|
|
27
|
+
// stay separate everywhere else (ADR-0004).
|
|
28
|
+
purge(): Promise<{ purged: true; title: string }>;
|
|
29
|
+
previewPurge?(): Promise<{
|
|
30
|
+
inboundLinks: number;
|
|
31
|
+
totalItems: number;
|
|
32
|
+
items: Array<{ id: string; title: string }>;
|
|
33
|
+
}>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The refusal of a permanent deletion — and the one place the reader can actually reach it.
|
|
38
|
+
*
|
|
39
|
+
* ⚠️ #601. It used to stand on the page only, and the confirmation dialog that produces it **stays
|
|
40
|
+
* open** on a refusal while marking everything outside itself `aria-hidden` (`Modal` → react-aria's
|
|
41
|
+
* `ModalOverlay`). Behind that the sentence was not merely awkward to get at: for a screen reader it
|
|
42
|
+
* was not there at all, and it announced nothing. Since #593 this is the sentence carrying the NAMES
|
|
43
|
+
* of the flows that still call the flow — the one thing that makes the refusal actionable, and the
|
|
44
|
+
* reason the ticket was built.
|
|
45
|
+
*
|
|
46
|
+
* ⚠️ It is rendered in exactly ONE of two places, never both: inside the dialog while that is open,
|
|
47
|
+
* on the page once it is closed. The second half is not a leftover — the reader closes the dialog to
|
|
48
|
+
* go and change the callers, and the sentence has to still be there when they come back. Two copies
|
|
49
|
+
* would be two announcements of one answer, which is the `#445` mistake in a new costume.
|
|
50
|
+
*
|
|
51
|
+
* ⚠️ This is NOT the `#430` class and does not go through `RefusalNotice`. That one answers a
|
|
52
|
+
* refused QUERY — a `403`/`404` that leaves a whole pane with nothing to show, told as a centered
|
|
53
|
+
* sentence without a retry. This is a refused mutation that carries data (`409` with `callers`),
|
|
54
|
+
* belongs beside the button that caused it, and its problem was never the wording.
|
|
55
|
+
*/
|
|
56
|
+
function PurgeRefusal({ error, className }: { error: unknown; className?: string }) {
|
|
57
|
+
const i18n = useI18n();
|
|
58
|
+
/**
|
|
59
|
+
* ⚠️ Deleting a flow for good is refused with `409 flow_in_use_by_flow` while published flows
|
|
60
|
+
* still call it (`flows.ts`, `purge`), and the CALLERS are what makes that refusal actionable —
|
|
61
|
+
* "reload and try again" is advice for a flow nobody calls, and this one is called.
|
|
62
|
+
*
|
|
63
|
+
* ⚠️ Since #593 they arrive as data — the flows this reader may see by name, the rest as a count
|
|
64
|
+
* — so the sentence is written here, in the reader's language, exactly as the share dialog writes
|
|
65
|
+
* the folder's half of the same rule (#448). The server still decides WHICH may be named; that is
|
|
66
|
+
* an authorization answer and nothing on this side recomputes it.
|
|
67
|
+
*/
|
|
68
|
+
const requestError = error instanceof IntelRequestError ? error : null;
|
|
69
|
+
const inUse = requestError?.code === "flow_in_use_by_flow" ? requestError : null;
|
|
70
|
+
return (
|
|
71
|
+
<div role="alert" className={cn("space-y-1 text-sm text-destructive", className)}>
|
|
72
|
+
{inUse ? (
|
|
73
|
+
<>
|
|
74
|
+
<p>{i18n.t("archive.purgeInUse")}</p>
|
|
75
|
+
{/* An API older than #593 sends no `callers`, and then its own sentence is the only place
|
|
76
|
+
the titles exist at all. Showing it beats showing nothing. */}
|
|
77
|
+
{inUse.callers === null ? (
|
|
78
|
+
<p className="text-xs">{inUse.message}</p>
|
|
79
|
+
) : (
|
|
80
|
+
<p className="text-xs">
|
|
81
|
+
{inUse.callers.titles.length > 0
|
|
82
|
+
? i18n.t("archive.purgeInUseCallers", {
|
|
83
|
+
titles: inUse.callers.titles.join(", "),
|
|
84
|
+
})
|
|
85
|
+
: i18n.t("archive.purgeInUseCallersHidden", { count: inUse.callers.hidden })}
|
|
86
|
+
{inUse.callers.titles.length > 0 && inUse.callers.hidden > 0
|
|
87
|
+
? ` ${i18n.t("archive.purgeInUseCallersMore", { count: inUse.callers.hidden })}`
|
|
88
|
+
: ""}
|
|
89
|
+
{` ${i18n.t("archive.purgeInUseHint")}`}
|
|
90
|
+
</p>
|
|
91
|
+
)}
|
|
92
|
+
</>
|
|
93
|
+
) : (
|
|
94
|
+
<p>{i18n.t("archive.purgeFailed")}</p>
|
|
95
|
+
)}
|
|
96
|
+
</div>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function Archive() {
|
|
101
|
+
const { data } = useIntelRouterContext();
|
|
102
|
+
const i18n = useI18n();
|
|
103
|
+
const queryClient = useQueryClient();
|
|
104
|
+
|
|
105
|
+
const archived = useQuery({
|
|
106
|
+
queryKey: ["archive"],
|
|
107
|
+
queryFn: async (): Promise<ArchivedEntry[]> => {
|
|
108
|
+
const [nodes, flows] = await Promise.all([
|
|
109
|
+
data.listNodes({ archivedOnly: true }),
|
|
110
|
+
data.listFlows({ archivedOnly: true }),
|
|
111
|
+
]);
|
|
112
|
+
const entries: ArchivedEntry[] = [
|
|
113
|
+
...nodes.items.flatMap((node) => {
|
|
114
|
+
// Stable across reloads: after D1 commits, only this key can reach an R2 cleanup receipt.
|
|
115
|
+
const purgeKey = `purge-${node.id}`;
|
|
116
|
+
return node.archivedAt
|
|
117
|
+
? [
|
|
118
|
+
{
|
|
119
|
+
id: node.id,
|
|
120
|
+
title: node.title,
|
|
121
|
+
kind: node.kind,
|
|
122
|
+
archivedAt: node.archivedAt,
|
|
123
|
+
updatedAt: node.updatedAt,
|
|
124
|
+
restore: () =>
|
|
125
|
+
data.archiveNode({
|
|
126
|
+
nodeId: node.id,
|
|
127
|
+
baseUpdatedAt: node.updatedAt,
|
|
128
|
+
archived: false,
|
|
129
|
+
idempotencyKey: crypto.randomUUID(),
|
|
130
|
+
}),
|
|
131
|
+
purge: () => data.purgeNode({ nodeId: node.id, idempotencyKey: purgeKey }),
|
|
132
|
+
previewPurge: () => data.previewNodePurge({ nodeId: node.id }),
|
|
133
|
+
} satisfies ArchivedEntry,
|
|
134
|
+
]
|
|
135
|
+
: [];
|
|
136
|
+
}),
|
|
137
|
+
...flows.items.flatMap((flow: Flow) =>
|
|
138
|
+
flow.archivedAt
|
|
139
|
+
? [
|
|
140
|
+
{
|
|
141
|
+
id: flow.id,
|
|
142
|
+
title: flow.title,
|
|
143
|
+
kind: "flow" as const,
|
|
144
|
+
archivedAt: flow.archivedAt,
|
|
145
|
+
updatedAt: flow.updatedAt,
|
|
146
|
+
restore: () =>
|
|
147
|
+
data.archiveFlow({
|
|
148
|
+
flowId: flow.id,
|
|
149
|
+
baseUpdatedAt: flow.updatedAt,
|
|
150
|
+
archived: false,
|
|
151
|
+
idempotencyKey: crypto.randomUUID(),
|
|
152
|
+
}),
|
|
153
|
+
purge: () => data.purgeFlow({ flowId: flow.id }),
|
|
154
|
+
} satisfies ArchivedEntry,
|
|
155
|
+
]
|
|
156
|
+
: [],
|
|
157
|
+
),
|
|
158
|
+
];
|
|
159
|
+
// Newest first, across both kinds: the thing somebody is looking for is almost always the
|
|
160
|
+
// thing they just archived.
|
|
161
|
+
return entries.sort((a, b) => b.archivedAt.localeCompare(a.archivedAt));
|
|
162
|
+
},
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// ⚠️ `baseUpdatedAt` from the row that is on screen, exactly as archiving used it. Restoring is
|
|
166
|
+
// a change like any other and takes the same conflict route — without it, two people tidying up
|
|
167
|
+
// at once would silently overwrite each other.
|
|
168
|
+
const restore = useMutation({
|
|
169
|
+
mutationFn: async (entry: ArchivedEntry) => await entry.restore(),
|
|
170
|
+
onSuccess: async () => {
|
|
171
|
+
await Promise.all([
|
|
172
|
+
queryClient.invalidateQueries({ queryKey: ["archive"] }),
|
|
173
|
+
// The tree gets it back, so every level has to be asked again — the entry may sit
|
|
174
|
+
// anywhere, and the archive does not know where.
|
|
175
|
+
queryClient.invalidateQueries({ queryKey: allTreeLevelsKey }),
|
|
176
|
+
queryClient.invalidateQueries({ queryKey: ["node-graph"] }),
|
|
177
|
+
queryClient.invalidateQueries({ queryKey: ["flows"] }),
|
|
178
|
+
]);
|
|
179
|
+
},
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// ⚠️ The confirmation is a real dialog, not a `window.confirm` — it has to name the thing and say
|
|
183
|
+
// that it does not come back. The state lives here because the row underneath disappears the
|
|
184
|
+
// moment the purge goes through.
|
|
185
|
+
const [confirming, setConfirming] = useState<ArchivedEntry | null>(null);
|
|
186
|
+
// Whether anything has scrolled away under the header. See the header markup for why.
|
|
187
|
+
const [scrolled, setScrolled] = useState(false);
|
|
188
|
+
const preview = useQuery({
|
|
189
|
+
queryKey: ["purge-preview", confirming?.id],
|
|
190
|
+
queryFn: async () => await confirming?.previewPurge?.(),
|
|
191
|
+
enabled: Boolean(confirming?.previewPurge),
|
|
192
|
+
retry: false,
|
|
193
|
+
});
|
|
194
|
+
const purge = useMutation({
|
|
195
|
+
mutationFn: async (entry: ArchivedEntry) => await entry.purge(),
|
|
196
|
+
onSuccess: async () => {
|
|
197
|
+
setConfirming(null);
|
|
198
|
+
await Promise.all([
|
|
199
|
+
queryClient.invalidateQueries({ queryKey: ["archive"] }),
|
|
200
|
+
// The tree and the graph did not show the node, but its DISAPPEARANCE can be visible
|
|
201
|
+
// elsewhere: a reference to it no longer resolves afterwards.
|
|
202
|
+
queryClient.invalidateQueries({ queryKey: allTreeLevelsKey }),
|
|
203
|
+
queryClient.invalidateQueries({ queryKey: ["node-graph"] }),
|
|
204
|
+
queryClient.invalidateQueries({ queryKey: ["flows"] }),
|
|
205
|
+
]);
|
|
206
|
+
},
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
return (
|
|
210
|
+
<div className="flex min-h-0 flex-1 flex-col">
|
|
211
|
+
{/* ⚠️ The rule below the header belongs to the SCROLL STATE, not to the header. While nothing
|
|
212
|
+
has scrolled away, it separates a heading from the content that heading is about, which is
|
|
213
|
+
a line drawn for no reason. It earns its place the moment content slides underneath.
|
|
214
|
+
|
|
215
|
+
The width is set from the start and only the COLOUR changes. Adding the border on scroll
|
|
216
|
+
would grow the header by one pixel at that moment and shove the whole list down — visible,
|
|
217
|
+
and exactly at the point where the eye is already following movement (#741).
|
|
218
|
+
|
|
219
|
+
⚠️ `motion-reduce:transition-none` is not decoration. Nothing in this repository disarms a
|
|
220
|
+
colour transition for a reader who asked for less movement — there is no global
|
|
221
|
+
`prefers-reduced-motion` rule in the stylesheets — so a fade added here would run for them
|
|
222
|
+
too unless it says otherwise. The line still appears either way; only the fade is dropped. */}
|
|
223
|
+
<div
|
|
224
|
+
data-slot="pane-header"
|
|
225
|
+
className={cn(
|
|
226
|
+
"border-b border-transparent px-6 py-4 transition-colors motion-reduce:transition-none",
|
|
227
|
+
scrolled && "border-border",
|
|
228
|
+
)}
|
|
229
|
+
>
|
|
230
|
+
<h1 className="text-lg font-medium text-foreground">{i18n.t("archive.title")}</h1>
|
|
231
|
+
<p className="mt-1 max-w-prose text-sm text-muted-foreground">
|
|
232
|
+
{i18n.t("archive.description")}
|
|
233
|
+
</p>
|
|
234
|
+
</div>
|
|
235
|
+
<div
|
|
236
|
+
data-slot="pane-body"
|
|
237
|
+
// Reading the position off the event rather than holding a ref: React skips the re-render
|
|
238
|
+
// when the boolean has not changed, so a scroll of 400 pixels still costs one render.
|
|
239
|
+
onScroll={(event) => setScrolled(event.currentTarget.scrollTop > 0)}
|
|
240
|
+
className="min-h-0 flex-1 overflow-y-auto px-6 py-4"
|
|
241
|
+
>
|
|
242
|
+
{archived.isError ? (
|
|
243
|
+
<p role="alert" className="text-sm text-destructive">
|
|
244
|
+
{i18n.t("archive.failed")}
|
|
245
|
+
</p>
|
|
246
|
+
) : null}
|
|
247
|
+
{restore.isError ? (
|
|
248
|
+
<p role="alert" className="mb-4 text-sm text-destructive">
|
|
249
|
+
{i18n.t("archive.restoreFailed")}
|
|
250
|
+
</p>
|
|
251
|
+
) : null}
|
|
252
|
+
{/* ⚠️ On the page only while no dialog is open (#601). The refusal carries the reason — a
|
|
253
|
+
flow still calls this one — and it has to survive the dialog it came from; but as long as
|
|
254
|
+
that dialog stands, the page is `aria-hidden` and this is the copy nobody can reach. */}
|
|
255
|
+
{purge.isError && !confirming ? (
|
|
256
|
+
<PurgeRefusal error={purge.error} className="mb-4" />
|
|
257
|
+
) : null}
|
|
258
|
+
{archived.data?.length === 0 ? (
|
|
259
|
+
<p className="text-sm text-muted-foreground">{i18n.t("archive.empty")}</p>
|
|
260
|
+
) : null}
|
|
261
|
+
<ul className="grid gap-1">
|
|
262
|
+
{(archived.data ?? []).map((entry) => (
|
|
263
|
+
<li
|
|
264
|
+
key={`${entry.kind}-${entry.id}`}
|
|
265
|
+
className="flex items-center gap-3 rounded-md border px-3 py-2"
|
|
266
|
+
>
|
|
267
|
+
{(() => {
|
|
268
|
+
// The same map the tree and the folder table draw from — a second one here would
|
|
269
|
+
// drift the first time a kind is added.
|
|
270
|
+
// ⚠️ The plain kind icon, deliberately. An archived document is in nobody's
|
|
271
|
+
// `prompts/list` — the catalogue skips it — so drawing it as an offered command
|
|
272
|
+
// here would be the one screen claiming something the surface does not do (#775).
|
|
273
|
+
const Icon = kindIcons[entry.kind];
|
|
274
|
+
return (
|
|
275
|
+
<Icon aria-hidden="true" className="size-4 shrink-0 text-muted-foreground" />
|
|
276
|
+
);
|
|
277
|
+
})()}
|
|
278
|
+
<span className="sr-only">{i18n.t(`node.kind.${entry.kind}`)}</span>
|
|
279
|
+
<span className="grid min-w-0 flex-1 leading-tight">
|
|
280
|
+
<span className="truncate text-sm font-medium">{entry.title}</span>
|
|
281
|
+
<span className="flex items-center gap-1 truncate text-xs text-muted-foreground">
|
|
282
|
+
{i18n.t("archive.archived")}
|
|
283
|
+
<RelativeTime value={entry.archivedAt} />
|
|
284
|
+
</span>
|
|
285
|
+
</span>
|
|
286
|
+
{/* Both actions stay permanently visible: the archive is the one place where getting
|
|
287
|
+
an item back must never depend on discovering a hover-only control. */}
|
|
288
|
+
<TooltipProvider delayDuration={300}>
|
|
289
|
+
<Tooltip>
|
|
290
|
+
{/* The wrapper keeps the tooltip available while the button is disabled. */}
|
|
291
|
+
<TooltipTrigger asChild>
|
|
292
|
+
<span className="inline-flex shrink-0">
|
|
293
|
+
<button
|
|
294
|
+
type="button"
|
|
295
|
+
disabled={restore.isPending}
|
|
296
|
+
onClick={() => restore.mutate(entry)}
|
|
297
|
+
aria-label={i18n.t(
|
|
298
|
+
restore.isPending && restore.variables?.id === entry.id
|
|
299
|
+
? "archive.restoring"
|
|
300
|
+
: "archive.restore",
|
|
301
|
+
{ title: entry.title },
|
|
302
|
+
)}
|
|
303
|
+
aria-busy={restore.isPending && restore.variables?.id === entry.id}
|
|
304
|
+
className="inline-flex size-8 shrink-0 items-center justify-center rounded-md border outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-60"
|
|
305
|
+
>
|
|
306
|
+
{restore.isPending && restore.variables?.id === entry.id ? (
|
|
307
|
+
<LoaderCircle aria-hidden="true" className="size-4 animate-spin" />
|
|
308
|
+
) : (
|
|
309
|
+
<ArchiveRestore aria-hidden="true" className="size-4" />
|
|
310
|
+
)}
|
|
311
|
+
</button>
|
|
312
|
+
</span>
|
|
313
|
+
</TooltipTrigger>
|
|
314
|
+
<TooltipContent>
|
|
315
|
+
{i18n.t(
|
|
316
|
+
restore.isPending && restore.variables?.id === entry.id
|
|
317
|
+
? "archive.restoringAction"
|
|
318
|
+
: "archive.restoreAction",
|
|
319
|
+
)}
|
|
320
|
+
</TooltipContent>
|
|
321
|
+
</Tooltip>
|
|
322
|
+
</TooltipProvider>
|
|
323
|
+
<button
|
|
324
|
+
type="button"
|
|
325
|
+
disabled={purge.isPending}
|
|
326
|
+
onClick={() => {
|
|
327
|
+
// ⚠️ The last refusal is cleared as the NEXT dialog opens, not when one closes:
|
|
328
|
+
// the sentence has to outlive the dialog it came from (#601), and it must not be
|
|
329
|
+
// standing in a dialog about a different entry — that would answer a question
|
|
330
|
+
// nobody asked, about the wrong thing.
|
|
331
|
+
purge.reset();
|
|
332
|
+
setConfirming(entry);
|
|
333
|
+
}}
|
|
334
|
+
aria-label={i18n.t("archive.purge", { title: entry.title })}
|
|
335
|
+
className="inline-flex size-8 shrink-0 items-center justify-center rounded-md border text-destructive outline-none hover:bg-destructive/10 focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-60"
|
|
336
|
+
>
|
|
337
|
+
<Trash2 aria-hidden="true" className="size-4" />
|
|
338
|
+
</button>
|
|
339
|
+
</li>
|
|
340
|
+
))}
|
|
341
|
+
</ul>
|
|
342
|
+
</div>
|
|
343
|
+
{confirming ? (
|
|
344
|
+
<Modal title={i18n.t("archive.purge.title")} close={() => setConfirming(null)}>
|
|
345
|
+
<p className="text-sm text-muted-foreground">
|
|
346
|
+
{i18n.t("archive.purge.body", { title: confirming.title })}
|
|
347
|
+
</p>
|
|
348
|
+
{confirming.previewPurge && preview.data ? (
|
|
349
|
+
<div className="mt-2 space-y-2 text-sm text-muted-foreground">
|
|
350
|
+
<p>{i18n.t("archive.purge.items", { count: preview.data.totalItems })}</p>
|
|
351
|
+
{/* ⚠️ The names, not only the number (D71, #733): what is confirmed here cannot be
|
|
352
|
+
undone, and "82 items" is not something a person can decide about. The list the
|
|
353
|
+
API sends is capped, so whatever it does not carry is counted out loud. */}
|
|
354
|
+
{preview.data.items.length > 0 ? (
|
|
355
|
+
<ul className="max-h-40 list-disc overflow-y-auto pl-5">
|
|
356
|
+
{preview.data.items.map((entry) => (
|
|
357
|
+
<li key={entry.id}>{entry.title}</li>
|
|
358
|
+
))}
|
|
359
|
+
</ul>
|
|
360
|
+
) : null}
|
|
361
|
+
{preview.data.totalItems > preview.data.items.length ? (
|
|
362
|
+
<p>
|
|
363
|
+
{i18n.t("archive.purge.items.more", {
|
|
364
|
+
count: preview.data.totalItems - preview.data.items.length,
|
|
365
|
+
})}
|
|
366
|
+
</p>
|
|
367
|
+
) : null}
|
|
368
|
+
<p>
|
|
369
|
+
{preview.data.inboundLinks === 0
|
|
370
|
+
? i18n.t("archive.purge.links.none")
|
|
371
|
+
: preview.data.inboundLinks === 1
|
|
372
|
+
? i18n.t("archive.purge.links.one")
|
|
373
|
+
: i18n.t("archive.purge.links.many", { count: preview.data.inboundLinks })}
|
|
374
|
+
</p>
|
|
375
|
+
</div>
|
|
376
|
+
) : null}
|
|
377
|
+
{confirming.previewPurge && preview.isPending ? (
|
|
378
|
+
<p className="mt-2 text-sm text-muted-foreground">
|
|
379
|
+
{i18n.t("archive.purge.previewLoading")}
|
|
380
|
+
</p>
|
|
381
|
+
) : null}
|
|
382
|
+
{confirming.previewPurge && preview.isError ? (
|
|
383
|
+
<p role="alert" className="mt-2 text-sm text-destructive">
|
|
384
|
+
{i18n.t("archive.purge.previewFailed")}
|
|
385
|
+
</p>
|
|
386
|
+
) : null}
|
|
387
|
+
<p className="mt-2 text-sm text-muted-foreground">{i18n.t("archive.purge.grants")}</p>
|
|
388
|
+
{/* ⚠️ Inside the dialog, because the dialog stays open on a refusal and hides the page
|
|
389
|
+
behind it from the accessibility tree entirely (#601). Above the buttons rather than
|
|
390
|
+
below them: the answer belongs between the reason and the button that asks again. */}
|
|
391
|
+
{purge.isError ? <PurgeRefusal error={purge.error} className="mt-4" /> : null}
|
|
392
|
+
<div className="mt-6 flex justify-end gap-2">
|
|
393
|
+
<button
|
|
394
|
+
type="button"
|
|
395
|
+
onClick={() => setConfirming(null)}
|
|
396
|
+
className="inline-flex h-8 items-center rounded-md border px-3 text-sm font-medium outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
|
|
397
|
+
>
|
|
398
|
+
{i18n.t("archive.purge.cancel")}
|
|
399
|
+
</button>
|
|
400
|
+
<button
|
|
401
|
+
type="button"
|
|
402
|
+
disabled={
|
|
403
|
+
purge.isPending ||
|
|
404
|
+
(Boolean(confirming.previewPurge) && (preview.isPending || preview.isError))
|
|
405
|
+
}
|
|
406
|
+
onClick={() => purge.mutate(confirming)}
|
|
407
|
+
className="inline-flex h-8 items-center gap-2 rounded-md bg-destructive px-3 text-sm font-medium text-destructive-foreground outline-none hover:bg-destructive/90 focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-60"
|
|
408
|
+
>
|
|
409
|
+
<Trash2 aria-hidden="true" className="size-4" />
|
|
410
|
+
{i18n.t("archive.purge.confirm")}
|
|
411
|
+
</button>
|
|
412
|
+
</div>
|
|
413
|
+
</Modal>
|
|
414
|
+
) : null}
|
|
415
|
+
</div>
|
|
416
|
+
);
|
|
417
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a file IS, and how big it is, without opening it.
|
|
3
|
+
*
|
|
4
|
+
* ⚠️ **Here rather than beside the viewer, and the reason is the bundle** (#819). Since the details
|
|
5
|
+
* field of every kind is drawn from the title line, these two are on the main path — while
|
|
6
|
+
* `attachmentFormatDetail`, which unpacks the file itself, stays with the viewer and keeps `fflate`
|
|
7
|
+
* in the viewer's lazy chunk. Two modules, drawn along the line between "cheap and always needed"
|
|
8
|
+
* and "expensive and only for a file that is open".
|
|
9
|
+
*/
|
|
10
|
+
// ⚠️ `audio` and `video` carry no preview (nothing under `file-preview/` renders either), but they
|
|
11
|
+
// are still their OWN kind here rather than folding into `unsupported` (#815, Jack's decision of
|
|
12
|
+
// 2026-08-29). What decides membership in this type is what a reader should be able to TELL APART,
|
|
13
|
+
// not what this package can render — an audio file and a spreadsheet are just as unrelated as a PDF
|
|
14
|
+
// and a spreadsheet, and drawing both of the first two as the same generic file would hide exactly
|
|
15
|
+
// the distinction a reader came here for.
|
|
16
|
+
export type AttachmentPreviewKind =
|
|
17
|
+
| "image"
|
|
18
|
+
| "pdf"
|
|
19
|
+
| "word"
|
|
20
|
+
| "spreadsheet"
|
|
21
|
+
| "presentation"
|
|
22
|
+
| "audio"
|
|
23
|
+
| "video"
|
|
24
|
+
| "unsupported";
|
|
25
|
+
|
|
26
|
+
const extensions: Record<Exclude<AttachmentPreviewKind, "unsupported">, readonly string[]> = {
|
|
27
|
+
image: ["avif", "bmp", "gif", "jpeg", "jpg", "png", "svg", "webp"],
|
|
28
|
+
pdf: ["pdf"],
|
|
29
|
+
word: ["docx"],
|
|
30
|
+
spreadsheet: ["xlsx"],
|
|
31
|
+
presentation: ["pptx"],
|
|
32
|
+
audio: ["aac", "flac", "m4a", "mp3", "ogg", "wav"],
|
|
33
|
+
video: ["avi", "mkv", "mov", "mp4", "webm"],
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export function attachmentPreviewKind(title: string, mediaType: string): AttachmentPreviewKind {
|
|
37
|
+
if (mediaType.startsWith("image/")) return "image";
|
|
38
|
+
if (mediaType === "application/pdf") return "pdf";
|
|
39
|
+
if (mediaType.startsWith("audio/")) return "audio";
|
|
40
|
+
if (mediaType.startsWith("video/")) return "video";
|
|
41
|
+
const extension = title.toLowerCase().split(".").at(-1) ?? "";
|
|
42
|
+
for (const [kind, supported] of Object.entries(extensions)) {
|
|
43
|
+
if (supported.includes(extension)) return kind as AttachmentPreviewKind;
|
|
44
|
+
}
|
|
45
|
+
return "unsupported";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function formatFileSize(bytes: number): string {
|
|
49
|
+
if (bytes < 1_000) return `${bytes} B`;
|
|
50
|
+
if (bytes < 1_000_000) return `${(bytes / 1_000).toFixed(bytes < 10_000 ? 1 : 0)} KB`;
|
|
51
|
+
return `${(bytes / 1_000_000).toFixed(bytes < 10_000_000 ? 1 : 0)} MB`;
|
|
52
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import type { NodeDocument } from "@contract/node.ts";
|
|
2
|
+
import { useQuery } from "@tanstack/react-query";
|
|
3
|
+
import { unzipSync } from "fflate";
|
|
4
|
+
import { FileQuestion } from "lucide-react";
|
|
5
|
+
import { Component, lazy, type ReactNode, Suspense, useEffect, useMemo, useState } from "react";
|
|
6
|
+
import {
|
|
7
|
+
type AttachmentPreviewKind,
|
|
8
|
+
attachmentPreviewKind,
|
|
9
|
+
} from "@/attachment-detail/attachment-detail.ts";
|
|
10
|
+
import { useI18n } from "@/i18n/i18n-context.tsx";
|
|
11
|
+
import { cn } from "@/lib/utils";
|
|
12
|
+
import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
13
|
+
|
|
14
|
+
const FilePreview = lazy(async () => ({
|
|
15
|
+
default: (await import("@/file-preview/file-preview.tsx")).FilePreview,
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
type FormatDetail = {
|
|
19
|
+
label: "dimensions" | "pages" | "sections" | "slides" | "worksheets";
|
|
20
|
+
value: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export async function attachmentFormatDetail(
|
|
24
|
+
file: File,
|
|
25
|
+
kind: AttachmentPreviewKind,
|
|
26
|
+
): Promise<FormatDetail | null> {
|
|
27
|
+
if (kind === "image") {
|
|
28
|
+
const bitmap = await createImageBitmap(file);
|
|
29
|
+
const value = `${bitmap.width} × ${bitmap.height} px`;
|
|
30
|
+
bitmap.close();
|
|
31
|
+
return { label: "dimensions", value };
|
|
32
|
+
}
|
|
33
|
+
const bytes = new Uint8Array(await file.arrayBuffer());
|
|
34
|
+
if (kind === "pdf") {
|
|
35
|
+
const source = new TextDecoder("latin1").decode(bytes);
|
|
36
|
+
const pages = source.match(/\/Type\s*\/Page\b/g)?.length ?? 0;
|
|
37
|
+
return pages > 0 ? { label: "pages", value: String(pages) } : null;
|
|
38
|
+
}
|
|
39
|
+
// Neither carries a zip archive to look inside, and neither has anything else this function
|
|
40
|
+
// could read out of the bytes (#815) — same as `unsupported`.
|
|
41
|
+
if (kind === "unsupported" || kind === "audio" || kind === "video") return null;
|
|
42
|
+
const archive = unzipSync(bytes);
|
|
43
|
+
if (kind === "presentation") {
|
|
44
|
+
const slides = Object.keys(archive).filter((name) => /^ppt\/slides\/slide\d+\.xml$/.test(name));
|
|
45
|
+
return { label: "slides", value: String(slides.length) };
|
|
46
|
+
}
|
|
47
|
+
const decoder = new TextDecoder();
|
|
48
|
+
if (kind === "spreadsheet") {
|
|
49
|
+
const workbook = archive["xl/workbook.xml"];
|
|
50
|
+
if (!workbook) return null;
|
|
51
|
+
const worksheets = decoder.decode(workbook).match(/<sheet\b/g)?.length ?? 0;
|
|
52
|
+
return { label: "worksheets", value: String(worksheets) };
|
|
53
|
+
}
|
|
54
|
+
const document = archive["word/document.xml"];
|
|
55
|
+
if (!document) return null;
|
|
56
|
+
const sections = Math.max(1, decoder.decode(document).match(/<w:sectPr\b/g)?.length ?? 0);
|
|
57
|
+
return { label: "sections", value: String(sections) };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function AttachmentViewer({ document }: { document: NodeDocument }) {
|
|
61
|
+
const { data } = useIntelRouterContext();
|
|
62
|
+
const i18n = useI18n();
|
|
63
|
+
const attachment = useQuery({
|
|
64
|
+
queryKey: ["attachment", document.node.id, document.version?.id],
|
|
65
|
+
queryFn: () => data.getNodeAttachment(document.node.id),
|
|
66
|
+
enabled: document.version !== null,
|
|
67
|
+
retry: false,
|
|
68
|
+
});
|
|
69
|
+
const file = useMemo(
|
|
70
|
+
() =>
|
|
71
|
+
attachment.data && document.version
|
|
72
|
+
? new File([attachment.data], document.node.title, { type: document.version.mediaType })
|
|
73
|
+
: null,
|
|
74
|
+
[attachment.data, document.node.title, document.version],
|
|
75
|
+
);
|
|
76
|
+
const kind = document.version
|
|
77
|
+
? attachmentPreviewKind(document.node.title, document.version.mediaType)
|
|
78
|
+
: "unsupported";
|
|
79
|
+
return (
|
|
80
|
+
// ⚠️ **No details button of its own since #819.** What stood behind it is the details field
|
|
81
|
+
// every kind now has, in the title line — a file was the one kind that had one, and a second
|
|
82
|
+
// one here would be the exception the ticket exists to end.
|
|
83
|
+
<div className="flex min-h-0 flex-1 flex-col px-6 pb-6">
|
|
84
|
+
<div
|
|
85
|
+
data-preview-kind={kind}
|
|
86
|
+
className={cn(
|
|
87
|
+
"relative flex min-h-0 flex-1 overflow-hidden rounded-xl",
|
|
88
|
+
kind === "image" ? "bg-muted/40" : "border bg-background",
|
|
89
|
+
)}
|
|
90
|
+
>
|
|
91
|
+
<div className="relative min-h-0 min-w-0 flex-1 bg-muted/40">
|
|
92
|
+
{document.version === null ? (
|
|
93
|
+
<PreviewMessage message={i18n.t("attachment.empty")} />
|
|
94
|
+
) : attachment.isPending ? (
|
|
95
|
+
<PreviewMessage message={i18n.t("common.loading")} />
|
|
96
|
+
) : attachment.isError || !file ? (
|
|
97
|
+
<PreviewMessage error message={i18n.t("attachment.loadFailed")} />
|
|
98
|
+
) : kind === "unsupported" || kind === "audio" || kind === "video" ? (
|
|
99
|
+
// ⚠️ Audio and video are told apart by their OWN icon everywhere else in the
|
|
100
|
+
// application (#815), but nothing under `file-preview/` renders either — the message
|
|
101
|
+
// here is the same one an unrecognised file gets, not a claim of a player this screen
|
|
102
|
+
// does not have.
|
|
103
|
+
<PreviewMessage message={i18n.t("attachment.unsupported")} />
|
|
104
|
+
) : kind === "image" ? (
|
|
105
|
+
<ImagePreview file={file} title={document.node.title} />
|
|
106
|
+
) : (
|
|
107
|
+
<ViewerErrorBoundary
|
|
108
|
+
key={`${document.node.id}:${document.version.id}`}
|
|
109
|
+
fallback={<PreviewMessage error message={i18n.t("attachment.loadFailed")} />}
|
|
110
|
+
>
|
|
111
|
+
<Suspense fallback={<PreviewMessage message={i18n.t("common.loading")} />}>
|
|
112
|
+
<FilePreview file={file} kind={kind} />
|
|
113
|
+
</Suspense>
|
|
114
|
+
</ViewerErrorBoundary>
|
|
115
|
+
)}
|
|
116
|
+
</div>
|
|
117
|
+
</div>
|
|
118
|
+
</div>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
class ViewerErrorBoundary extends Component<
|
|
123
|
+
{ children: ReactNode; fallback: ReactNode },
|
|
124
|
+
{ failed: boolean }
|
|
125
|
+
> {
|
|
126
|
+
override state = { failed: false };
|
|
127
|
+
|
|
128
|
+
static getDerivedStateFromError() {
|
|
129
|
+
return { failed: true };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
override render() {
|
|
133
|
+
return this.state.failed ? this.props.fallback : this.props.children;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function PreviewMessage({ message, error = false }: { message: string; error?: boolean }) {
|
|
138
|
+
return (
|
|
139
|
+
<div
|
|
140
|
+
role={error ? "alert" : undefined}
|
|
141
|
+
className={cn(
|
|
142
|
+
"grid h-full min-h-64 place-items-center p-8 text-center text-sm text-muted-foreground",
|
|
143
|
+
error && "text-destructive",
|
|
144
|
+
)}
|
|
145
|
+
>
|
|
146
|
+
<div className="max-w-sm space-y-3">
|
|
147
|
+
<FileQuestion aria-hidden="true" className="mx-auto size-9" />
|
|
148
|
+
<p>{message}</p>
|
|
149
|
+
</div>
|
|
150
|
+
</div>
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function ImagePreview({ file, title }: { file: File; title: string }) {
|
|
155
|
+
const [url, setUrl] = useState("");
|
|
156
|
+
useEffect(() => {
|
|
157
|
+
const next = URL.createObjectURL(file);
|
|
158
|
+
setUrl(next);
|
|
159
|
+
return () => URL.revokeObjectURL(next);
|
|
160
|
+
}, [file]);
|
|
161
|
+
return url ? (
|
|
162
|
+
<div className="grid h-full min-h-64 place-items-center overflow-auto">
|
|
163
|
+
<img src={url} alt={title} className="h-auto w-auto max-h-full max-w-full object-contain" />
|
|
164
|
+
</div>
|
|
165
|
+
) : null;
|
|
166
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { type DefaultReactSuggestionItem, getDefaultReactSlashMenuItems } from "@blocknote/react";
|
|
2
|
+
import { BlockNoteView as LibraryBlockNoteView } from "@blocknote/shadcn";
|
|
3
|
+
import type { ComponentType, ReactNode } from "react";
|
|
4
|
+
|
|
5
|
+
// BlockNote 0.52's generic view declaration conflicts with exactOptionalPropertyTypes even though
|
|
6
|
+
// useCreateBlockNote returns its matching default editor. Keep that upstream type seam in one place.
|
|
7
|
+
const CompatibleBlockNoteView = LibraryBlockNoteView as unknown as ComponentType<{
|
|
8
|
+
editor: unknown;
|
|
9
|
+
onChange(): void;
|
|
10
|
+
children?: ReactNode;
|
|
11
|
+
}>;
|
|
12
|
+
|
|
13
|
+
// `children` is how BlockNote mounts its own controllers — the slash menu among them — inside the
|
|
14
|
+
// editor's context. It travels through this seam rather than around it, so there is still one place
|
|
15
|
+
// that knows about the library's types.
|
|
16
|
+
export function BlockNoteView(props: { editor: unknown; onChange(): void; children?: ReactNode }) {
|
|
17
|
+
return <CompatibleBlockNoteView {...props} />;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// The same seam once more: the parameter type of BlockNote's own slash-menu helper does not accept
|
|
21
|
+
// an editor built from a schema of ours, because `heading`'s optional `isToggleable` prop fails the
|
|
22
|
+
// library's own `PropSchema` constraint under `exactOptionalPropertyTypes`. The editor is exactly
|
|
23
|
+
// what the helper wants at run time — it is the declaration that cannot say so.
|
|
24
|
+
const compatibleDefaultSlashMenuItems = getDefaultReactSlashMenuItems as unknown as (
|
|
25
|
+
editor: unknown,
|
|
26
|
+
) => DefaultReactSuggestionItem[];
|
|
27
|
+
|
|
28
|
+
export function defaultSlashMenuItems(editor: unknown): DefaultReactSuggestionItem[] {
|
|
29
|
+
return compatibleDefaultSlashMenuItems(editor);
|
|
30
|
+
}
|