@graph8/sdk 0.373.0 → 0.374.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.
Files changed (279) hide show
  1. package/README.md +3 -3
  2. package/blocks/_foundation/README.md +47 -0
  3. package/blocks/_foundation/block.json +13 -0
  4. package/blocks/_foundation/components/auth-gate.tsx +363 -0
  5. package/blocks/_foundation/server/auth.ts +815 -0
  6. package/blocks/campaigns/README.md +1 -1
  7. package/blocks/campaigns/block.json +34 -1
  8. package/blocks/campaigns/components/campaign-links.tsx +180 -0
  9. package/blocks/company-intelligence/components/research-run.tsx +6 -2
  10. package/blocks/crm/README.md +19 -0
  11. package/blocks/crm/block.json +205 -6
  12. package/blocks/crm/components/bulk-update.tsx +119 -0
  13. package/blocks/crm/components/contact-documents-tab.tsx +144 -0
  14. package/blocks/crm/components/contact-insights-tab.tsx +118 -0
  15. package/blocks/crm/components/contact-lookup-link.tsx +47 -6
  16. package/blocks/crm/components/contact-row-actions.tsx +43 -0
  17. package/blocks/crm/components/contact-tag-chips.tsx +113 -0
  18. package/blocks/crm/components/contacts-screen.tsx +46 -5
  19. package/blocks/crm/components/owner-assign.tsx +132 -0
  20. package/blocks/crm/components/record-open-links.tsx +79 -0
  21. package/blocks/crm/components/tag-assign.tsx +112 -0
  22. package/blocks/crm/components/tag-manager.tsx +178 -0
  23. package/blocks/crm/components/unlock-info-button.tsx +82 -0
  24. package/blocks/crm/components/verify-email-button.tsx +93 -0
  25. package/blocks/crm/hooks/use-contact-tags.ts +29 -0
  26. package/blocks/crm/lib/record-actions.ts +102 -0
  27. package/blocks/deals/README.md +1 -0
  28. package/blocks/deals/block.json +17 -0
  29. package/blocks/deals/components/create-deal-button.tsx +109 -0
  30. package/blocks/deals/components/deal-detail.tsx +5 -3
  31. package/blocks/dialer/components/call-log-screen.tsx +6 -1
  32. package/blocks/documents/README.md +86 -0
  33. package/blocks/documents/block.json +378 -0
  34. package/blocks/documents/components/account-bar.tsx +48 -0
  35. package/blocks/documents/components/correspondence-form.tsx +95 -0
  36. package/blocks/documents/components/correspondence-import.tsx +106 -0
  37. package/blocks/documents/components/correspondence-panel.tsx +191 -0
  38. package/blocks/documents/components/documents-screen.tsx +76 -0
  39. package/blocks/documents/components/library-document-detail.tsx +175 -0
  40. package/blocks/documents/components/library-document-form.tsx +105 -0
  41. package/blocks/documents/components/library-folder-form.tsx +74 -0
  42. package/blocks/documents/components/library-panel.tsx +235 -0
  43. package/blocks/documents/components/library-upload.tsx +160 -0
  44. package/blocks/documents/components/question-row.tsx +130 -0
  45. package/blocks/documents/components/questionnaire-detail.tsx +197 -0
  46. package/blocks/documents/components/questionnaires-panel.tsx +173 -0
  47. package/blocks/documents/components/record-documents-tab.tsx +109 -0
  48. package/blocks/documents/lib/documents-types.ts +252 -0
  49. package/blocks/documents/lib/download.ts +64 -0
  50. package/blocks/documents/recipes/follow-a-questionnaire.ts +26 -0
  51. package/blocks/documents/recipes/log-account-correspondence.ts +27 -0
  52. package/blocks/documents/recipes/organize-the-library.ts +25 -0
  53. package/blocks/email-signatures/README.md +123 -0
  54. package/blocks/email-signatures/block.json +341 -0
  55. package/blocks/email-signatures/components/email-signatures-screen.tsx +166 -0
  56. package/blocks/email-signatures/components/signature-assign-template.tsx +119 -0
  57. package/blocks/email-signatures/components/signature-block-editor.tsx +87 -0
  58. package/blocks/email-signatures/components/signature-campaign-form.tsx +165 -0
  59. package/blocks/email-signatures/components/signature-campaigns.tsx +75 -0
  60. package/blocks/email-signatures/components/signature-identity-form.tsx +131 -0
  61. package/blocks/email-signatures/components/signature-mailboxes.tsx +195 -0
  62. package/blocks/email-signatures/components/signature-policy.tsx +130 -0
  63. package/blocks/email-signatures/components/signature-preview.tsx +119 -0
  64. package/blocks/email-signatures/components/signature-report.tsx +77 -0
  65. package/blocks/email-signatures/components/signature-template-details.tsx +112 -0
  66. package/blocks/email-signatures/components/signature-template-form.tsx +109 -0
  67. package/blocks/email-signatures/hooks/use-signatures-data.ts +93 -0
  68. package/blocks/email-signatures/lib/signatures-types.ts +392 -0
  69. package/blocks/email-signatures/recipes/assign-a-signature-to-a-new-hire.ts +32 -0
  70. package/blocks/email-signatures/recipes/create-a-template-and-make-it-default.ts +35 -0
  71. package/blocks/email-signatures/recipes/signature-engagement-report.ts +16 -0
  72. package/blocks/enrichment/README.md +22 -4
  73. package/blocks/enrichment/block.json +145 -5
  74. package/blocks/enrichment/components/auto-enrichment-configs.tsx +134 -0
  75. package/blocks/enrichment/components/auto-enrichment-settings.tsx +82 -0
  76. package/blocks/enrichment/components/bulk-enrich-panel.tsx +7 -7
  77. package/blocks/enrichment/components/enrichment-activity.tsx +93 -0
  78. package/blocks/enrichment/components/enrichment-job-detail.tsx +18 -0
  79. package/blocks/enrichment/components/enrichment-screen.tsx +34 -3
  80. package/blocks/enrichment/components/list-pipelines.tsx +97 -0
  81. package/blocks/enrichment/components/pipeline-detail.tsx +214 -0
  82. package/blocks/enrichment/components/pipeline-editor.tsx +144 -0
  83. package/blocks/enrichment/components/pipeline-templates.tsx +90 -0
  84. package/blocks/enrichment/lib/enrichment-types.ts +12 -0
  85. package/blocks/enrichment/lib/pipeline-types.ts +234 -0
  86. package/blocks/forms/README.md +2 -0
  87. package/blocks/forms/components/forms-screen.tsx +6 -3
  88. package/blocks/inbox/README.md +40 -4
  89. package/blocks/inbox/block.json +130 -8
  90. package/blocks/inbox/components/blocked-senders.tsx +103 -0
  91. package/blocks/inbox/components/compose-message.tsx +193 -0
  92. package/blocks/inbox/components/inbox-screen.tsx +54 -6
  93. package/blocks/inbox/components/inbox-search.tsx +106 -0
  94. package/blocks/inbox/components/tag-manager.tsx +136 -0
  95. package/blocks/inbox/components/thread-actions.tsx +133 -0
  96. package/blocks/inbox/components/thread-view.tsx +2 -0
  97. package/blocks/inbox/hooks/use-inbox-mailboxes.ts +31 -0
  98. package/blocks/inbox/lib/inbox-manage.ts +227 -0
  99. package/blocks/newsletter/components/newsletter-screen.tsx +3 -1
  100. package/blocks/pages/block.json +10 -1
  101. package/blocks/pages/hooks/use-landing-page-paths.ts +60 -0
  102. package/blocks/programmatic-pages/README.md +82 -0
  103. package/blocks/programmatic-pages/block.json +292 -0
  104. package/blocks/programmatic-pages/components/programmatic-campaign-form.tsx +136 -0
  105. package/blocks/programmatic-pages/components/programmatic-generate.tsx +112 -0
  106. package/blocks/programmatic-pages/components/programmatic-generated-pages.tsx +210 -0
  107. package/blocks/programmatic-pages/components/programmatic-overview.tsx +84 -0
  108. package/blocks/programmatic-pages/components/programmatic-pages-screen.tsx +221 -0
  109. package/blocks/programmatic-pages/components/programmatic-preview.tsx +66 -0
  110. package/blocks/programmatic-pages/components/programmatic-publish.tsx +102 -0
  111. package/blocks/programmatic-pages/components/programmatic-settings.tsx +96 -0
  112. package/blocks/programmatic-pages/components/programmatic-source-fields.tsx +75 -0
  113. package/blocks/programmatic-pages/components/programmatic-source.tsx +106 -0
  114. package/blocks/programmatic-pages/components/programmatic-variables.tsx +84 -0
  115. package/blocks/programmatic-pages/hooks/use-programmatic-workspace.ts +19 -0
  116. package/blocks/programmatic-pages/lib/programmatic-types.ts +330 -0
  117. package/blocks/programmatic-pages/recipes/create-a-programmatic-campaign.ts +23 -0
  118. package/blocks/programmatic-pages/recipes/render-and-follow.ts +21 -0
  119. package/blocks/programmatic-pages/recipes/review-then-publish.ts +17 -0
  120. package/blocks/quotes/README.md +25 -1
  121. package/blocks/quotes/block.json +123 -6
  122. package/blocks/quotes/components/manual-products.tsx +144 -0
  123. package/blocks/quotes/components/quote-batch-actions.tsx +86 -0
  124. package/blocks/quotes/components/quote-documents.tsx +94 -0
  125. package/blocks/quotes/components/quote-evidence.tsx +95 -0
  126. package/blocks/quotes/components/quote-list.tsx +23 -5
  127. package/blocks/quotes/components/quote-settings.tsx +184 -0
  128. package/blocks/quotes/components/quote-templates.tsx +189 -0
  129. package/blocks/quotes/components/quote-viewer.tsx +16 -3
  130. package/blocks/quotes/components/quotes-screen.tsx +15 -5
  131. package/blocks/quotes/lib/quote-admin.ts +264 -0
  132. package/blocks/sequencer/README.md +55 -0
  133. package/blocks/sequencer/block.json +172 -5
  134. package/blocks/sequencer/components/enrollment-withdraw.tsx +71 -0
  135. package/blocks/sequencer/components/sequence-builder.tsx +13 -3
  136. package/blocks/sequencer/components/sequence-campaign-link.tsx +78 -0
  137. package/blocks/sequencer/components/sequence-detail.tsx +6 -0
  138. package/blocks/sequencer/components/sequence-edit-form.tsx +111 -0
  139. package/blocks/sequencer/components/sequence-enrollments.tsx +2 -1
  140. package/blocks/sequencer/components/sequence-manage-actions.tsx +119 -0
  141. package/blocks/sequencer/components/sequence-senders-panel.tsx +84 -0
  142. package/blocks/sequencer/components/sequence-templates.tsx +160 -0
  143. package/blocks/sequencer/components/sequences-screen.tsx +36 -6
  144. package/blocks/sequencer/components/step-ai-assist.tsx +196 -0
  145. package/blocks/sequencer/components/step-editor.tsx +10 -1
  146. package/blocks/sequencer/components/step-templates.tsx +224 -0
  147. package/blocks/sequencer/lib/sequencer-manage.ts +235 -0
  148. package/blocks/sequencer/lib/sequencer-utils.ts +4 -0
  149. package/blocks/signals/README.md +39 -0
  150. package/blocks/signals/block.json +287 -0
  151. package/blocks/signals/components/auto-seed-panel.tsx +111 -0
  152. package/blocks/signals/components/listener-form.tsx +184 -0
  153. package/blocks/signals/components/matched-posts.tsx +195 -0
  154. package/blocks/signals/components/post-engagements.tsx +114 -0
  155. package/blocks/signals/components/record-social-tab.tsx +49 -0
  156. package/blocks/signals/components/social-listeners-screen.tsx +250 -0
  157. package/blocks/signals/components/social-settings.tsx +128 -0
  158. package/blocks/signals/components/source-lists.tsx +83 -0
  159. package/blocks/signals/components/top-engagers.tsx +68 -0
  160. package/blocks/signals/components/x-integration.tsx +129 -0
  161. package/blocks/signals/lib/signals-types.ts +228 -0
  162. package/blocks/signals/recipes/preview-and-create-a-listener.ts +16 -0
  163. package/blocks/signals/recipes/spend-against-caps.ts +14 -0
  164. package/blocks/signals/recipes/triage-matched-posts.ts +18 -0
  165. package/blocks/visitors/components/traffic-chart.tsx +3 -2
  166. package/blocks/web-analytics/README.md +102 -0
  167. package/blocks/web-analytics/block.json +292 -0
  168. package/blocks/web-analytics/components/acquisition-tab.tsx +154 -0
  169. package/blocks/web-analytics/components/analytics-filters.tsx +161 -0
  170. package/blocks/web-analytics/components/audience-tab.tsx +135 -0
  171. package/blocks/web-analytics/components/conversions-tab.tsx +128 -0
  172. package/blocks/web-analytics/components/engagement-tab.tsx +123 -0
  173. package/blocks/web-analytics/components/overview-tab.tsx +102 -0
  174. package/blocks/web-analytics/components/pages-tab.tsx +148 -0
  175. package/blocks/web-analytics/components/ranked-table.tsx +103 -0
  176. package/blocks/web-analytics/components/realtime-tab.tsx +103 -0
  177. package/blocks/web-analytics/components/report-card.tsx +71 -0
  178. package/blocks/web-analytics/components/web-analytics-screen.tsx +90 -0
  179. package/blocks/web-analytics/hooks/use-report.ts +48 -0
  180. package/blocks/web-analytics/lib/analytics-scope.ts +155 -0
  181. package/blocks/web-analytics/lib/page-path.ts +14 -0
  182. package/blocks/web-analytics/lib/web-analytics-types.ts +187 -0
  183. package/blocks/web-analytics/recipes/best-landing-pages.ts +26 -0
  184. package/blocks/web-analytics/recipes/form-conversion-check.ts +19 -0
  185. package/blocks/web-analytics/recipes/weekly-traffic-report.ts +37 -0
  186. package/blocks/workbench/README.md +54 -2
  187. package/blocks/workbench/block.json +234 -5
  188. package/blocks/workbench/components/workbench-auto-enrich.tsx +88 -0
  189. package/blocks/workbench/components/workbench-automation.tsx +36 -0
  190. package/blocks/workbench/components/workbench-connections.tsx +136 -0
  191. package/blocks/workbench/components/workbench-crm-import.tsx +91 -0
  192. package/blocks/workbench/components/workbench-datafile-import.tsx +154 -0
  193. package/blocks/workbench/components/workbench-grid.tsx +70 -7
  194. package/blocks/workbench/components/workbench-pipelines.tsx +173 -0
  195. package/blocks/workbench/components/workbench-row-actions.tsx +8 -1
  196. package/blocks/workbench/components/workbench-schedule.tsx +191 -0
  197. package/blocks/workbench/components/workbench-screen.tsx +50 -17
  198. package/blocks/workbench/components/workbench-send-to-sequence.tsx +116 -0
  199. package/blocks/workbench/components/workbench-table-settings.tsx +53 -0
  200. package/blocks/workbench/components/workbench-triggers.tsx +178 -0
  201. package/blocks/workbench/hooks/use-connected-crms.ts +29 -0
  202. package/blocks/workbench/lib/api-status.ts +8 -0
  203. package/blocks/workbench/lib/automation-types.ts +257 -0
  204. package/dist/cli.js +257 -51
  205. package/dist/index.browser.js +1 -1
  206. package/dist/index.browser.js.map +1 -1
  207. package/dist/index.browser.mjs +1 -1
  208. package/dist/index.browser.mjs.map +1 -1
  209. package/dist/index.js +1 -1
  210. package/dist/index.js.map +1 -1
  211. package/dist/index.mjs +1 -1
  212. package/dist/index.mjs.map +1 -1
  213. package/dist/react.browser.js +1 -1
  214. package/dist/react.browser.js.map +1 -1
  215. package/dist/react.browser.mjs +1 -1
  216. package/dist/react.browser.mjs.map +1 -1
  217. package/dist/react.js +1 -1
  218. package/dist/react.js.map +1 -1
  219. package/dist/react.mjs +1 -1
  220. package/dist/react.mjs.map +1 -1
  221. package/dist/types/blocks/auth.d.ts +45 -0
  222. package/dist/types/blocks/create.d.ts +5 -2
  223. package/dist/types/version.d.ts +2 -2
  224. package/docs/api/forms.md +1 -1
  225. package/package.json +1 -1
  226. package/stacks/6sense/pages/contacts.tsx.tmpl +2 -0
  227. package/stacks/6sense.json +20 -0
  228. package/stacks/apollo/pages/calls.tsx.tmpl +9 -9
  229. package/stacks/apollo/pages/campaigns.tsx.tmpl +27 -0
  230. package/stacks/apollo/pages/contacts.tsx.tmpl +6 -0
  231. package/stacks/apollo/pages/inbox-contact.tsx.tmpl +3 -3
  232. package/stacks/apollo/pages/lists.tsx.tmpl +14 -2
  233. package/stacks/apollo/pages/sequences.tsx.tmpl +8 -2
  234. package/stacks/apollo.json +33 -2
  235. package/stacks/clay/pages/contacts.tsx.tmpl +2 -0
  236. package/stacks/clay/pages/lists.tsx.tmpl +14 -2
  237. package/stacks/gong/pages/calls.tsx.tmpl +9 -9
  238. package/stacks/gong/pages/contacts.tsx.tmpl +2 -0
  239. package/stacks/hootsuite/pages/contacts.tsx.tmpl +2 -0
  240. package/stacks/hootsuite.json +10 -0
  241. package/stacks/hubspot/pages/campaigns.tsx.tmpl +30 -0
  242. package/stacks/hubspot/pages/companies.tsx.tmpl +2 -0
  243. package/stacks/hubspot/pages/contacts.tsx.tmpl +6 -0
  244. package/stacks/hubspot/pages/deals.tsx.tmpl +4 -0
  245. package/stacks/hubspot/pages/forms.tsx.tmpl +36 -0
  246. package/stacks/hubspot/pages/inbox.tsx.tmpl +2 -2
  247. package/stacks/hubspot/pages/sequences.tsx.tmpl +8 -2
  248. package/stacks/hubspot/pages/web-analytics.tsx.tmpl +14 -0
  249. package/stacks/hubspot.json +45 -2
  250. package/stacks/intercom/pages/inbox-contact.tsx.tmpl +3 -3
  251. package/stacks/intercom.json +10 -0
  252. package/stacks/mailchimp/pages/contacts.tsx.tmpl +2 -0
  253. package/stacks/mailchimp/pages/forms.tsx.tmpl +36 -0
  254. package/stacks/mailchimp.json +2 -1
  255. package/stacks/outreach/pages/calls.tsx.tmpl +9 -9
  256. package/stacks/outreach/pages/campaigns.tsx.tmpl +27 -0
  257. package/stacks/outreach/pages/contacts.tsx.tmpl +2 -0
  258. package/stacks/outreach/pages/inbox-contact.tsx.tmpl +3 -3
  259. package/stacks/outreach/pages/sequences.tsx.tmpl +8 -2
  260. package/stacks/outreach.json +13 -2
  261. package/stacks/pipedrive/pages/contacts.tsx.tmpl +2 -0
  262. package/stacks/pipedrive/pages/inbox.tsx.tmpl +2 -2
  263. package/stacks/pipedrive.json +10 -0
  264. package/stacks/salesforce/pages/companies.tsx.tmpl +2 -0
  265. package/stacks/salesforce/pages/contacts.tsx.tmpl +6 -0
  266. package/stacks/salesforce/pages/deals.tsx.tmpl +4 -0
  267. package/stacks/salesforce.json +10 -0
  268. package/stacks/zoominfo/pages/contacts.tsx.tmpl +4 -0
  269. package/stacks/zoominfo/pages/lists.tsx.tmpl +14 -2
  270. package/stacks/zoominfo.json +20 -0
  271. package/styles/styles.css +1 -1
  272. package/templates/auth/auth-route.ts.tmpl +16 -0
  273. package/templates/auth/g8.auth.anonymous.ts.tmpl +15 -0
  274. package/templates/auth/g8.auth.basic.ts.tmpl +17 -0
  275. package/templates/auth/g8.auth.jwt.ts.tmpl +23 -0
  276. package/templates/auth/g8.auth.password.ts.tmpl +18 -0
  277. package/templates/auth/g8.auth.session.ts.tmpl +23 -0
  278. package/templates/auth/middleware.ts.tmpl +14 -0
  279. package/templates/next-app/README.md.tmpl +5 -0
@@ -0,0 +1,175 @@
1
+ "use client";
2
+
3
+ /**
4
+ * LibraryDocumentDetail: one Library document with its text, its facts and every action on it.
5
+ *
6
+ * SDK operations, in order:
7
+ * 1. getLibraryDocument (GET /library/documents/{document_id}) read the full record, with its extracted text
8
+ * 2. downloadLibraryDocument (GET /library/documents/{document_id}/download) read the ORIGINAL bytes (not the JSON envelope), saved as a file
9
+ * 3. updateLibraryDocument, through LibraryDocumentForm write rename, move or change the text
10
+ * 4. reextractLibraryDocument (POST .../reextract) write read a PDF or DOCX's text again; replaces the stored text, never the file
11
+ * 5. refreshLibraryDocument (POST .../refresh) write re-fetch a SCRAPED page (the API may answer that it cannot yet; its message is shown as is)
12
+ * 6. restoreLibraryDocument (POST .../restore) write bring back a removed document
13
+ * 7. deleteLibraryDocument (DELETE .../{document_id}) DESTRUCTIVE removes the row AND its bytes for good; behind ConfirmSpendDialog
14
+ * Only the owner or an admin can delete (the API says 403 otherwise and the reason is shown). Nothing here is billable or external.
15
+ */
16
+
17
+ import { useState } from "react";
18
+ import type { ReactNode } from "react";
19
+ import { Download, FileText, Pencil, RefreshCw, RotateCcw, ScanText, Trash2 } from "lucide-react";
20
+ import { Button, Skeleton } from "@graph8/sdk/ui";
21
+ import { ApiError } from "@/g8/components/api-error";
22
+ import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
23
+ import { EmptyState } from "@/g8/components/empty-state";
24
+ import { DetailPanel } from "@/g8/components/screen/detail-panel";
25
+ import { FormSheet } from "@/g8/components/screen/form-sheet";
26
+ import { KeyValueList } from "@/g8/components/screen/key-value-list";
27
+ import { StatusPill } from "@/g8/components/screen/status-pill";
28
+ import { g8Delete, g8Get, g8Post } from "@/g8/lib/g8-client";
29
+ import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
30
+ import { useG8Query } from "@/g8/hooks/use-g8-query";
31
+ import { downloadFile } from "../lib/download";
32
+ import { REEXTRACT_TYPES, docName, fmtSize, folderLabel, when } from "../lib/documents-types";
33
+ import type { LibraryDoc, LibraryFolder, Rec } from "../lib/documents-types";
34
+ import { LibraryDocumentForm } from "./library-document-form";
35
+
36
+ export interface LibraryDocumentDetailProps {
37
+ doc: LibraryDoc;
38
+ folders: LibraryFolder[];
39
+ onClose: () => void;
40
+ onDeleted?: (doc: LibraryDoc) => void;
41
+ onExpand?: () => void;
42
+ onCollapse?: () => void;
43
+ renderActions?: (doc: LibraryDoc) => ReactNode;
44
+ extraTabs?: { id: string; label: string; render: (doc: LibraryDoc) => ReactNode }[];
45
+ }
46
+
47
+ const sourceLabel = (s: string | null | undefined): string => (s ? s.charAt(0).toUpperCase() + s.slice(1) : "");
48
+
49
+ export function LibraryDocumentDetail({ doc, folders, onClose, onDeleted, onExpand, onCollapse, renderActions, extraTabs }: LibraryDocumentDetailProps) {
50
+ const [editing, setEditing] = useState(false);
51
+ const [deleting, setDeleting] = useState(false);
52
+ const [notice, setNotice] = useState<string | null>(null);
53
+ const [downloadError, setDownloadError] = useState<unknown>(undefined);
54
+ const [downloading, setDownloading] = useState(false);
55
+
56
+ const full = useG8Query(
57
+ (signal) => g8Get<LibraryDoc>(`/library/documents/${encodeURIComponent(doc.id)}`, {}, { signal }).then((r) => r.data),
58
+ [doc.id],
59
+ { key: ["documents", "doc", doc.id], placeholderData: doc },
60
+ );
61
+ const d = full.data && full.data.id === doc.id ? full.data : doc;
62
+ const removed = Boolean(d.deleted_at);
63
+ const type = (d.file_type ?? "").toLowerCase();
64
+ const canReextract = (REEXTRACT_TYPES as readonly string[]).includes(type) && d.has_file_data === true && !removed;
65
+
66
+ const reextract = useG8Mutation(() => g8Post<Rec>(`/library/documents/${encodeURIComponent(doc.id)}/reextract`), {
67
+ onSuccess: (r) => { const n = (r.data as Rec | null)?.content_length; setNotice(typeof n === "number" ? `Text read again: ${n.toLocaleString()} characters.` : "Text read again."); },
68
+ invalidates: [["documents"]],
69
+ });
70
+ const refresh = useG8Mutation(() => g8Post<Rec>(`/library/documents/${encodeURIComponent(doc.id)}/refresh`), {
71
+ onSuccess: (r) => { const m = (r.data as Rec | null)?.message; setNotice(typeof m === "string" && m ? m : "Refresh requested."); },
72
+ invalidates: [["documents"]],
73
+ });
74
+ const restore = useG8Mutation(() => g8Post<LibraryDoc>(`/library/documents/${encodeURIComponent(doc.id)}/restore`), { onSuccess: () => setNotice("Document restored."), invalidates: [["documents"]] });
75
+ const remove = useG8Mutation(() => g8Delete(`/library/documents/${encodeURIComponent(doc.id)}`), {
76
+ onSuccess: () => { setDeleting(false); onDeleted?.(doc); },
77
+ invalidates: [["documents"]],
78
+ });
79
+
80
+ const save = async (): Promise<void> => {
81
+ setDownloading(true);
82
+ setDownloadError(undefined);
83
+ try {
84
+ await downloadFile(`/library/documents/${encodeURIComponent(doc.id)}/download`, d.file_name || docName(d));
85
+ } catch (err) {
86
+ setDownloadError(err);
87
+ } finally {
88
+ setDownloading(false);
89
+ }
90
+ };
91
+
92
+ const preview = full.error && !full.data ? <ApiError error={full.error} title="Could not load this document" onRetry={() => void full.refetch()} /> : full.isPlaceholderData ? (
93
+ <Skeleton className="h-32 w-full" aria-label="Loading text" />
94
+ ) : d.content ? (
95
+ <pre aria-label="Document text" className="bg-muted max-h-[28rem] overflow-auto whitespace-pre-wrap rounded-md p-3 text-sm">{d.content}</pre>
96
+ ) : (
97
+ <EmptyState compact icon={<FileText className="size-8" />} title="No text to show" description={type === "png" || type === "jpg" || type === "jpeg" ? "Images are stored as files. Download it to see it." : d.has_file_data ? "No text was read from this file. Download the original, or read it again." : "This document has no text."} />
98
+ );
99
+
100
+ const actions = (
101
+ <>
102
+ {renderActions?.(d)}
103
+ {removed ? (
104
+ <Button size="sm" variant="outline" loading={restore.loading} onClick={() => void restore.mutate(undefined)}><RotateCcw className="size-4" />Restore</Button>
105
+ ) : (
106
+ <Button size="sm" variant="outline" disabled={full.isPlaceholderData} onClick={() => setEditing(true)}><Pencil className="size-4" />Edit</Button>
107
+ )}
108
+ <Button size="sm" variant="outline" loading={downloading} onClick={() => void save()}><Download className="size-4" />Download</Button>
109
+ {canReextract ? <Button size="sm" variant="outline" loading={reextract.loading} onClick={() => void reextract.mutate(undefined)}><ScanText className="size-4" />Read text again</Button> : null}
110
+ {d.source === "scrape" && !removed ? <Button size="sm" variant="outline" loading={refresh.loading} onClick={() => void refresh.mutate(undefined)}><RefreshCw className="size-4" />Refresh from source</Button> : null}
111
+ <Button size="sm" variant="outline" onClick={() => setDeleting(true)}><Trash2 className="size-4" />Delete</Button>
112
+ </>
113
+ );
114
+
115
+ return (
116
+ <>
117
+ <DetailPanel
118
+ title={docName(d)}
119
+ subtitle={`${folderLabel(d.folder_path)}${d.file_name && d.file_name !== d.display_name ? ` · ${d.file_name}` : ""}`}
120
+ badges={<>{type ? <StatusPill tone="info">{type.toUpperCase()}</StatusPill> : null}{removed ? <StatusPill tone="warning">Removed</StatusPill> : null}</>}
121
+ actions={actions}
122
+ onClose={onClose}
123
+ onExpand={onExpand}
124
+ onCollapse={onCollapse}
125
+ summary={notice || reextract.error || refresh.error || restore.error || downloadError ? (
126
+ <div className="grid gap-2 px-4 pt-3">
127
+ {notice ? <p role="status" className="text-sm">{notice}</p> : null}
128
+ {reextract.error ? <ApiError error={reextract.error} title="Could not read the text again" compact /> : null}
129
+ {refresh.error ? <ApiError error={refresh.error} title="Could not refresh the document" compact /> : null}
130
+ {restore.error ? <ApiError error={restore.error} title="Could not restore the document" compact /> : null}
131
+ {downloadError ? <ApiError error={downloadError} title="Could not download the file" compact /> : null}
132
+ </div>
133
+ ) : undefined}
134
+ tabs={[
135
+ { id: "text", label: "Text", content: preview },
136
+ {
137
+ id: "details",
138
+ label: "Details",
139
+ content: (
140
+ <KeyValueList columns={2} items={[
141
+ { label: "Title", value: d.display_name },
142
+ { label: "File name", value: d.file_name },
143
+ { label: "Type", value: type ? type.toUpperCase() : "" },
144
+ { label: "Folder", value: folderLabel(d.folder_path) },
145
+ { label: "Size", value: fmtSize(d.file_size_bytes) },
146
+ { label: "Source", value: sourceLabel(d.source) },
147
+ { label: "Added by", value: d.owner_name },
148
+ { label: "Added", value: when(d.created_at) },
149
+ { label: "Updated", value: when(d.updated_at) },
150
+ { label: "Searchable by the AI", value: d.indexed_at ? `Since ${when(d.indexed_at)}` : "Not yet" },
151
+ { label: "Version", value: d.current_version != null ? String(d.current_version) : "" },
152
+ { label: "Document id", value: d.id, mono: true },
153
+ ]} />
154
+ ),
155
+ },
156
+ ...(extraTabs ?? []).map((t) => ({ id: t.id, label: t.label, content: t.render(d) })),
157
+ ]}
158
+ />
159
+ <FormSheet open={editing} onOpenChange={setEditing} title="Edit document" description="Only what you change is saved." width="lg">
160
+ {editing ? <LibraryDocumentForm document={doc} folders={folders} onCancel={() => setEditing(false)} onSaved={() => { setEditing(false); setNotice("Saved."); }} /> : null}
161
+ </FormSheet>
162
+ <ConfirmSpendDialog
163
+ open={deleting}
164
+ tier="destructive"
165
+ title={`Delete ${docName(d)}?`}
166
+ affected={1}
167
+ description={<><p>The document and its stored file are removed for good. It cannot be restored. Only its owner or an admin can delete it.</p>{remove.error ? <ApiError error={remove.error} title="Could not delete the document" /> : null}</>}
168
+ confirmLabel="Delete"
169
+ confirming={remove.loading}
170
+ onCancel={() => setDeleting(false)}
171
+ onConfirm={() => void remove.mutate(undefined)}
172
+ />
173
+ </>
174
+ );
175
+ }
@@ -0,0 +1,105 @@
1
+ "use client";
2
+
3
+ /**
4
+ * LibraryDocumentForm: write a text document into the Library, or edit one (rename, move, change its text).
5
+ *
6
+ * SDK operations, in order:
7
+ * 1. getLibraryDocument (GET /library/documents/{document_id}) read edit only: the FULL record is read before the form shows
8
+ * 2. createLibraryDocument (POST /library/documents) write new text document; stored as typed, no extraction
9
+ * 3. updateLibraryDocument (PUT /library/documents/{document_id}) write only the fields that changed; an omitted field is left alone
10
+ * Edit is disabled until the full record has loaded: a list row can carry less than the record, and saving a form seeded
11
+ * from it would write blanks over real text. A cleared text box is sent as an empty string, which the API stores. After a
12
+ * save the document's cache entries and the library lists are refreshed, so reopening the form shows what was saved.
13
+ */
14
+
15
+ import { useState } from "react";
16
+ import { Button, Input, Label, Skeleton, Textarea } from "@graph8/sdk/ui";
17
+ import { ApiError } from "@/g8/components/api-error";
18
+ import { g8Get, g8Post, g8Put } from "@/g8/lib/g8-client";
19
+ import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
20
+ import { useG8Query } from "@/g8/hooks/use-g8-query";
21
+ import { ROOT, docName, fileNameFor, folderLabel } from "../lib/documents-types";
22
+ import type { LibraryDoc, LibraryFolder } from "../lib/documents-types";
23
+
24
+ export interface LibraryDocumentFormProps {
25
+ /** Edit this document; omit to write a new one. */
26
+ document?: LibraryDoc;
27
+ folders: LibraryFolder[];
28
+ /** Pre-select the folder for a new document. */
29
+ folderPath?: string;
30
+ onSaved: (doc: LibraryDoc) => void;
31
+ onCancel?: () => void;
32
+ }
33
+
34
+ const SELECT = "border-input bg-background h-9 w-full rounded-md border px-2 text-sm";
35
+
36
+ export function LibraryDocumentForm({ document, folders, folderPath, onSaved, onCancel }: LibraryDocumentFormProps) {
37
+ const full = useG8Query(
38
+ (signal) => g8Get<LibraryDoc>(`/library/documents/${encodeURIComponent(document!.id)}`, {}, { signal }).then((r) => r.data),
39
+ [document?.id],
40
+ { enabled: Boolean(document), key: ["documents", "doc", document?.id ?? ""], placeholderData: document },
41
+ );
42
+ if (!document) return <Fields folders={folders} folderPath={folderPath} onSaved={onSaved} onCancel={onCancel} />;
43
+ if (full.error && (!full.data || full.isPlaceholderData)) return <ApiError error={full.error} title="Could not load this document" onRetry={() => void full.refetch()} />;
44
+ if (!full.data || full.isPlaceholderData || full.data.id !== document.id) return <Skeleton className="h-40 w-full" aria-label="Loading document" />;
45
+ return <Fields key={full.data.id} document={full.data} folders={folders} onSaved={onSaved} onCancel={onCancel} />;
46
+ }
47
+
48
+ function Fields({ document, folders, folderPath, onSaved, onCancel }: LibraryDocumentFormProps) {
49
+ const editing = Boolean(document);
50
+ const [title, setTitle] = useState(document?.display_name ?? "");
51
+ const [fileName, setFileName] = useState(document?.file_name ?? "");
52
+ const [fileTouched, setFileTouched] = useState(false);
53
+ const [folder, setFolder] = useState(document?.folder_path ?? folderPath ?? ROOT);
54
+ const [content, setContent] = useState(document?.content ?? "");
55
+ const folderChoices = [...new Set([ROOT, ...folders.map((f) => f.full_path), folder])];
56
+ const valid = title.trim() !== "" && (editing || (fileName.trim() !== "" && content.trim() !== ""));
57
+
58
+ const save = useG8Mutation(
59
+ async (): Promise<LibraryDoc> => {
60
+ if (document) {
61
+ const diff: Record<string, unknown> = {};
62
+ if (title.trim() !== (document.display_name ?? "")) diff.display_name = title.trim();
63
+ if (folder !== document.folder_path) diff.folder_path = folder;
64
+ if (content !== (document.content ?? "")) diff.content = content;
65
+ if (Object.keys(diff).length === 0) return document;
66
+ return (await g8Put<LibraryDoc>(`/library/documents/${encodeURIComponent(document.id)}`, diff)).data;
67
+ }
68
+ return (await g8Post<LibraryDoc>("/library/documents", { folder_path: folder, file_name: fileName.trim(), display_name: title.trim(), content })).data;
69
+ },
70
+ { onSuccess: onSaved, invalidates: (saved) => [["documents", "list"], ["documents", "tree"], ["documents", "doc", saved?.id ?? document?.id ?? ""]] },
71
+ );
72
+
73
+ return (
74
+ <form className="grid gap-3" onSubmit={(e) => { e.preventDefault(); if (valid) void save.mutate(undefined); }}>
75
+ <div>
76
+ <Label htmlFor="g8-dd-title" className="mb-1">Title</Label>
77
+ <Input id="g8-dd-title" value={title} onChange={(e) => { setTitle(e.target.value); if (!editing && !fileTouched) setFileName(fileNameFor(e.target.value)); }} placeholder="Security overview" />
78
+ </div>
79
+ <div className="grid gap-3 sm:grid-cols-2">
80
+ <div>
81
+ <Label htmlFor="g8-dd-folder" className="mb-1">Folder</Label>
82
+ <select id="g8-dd-folder" className={SELECT} value={folder} onChange={(e) => setFolder(e.target.value)}>
83
+ {folderChoices.map((p) => <option key={p} value={p}>{folderLabel(p)}</option>)}
84
+ </select>
85
+ </div>
86
+ {editing ? null : (
87
+ <div>
88
+ <Label htmlFor="g8-dd-file" className="mb-1">File name</Label>
89
+ <Input id="g8-dd-file" value={fileName} onChange={(e) => { setFileName(e.target.value); setFileTouched(true); }} placeholder="security-overview.md" />
90
+ </div>
91
+ )}
92
+ </div>
93
+ <div>
94
+ <Label htmlFor="g8-dd-content" className="mb-1">Text</Label>
95
+ <Textarea id="g8-dd-content" rows={12} value={content} onChange={(e) => setContent(e.target.value)} placeholder={editing ? undefined : "Write or paste the document. It is stored as typed."} />
96
+ {editing && document?.has_file_data ? <p className="text-muted-foreground mt-1 text-xs">{docName(document)} was uploaded as a file. Changing this text changes what the library reads; the original file is untouched.</p> : null}
97
+ </div>
98
+ {save.error ? <ApiError error={save.error} title="Could not save the document" /> : null}
99
+ <div className="flex gap-2">
100
+ <Button type="submit" loading={save.loading} disabled={!valid}>{editing ? "Save changes" : "Add document"}</Button>
101
+ {onCancel ? <Button type="button" variant="outline" onClick={onCancel}>Cancel</Button> : null}
102
+ </div>
103
+ </form>
104
+ );
105
+ }
@@ -0,0 +1,74 @@
1
+ "use client";
2
+
3
+ /**
4
+ * LibraryFolderForm: make a Library folder or rename one.
5
+ *
6
+ * SDK operations, in order:
7
+ * 1. createLibraryFolder (POST /library/folders) write a new folder under a parent (the Library root or another folder)
8
+ * 2. renameLibraryFolder (PUT /library/folders/{folder_id}) write rename only; the documents inside keep their ids
9
+ * The API's rules are checked here first so the person sees them before the call: a name is letters, numbers, spaces,
10
+ * hyphens and underscores (at most 50), and folders go three levels deep. Nothing here is billable, external or destructive.
11
+ */
12
+
13
+ import { useState } from "react";
14
+ import { Button, Input, Label } from "@graph8/sdk/ui";
15
+ import { ApiError } from "@/g8/components/api-error";
16
+ import { g8Post, g8Put } from "@/g8/lib/g8-client";
17
+ import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
18
+ import { ROOT, folderLabel, folderNameProblem } from "../lib/documents-types";
19
+ import type { LibraryFolder } from "../lib/documents-types";
20
+
21
+ export interface LibraryFolderFormProps {
22
+ /** Rename this folder; omit to create one. */
23
+ folder?: LibraryFolder;
24
+ /** Every folder, to offer as the parent when creating. */
25
+ folders: LibraryFolder[];
26
+ /** Pre-select the parent when creating. */
27
+ parentPath?: string;
28
+ onSaved: (folder: LibraryFolder) => void;
29
+ onCancel?: () => void;
30
+ }
31
+
32
+ const SELECT = "border-input bg-background h-9 w-full rounded-md border px-2 text-sm";
33
+
34
+ export function LibraryFolderForm({ folder, folders, parentPath, onSaved, onCancel }: LibraryFolderFormProps) {
35
+ const [name, setName] = useState(folder?.name ?? "");
36
+ const [parent, setParent] = useState(parentPath || ROOT);
37
+ const parentOptions = [ROOT, ...folders.map((f) => f.full_path)].filter((p) => p.split("/").length < 3);
38
+ const problem = name.trim() === "" ? null : folderNameProblem(name, folder ? folder.parent_path : parent);
39
+ const changed = folder ? name.trim() !== folder.name : true;
40
+ const valid = name.trim() !== "" && problem === null && changed;
41
+
42
+ const save = useG8Mutation(
43
+ async (): Promise<LibraryFolder> => {
44
+ if (folder) return (await g8Put<LibraryFolder>(`/library/folders/${encodeURIComponent(folder.id)}`, { name: name.trim() })).data;
45
+ return (await g8Post<LibraryFolder>("/library/folders", { name: name.trim(), parent_path: parent })).data;
46
+ },
47
+ { onSuccess: onSaved, invalidates: [["documents"]] },
48
+ );
49
+
50
+ return (
51
+ <form className="grid gap-3" onSubmit={(e) => { e.preventDefault(); if (valid) void save.mutate(undefined); }}>
52
+ <div>
53
+ <Label htmlFor="g8-df-name" className="mb-1">Folder name</Label>
54
+ <Input id="g8-df-name" value={name} maxLength={50} onChange={(e) => setName(e.target.value)} placeholder="Case studies" />
55
+ {problem ? <p role="alert" className="text-error mt-1 text-xs">{problem}</p> : null}
56
+ </div>
57
+ {folder ? (
58
+ <p className="text-muted-foreground text-xs">Inside {folderLabel(folder.parent_path)}. Renaming keeps every document in it.</p>
59
+ ) : (
60
+ <div>
61
+ <Label htmlFor="g8-df-parent" className="mb-1">Inside</Label>
62
+ <select id="g8-df-parent" className={SELECT} value={parent} onChange={(e) => setParent(e.target.value)}>
63
+ {parentOptions.map((p) => <option key={p} value={p}>{folderLabel(p)}</option>)}
64
+ </select>
65
+ </div>
66
+ )}
67
+ {save.error ? <ApiError error={save.error} title={folder ? "Could not rename the folder" : "Could not create the folder"} /> : null}
68
+ <div className="flex gap-2">
69
+ <Button type="submit" loading={save.loading} disabled={!valid}>{folder ? "Rename folder" : "Create folder"}</Button>
70
+ {onCancel ? <Button type="button" variant="outline" onClick={onCancel}>Cancel</Button> : null}
71
+ </div>
72
+ </form>
73
+ );
74
+ }
@@ -0,0 +1,235 @@
1
+ "use client";
2
+
3
+ /**
4
+ * LibraryPanel: the organization's document Library as a finished page: folders on one side, documents and the selected
5
+ * document's detail on the other.
6
+ *
7
+ * SDK operations, in order:
8
+ * 1. getLibraryTree (GET /library/tree) read read FIRST: the API creates the preset folders (Security, Case Studies) when the tree is read
9
+ * 2. listLibraryFolders (GET /library/folders) read the flat folder list (the tree leaves out folders more than one level down)
10
+ * 3. listLibraryDocuments (GET /library/documents) read every document, or with removed ones too; the API does not page it
11
+ * 4. deleteLibraryFolder (DELETE /library/folders/{folder_id}) DESTRUCTIVE an EMPTY folder only; behind ConfirmSpendDialog
12
+ * 5. reextractAllLibraryDocuments (POST .../reextract-all) write replaces the stored text of PDF and Word files (never the files); behind ConfirmSpendDialog, counted from the documents it will touch
13
+ * New folders, renames, new text documents, uploads and every per-document action live in the composed parts
14
+ * (LibraryFolderForm, LibraryDocumentForm, LibraryUpload, LibraryDocumentDetail). The Library here is the organization's
15
+ * (global) one: the API's campaign and account libraries are read and written with different ids, so they are not offered.
16
+ * URL: `?id=` the selected document, `?folder=` the folder, `&view=full` the document as a page.
17
+ */
18
+
19
+ import { useState } from "react";
20
+ import type { ReactNode } from "react";
21
+ import { FileText, Folder, FolderOpen, FolderPlus, Pencil, Plus, ScanText, Trash2, Upload } from "lucide-react";
22
+ import { Button, Skeleton } from "@graph8/sdk/ui";
23
+ import { useBrand } from "@/g8/hooks/use-brand";
24
+ import { ApiError } from "@/g8/components/api-error";
25
+ import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
26
+ import { EmptyState } from "@/g8/components/empty-state";
27
+ import { FormSheet } from "@/g8/components/screen/form-sheet";
28
+ import { SplitView } from "@/g8/components/screen/split-view";
29
+ import { StatStrip } from "@/g8/components/screen/stat-strip";
30
+ import { StatusPill } from "@/g8/components/screen/status-pill";
31
+ import { Toolbar } from "@/g8/components/screen/toolbar";
32
+ import { useUrlState } from "@/g8/hooks/use-url-state";
33
+ import { g8Delete, g8Get, g8Post } from "@/g8/lib/g8-client";
34
+ import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
35
+ import { useG8Query } from "@/g8/hooks/use-g8-query";
36
+ import { plural } from "@/g8/lib/plural";
37
+ import { DEFAULT_MAX_BYTES, REEXTRACT_TYPES, ROOT, docName, fmtSize, folderLabel, rowsOf, when } from "../lib/documents-types";
38
+ import type { LibraryDoc, LibraryFolder, Rec } from "../lib/documents-types";
39
+ import { LibraryDocumentDetail } from "./library-document-detail";
40
+ import { LibraryDocumentForm } from "./library-document-form";
41
+ import { LibraryFolderForm } from "./library-folder-form";
42
+ import { LibraryUpload } from "./library-upload";
43
+
44
+ export interface LibraryPanelProps {
45
+ renderRowActions?: (doc: LibraryDoc) => ReactNode;
46
+ renderDetailActions?: (doc: LibraryDoc) => ReactNode;
47
+ extraDetailTabs?: { id: string; label: string; render: (doc: LibraryDoc) => ReactNode }[];
48
+ toolbarActions?: ReactNode;
49
+ /** Largest upload in bytes. Default 5 MiB, the /api/g8 proxy's body limit. */
50
+ maxBytes?: number;
51
+ }
52
+
53
+ type Sheet = null | "upload" | "document" | "folder" | "rename";
54
+ const SELECT = "border-input bg-background h-9 rounded-md border px-2 text-sm";
55
+ const ALL = "";
56
+
57
+ /** The documents the "read text again" call will touch: PDF and Word files with stored bytes, in the folder and under it (the API matches on the path prefix). */
58
+ export function reextractTargets(docs: LibraryDoc[], folderPath: string): LibraryDoc[] {
59
+ return docs.filter((d) => !d.deleted_at && d.has_file_data === true && (REEXTRACT_TYPES as readonly string[]).includes((d.file_type ?? "").toLowerCase()) && (folderPath === ALL || d.folder_path.startsWith(folderPath)));
60
+ }
61
+
62
+ export function LibraryPanel({ renderRowActions, renderDetailActions, extraDetailTabs, toolbarActions, maxBytes = DEFAULT_MAX_BYTES }: LibraryPanelProps) {
63
+ const brand = useBrand();
64
+ const [id, setId] = useUrlState("id", "");
65
+ const [folderParam, setFolderParam] = useUrlState("folder", "");
66
+ const [view, setView] = useUrlState("view", "");
67
+ const [search, setSearch] = useState("");
68
+ const [typeFilter, setTypeFilter] = useState("");
69
+ const [sourceFilter, setSourceFilter] = useState("");
70
+ const [showRemoved, setShowRemoved] = useState(false);
71
+ const [sheet, setSheet] = useState<Sheet>(null);
72
+ const [folderToDelete, setFolderToDelete] = useState<LibraryFolder | null>(null);
73
+ const [rereading, setRereading] = useState(false);
74
+ const [notice, setNotice] = useState<string | null>(null);
75
+
76
+ const tree = useG8Query((signal) => g8Get<Rec>("/library/tree", {}, { signal }).then((r) => r.data), [], { key: ["documents", "tree"] });
77
+ const ready = tree.data !== undefined || tree.error !== undefined;
78
+ const foldersQ = useG8Query((signal) => g8Get<Rec>("/library/folders", {}, { signal }).then((r) => rowsOf<LibraryFolder>(r.data)), [], { enabled: ready, key: ["documents", "folders"] });
79
+ const docsQ = useG8Query(
80
+ (signal) => g8Get<Rec>("/library/documents", { include_deleted: showRemoved ? true : undefined }, { signal }).then((r) => rowsOf<LibraryDoc>(r.data)),
81
+ [showRemoved],
82
+ { key: ["documents", "list", showRemoved] },
83
+ );
84
+
85
+ const folders = (foldersQ.data ?? []).slice().sort((a, b) => a.full_path.localeCompare(b.full_path));
86
+ const docs = docsQ.data ?? [];
87
+ const folder = folders.find((f) => f.full_path === folderParam) ?? null;
88
+ const folderPath = folder ? folder.full_path : ALL;
89
+ const q = search.trim().toLowerCase();
90
+ const shown = docs.filter((d) => (folderPath === ALL || d.folder_path === folderPath) && (!typeFilter || (d.file_type ?? "") === typeFilter) && (!sourceFilter || (d.source ?? "") === sourceFilter) && (!q || `${docName(d)} ${d.file_name ?? ""} ${d.folder_path}`.toLowerCase().includes(q)));
91
+ const selected = docs.find((d) => d.id === id) ?? null;
92
+ const countIn = (path: string): number => docs.filter((d) => d.folder_path === path && !d.deleted_at).length;
93
+ const hasChildren = (f: LibraryFolder): boolean => folders.some((x) => x.parent_path === f.full_path);
94
+ const targets = reextractTargets(docs, folderPath);
95
+ const types = [...new Set(docs.map((d) => d.file_type).filter((t): t is string => Boolean(t)))].sort();
96
+ const sources = [...new Set(docs.map((d) => d.source).filter((s): s is string => Boolean(s)))].sort();
97
+ const known = docs.map((d) => d.file_size_bytes).filter((n): n is number => typeof n === "number");
98
+ const closeDetail = (): void => { setId(null); setView(null); };
99
+
100
+ const deleteFolder = useG8Mutation((f: LibraryFolder) => g8Delete(`/library/folders/${encodeURIComponent(f.id)}`), {
101
+ onSuccess: (_r, f) => { setFolderToDelete(null); if (folderParam === f.full_path) setFolderParam(null); },
102
+ invalidates: [["documents"]],
103
+ });
104
+ const rereadAll = useG8Mutation(() => g8Post<Rec>("/library/documents/reextract-all", undefined, { query: { folder_path: folder?.full_path } }), {
105
+ onSuccess: () => { setRereading(false); setNotice(`Asked the library to read the text of ${plural(targets.length, "document")} again.`); },
106
+ invalidates: [["documents"]],
107
+ });
108
+
109
+ const rail = (
110
+ <nav aria-label="Folders" className="grid gap-0.5 text-sm">
111
+ <button type="button" aria-current={folderPath === ALL} className={`flex items-center justify-between rounded-md px-2 py-1.5 text-left ${folderPath === ALL ? "bg-muted font-medium" : "hover:bg-muted"}`} onClick={() => setFolderParam(null)}>
112
+ <span className="flex items-center gap-2"><FolderOpen className="size-4" />All documents</span><span className="text-muted-foreground tabular-nums">{docsQ.data ? docs.filter((d) => !d.deleted_at).length : ""}</span>
113
+ </button>
114
+ {folders.map((f) => (
115
+ <button key={f.id} type="button" aria-current={folderPath === f.full_path} style={{ paddingLeft: `${0.5 + (f.full_path.split("/").length - 2) * 0.9}rem` }} className={`flex items-center justify-between rounded-md py-1.5 pr-2 text-left ${folderPath === f.full_path ? "bg-muted font-medium" : "hover:bg-muted"}`} onClick={() => setFolderParam(f.full_path)}>
116
+ <span className="flex min-w-0 items-center gap-2"><Folder className="size-4 shrink-0" /><span className="truncate">{f.name}</span></span><span className="text-muted-foreground tabular-nums">{docsQ.data ? countIn(f.full_path) : ""}</span>
117
+ </button>
118
+ ))}
119
+ {foldersQ.error ? <ApiError error={foldersQ.error} title="Could not load folders" compact onRetry={() => void foldersQ.refetch()} /> : null}
120
+ </nav>
121
+ );
122
+
123
+ const rows = docsQ.error ? <ApiError error={docsQ.error} title="Could not load documents" onRetry={() => void docsQ.refetch()} /> : docsQ.loading && !docsQ.data ? (
124
+ <div aria-busy="true" className="grid gap-2"><Skeleton className="h-14 w-full" /><Skeleton className="h-14 w-full" /><Skeleton className="h-14 w-full" /></div>
125
+ ) : shown.length === 0 ? (
126
+ <EmptyState icon={<FileText className="size-8" />} title={docs.length === 0 ? "Nothing in the library yet" : "No documents match"} description={docs.length === 0 ? `Add the material ${brand.name} should draw on: case studies, security policies, battlecards.` : folder && !q && !typeFilter && !sourceFilter ? `${folderLabel(folderPath)} has no documents.` : "Try another search or filter."} action={docs.length === 0 ? <Button onClick={() => setSheet("upload")}>Upload files</Button> : undefined} />
127
+ ) : (
128
+ <>
129
+ <p role="status" className="text-muted-foreground mb-2 text-xs">{shown.length === docs.length ? `All ${plural(docs.length, "document")}` : `${shown.length.toLocaleString()} of ${plural(docs.length, "document")}`}</p>
130
+ <ul className="grid gap-2">
131
+ {shown.map((d) => (
132
+ <li key={d.id} className={`flex flex-wrap items-center justify-between gap-2 rounded-md border p-3 ${d.id === id ? "bg-muted" : ""}`}>
133
+ <button type="button" className="min-w-0 flex-1 text-left" onClick={() => setId(d.id)}>
134
+ <span className="block truncate font-medium">{docName(d)}</span>
135
+ <span className="text-muted-foreground block truncate text-xs">{folderLabel(d.folder_path)} · {fmtSize(d.file_size_bytes) || "-"} · {when(d.updated_at ?? d.created_at) || "-"}</span>
136
+ </button>
137
+ {d.deleted_at ? <StatusPill tone="warning">Removed</StatusPill> : null}
138
+ {d.file_type ? <StatusPill tone="info">{d.file_type.toUpperCase()}</StatusPill> : null}
139
+ {renderRowActions?.(d)}
140
+ </li>
141
+ ))}
142
+ </ul>
143
+ </>
144
+ );
145
+
146
+ const list = (
147
+ <div className="grid gap-3">
148
+ <Toolbar
149
+ search={{ value: search, onChange: setSearch, placeholder: "Search documents" }}
150
+ filters={(
151
+ <>
152
+ <select aria-label="Filter by type" className={SELECT} value={typeFilter} onChange={(e) => setTypeFilter(e.target.value)}><option value="">All types</option>{types.map((t) => <option key={t} value={t}>{t.toUpperCase()}</option>)}</select>
153
+ <select aria-label="Filter by source" className={SELECT} value={sourceFilter} onChange={(e) => setSourceFilter(e.target.value)}><option value="">All sources</option>{sources.map((s) => <option key={s} value={s}>{s.charAt(0).toUpperCase() + s.slice(1)}</option>)}</select>
154
+ <label className="flex items-center gap-1.5 text-sm"><input type="checkbox" checked={showRemoved} onChange={(e) => { setShowRemoved(e.target.checked); closeDetail(); }} />Show removed</label>
155
+ </>
156
+ )}
157
+ actions={(
158
+ <>
159
+ {toolbarActions}
160
+ <Button size="sm" variant="outline" onClick={() => setSheet("folder")}><FolderPlus className="size-4" />New folder</Button>
161
+ <Button size="sm" variant="outline" onClick={() => setSheet("document")}><Plus className="size-4" />New document</Button>
162
+ <Button size="sm" onClick={() => setSheet("upload")}><Upload className="size-4" />Upload</Button>
163
+ </>
164
+ )}
165
+ />
166
+ <div className="grid gap-3 lg:grid-cols-[14rem_1fr]">
167
+ <div className="grid content-start gap-2">
168
+ {rail}
169
+ {folder ? (
170
+ <div className="flex flex-wrap gap-1">
171
+ <Button size="sm" variant="ghost" onClick={() => setSheet("rename")}><Pencil className="size-4" />Rename folder</Button>
172
+ <Button size="sm" variant="ghost" disabled={countIn(folder.full_path) > 0 || hasChildren(folder)} title={countIn(folder.full_path) > 0 || hasChildren(folder) ? "Only an empty folder can be deleted: move or delete what is inside first." : undefined} onClick={() => setFolderToDelete(folder)}><Trash2 className="size-4" />Delete folder</Button>
173
+ </div>
174
+ ) : null}
175
+ <Button size="sm" variant="ghost" disabled={targets.length === 0} title={targets.length === 0 ? "No PDF or Word files here to read again." : undefined} onClick={() => setRereading(true)}><ScanText className="size-4" />{folder ? "Read this folder's text again" : "Read all text again"}</Button>
176
+ </div>
177
+ <div className="min-w-0">{notice ? <p role="status" className="mb-2 text-sm">{notice}</p> : null}{rows}</div>
178
+ </div>
179
+ </div>
180
+ );
181
+
182
+ return (
183
+ <div className="grid gap-4">
184
+ <StatStrip items={[
185
+ { label: "Documents", value: docsQ.data ? docs.filter((d) => !d.deleted_at).length.toLocaleString() : "..." },
186
+ { label: "Folders", value: foldersQ.data ? folders.length.toLocaleString() : "..." },
187
+ { label: "Stored", value: known.length > 0 ? fmtSize(known.reduce((a, b) => a + b, 0)) : "-", hint: known.length < docs.length ? "Adds up the documents that report a size." : undefined },
188
+ { label: "Removed", value: showRemoved ? docs.filter((d) => d.deleted_at).length.toLocaleString() : "-", hint: showRemoved ? undefined : "Turn on Show removed to count them." },
189
+ ]} />
190
+ <SplitView
191
+ list={list}
192
+ detail={selected ? <LibraryDocumentDetail key={selected.id} doc={selected} folders={folders} onClose={closeDetail} onDeleted={closeDetail} onExpand={() => setView("full")} onCollapse={view === "full" ? () => setView(null) : undefined} renderActions={renderDetailActions} extraTabs={extraDetailTabs} /> : null}
193
+ detailOpen={Boolean(selected)}
194
+ onCloseDetail={closeDetail}
195
+ expanded={view === "full"}
196
+ onExpandedChange={(e) => setView(e ? "full" : null)}
197
+ detailWidth="xl"
198
+ />
199
+ <FormSheet open={sheet === "upload"} onOpenChange={(o) => setSheet(o ? "upload" : null)} title="Upload files" description="Added to the folder you choose and read for their text.">
200
+ {sheet === "upload" ? <LibraryUpload folders={folders} folderPath={folderPath || ROOT} maxBytes={maxBytes} onDone={() => setSheet(null)} /> : null}
201
+ </FormSheet>
202
+ <FormSheet open={sheet === "document"} onOpenChange={(o) => setSheet(o ? "document" : null)} title="New document" description="Text you write or paste. It is stored as typed." width="lg">
203
+ {sheet === "document" ? <LibraryDocumentForm folders={folders} folderPath={folderPath || ROOT} onCancel={() => setSheet(null)} onSaved={(d) => { setSheet(null); setId(d.id); }} /> : null}
204
+ </FormSheet>
205
+ <FormSheet open={sheet === "folder"} onOpenChange={(o) => setSheet(o ? "folder" : null)} title="New folder">
206
+ {sheet === "folder" ? <LibraryFolderForm folders={folders} parentPath={folderPath || ROOT} onCancel={() => setSheet(null)} onSaved={(f) => { setSheet(null); setFolderParam(f.full_path); }} /> : null}
207
+ </FormSheet>
208
+ <FormSheet open={sheet === "rename" && folder !== null} onOpenChange={(o) => setSheet(o ? "rename" : null)} title="Rename folder">
209
+ {sheet === "rename" && folder ? <LibraryFolderForm folder={folder} folders={folders} onCancel={() => setSheet(null)} onSaved={(f) => { setSheet(null); setFolderParam(f.full_path ?? folder.full_path); }} /> : null}
210
+ </FormSheet>
211
+ <ConfirmSpendDialog
212
+ open={folderToDelete !== null}
213
+ tier="destructive"
214
+ title={`Delete the folder ${folderToDelete?.name ?? ""}?`}
215
+ affected={1}
216
+ description={<><p>An empty folder is removed. A folder with documents in it cannot be deleted.</p>{deleteFolder.error ? <ApiError error={deleteFolder.error} title="Could not delete the folder" /> : null}</>}
217
+ confirmLabel="Delete"
218
+ confirming={deleteFolder.loading}
219
+ onCancel={() => setFolderToDelete(null)}
220
+ onConfirm={() => { if (folderToDelete) void deleteFolder.mutate(folderToDelete); }}
221
+ />
222
+ <ConfirmSpendDialog
223
+ open={rereading}
224
+ tier="destructive"
225
+ title={`Read the text of ${plural(targets.length, "document")} again?`}
226
+ affected={targets.length}
227
+ description={<><p>{folder ? `PDF and Word files in ${folderLabel(folder.full_path)} and under it` : "Every PDF and Word file in the library"} get their stored text replaced by a fresh read, including text edited by hand. The files themselves are not changed. A large library takes a while.</p>{rereadAll.error ? <ApiError error={rereadAll.error} title="Could not start the re-read" /> : null}</>}
228
+ confirmLabel="Read again"
229
+ confirming={rereadAll.loading}
230
+ onCancel={() => setRereading(false)}
231
+ onConfirm={() => void rereadAll.mutate(undefined)}
232
+ />
233
+ </div>
234
+ );
235
+ }