deepspace 0.3.10 → 0.4.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 (120) hide show
  1. package/dist/cli.js +331 -115
  2. package/dist/cli.js.map +1 -1
  3. package/dist/index.d.ts +199 -16
  4. package/dist/index.js +354 -166
  5. package/dist/index.js.map +1 -1
  6. package/dist/server.d.ts +242 -73
  7. package/dist/server.js +985 -374
  8. package/dist/server.js.map +1 -1
  9. package/dist/worker.d.ts +322 -73
  10. package/dist/worker.js +961 -343
  11. package/dist/worker.js.map +1 -1
  12. package/features/admin-page/feature.json +29 -0
  13. package/features/admin-page/src/AdminPage.tsx +292 -0
  14. package/features/admin-page/src/admin-schema.ts +20 -0
  15. package/features/ai-chat/feature.json +41 -0
  16. package/features/ai-chat/src/AiChatPage.tsx +692 -0
  17. package/features/ai-chat/src/ChatPanel.tsx +1098 -0
  18. package/features/ai-chat/src/ai-chat-schema.ts +11 -0
  19. package/features/canvas/feature.json +45 -0
  20. package/features/canvas/src/CanvasEditorPage.tsx +87 -0
  21. package/features/canvas/src/CanvasListPage.tsx +202 -0
  22. package/features/canvas/src/canvas-schema.ts +33 -0
  23. package/features/canvas/src/components/CanvasToolbar.tsx +170 -0
  24. package/features/canvas/src/components/CanvasView.tsx +369 -0
  25. package/features/canvas/src/components/ShapeRenderer.tsx +114 -0
  26. package/features/cron/feature.json +33 -0
  27. package/features/cron/src/CronLogPage.tsx +100 -0
  28. package/features/cron/src/cron.ts +25 -0
  29. package/features/docs/feature.json +96 -0
  30. package/features/docs/src/DocsEditorPage.tsx +1012 -0
  31. package/features/docs/src/DocsListPage.tsx +951 -0
  32. package/features/docs/src/DocsOutlinePanel.tsx +68 -0
  33. package/features/docs/src/DocsPresence.tsx +138 -0
  34. package/features/docs/src/InviteDialog.tsx +336 -0
  35. package/features/docs/src/LibrarySidebar.tsx +464 -0
  36. package/features/docs/src/docs-favorites.ts +18 -0
  37. package/features/docs/src/docs-library-types.ts +15 -0
  38. package/features/docs/src/docs-schema.ts +80 -0
  39. package/features/docs/src/docs-ui.css +309 -0
  40. package/features/docs/src/editor/DocEditorSurface.tsx +257 -0
  41. package/features/docs/src/editor/DocsTiptapToolbar.tsx +560 -0
  42. package/features/docs/src/editor/useDocEditor.ts +206 -0
  43. package/features/docs/src/use-yjs-room-with-awareness.ts +285 -0
  44. package/features/file-attachments/feature.json +42 -0
  45. package/features/file-attachments/src/AttachmentsPage.tsx +864 -0
  46. package/features/file-attachments/src/attachments-constants.ts +9 -0
  47. package/features/file-attachments/src/attachments-schema.ts +45 -0
  48. package/features/file-attachments/src/useFilePreview.ts +471 -0
  49. package/features/file-attachments/src/useMimeTypeDetection.ts +138 -0
  50. package/features/file-manager/feature.json +24 -0
  51. package/features/file-manager/src/FileManagerPage.tsx +330 -0
  52. package/features/integration-test/feature.json +18 -0
  53. package/features/integration-test/src/IntegrationTestPage.tsx +423 -0
  54. package/features/integration-test/src/integration.spec.ts +42 -0
  55. package/features/items/feature.json +33 -0
  56. package/features/items/src/ItemsPage.tsx +325 -0
  57. package/features/items/src/items-constants.ts +10 -0
  58. package/features/items/src/items-schema.ts +27 -0
  59. package/features/kanban/feature.json +18 -0
  60. package/features/kanban/src/Kanban.tsx +283 -0
  61. package/features/landing/feature.json +62 -0
  62. package/features/landing/src/LandingPage.tsx +627 -0
  63. package/features/landing/src/primitives.tsx +389 -0
  64. package/features/landing/src/sections/FAQSection.tsx +110 -0
  65. package/features/landing/src/sections/FeaturesGridSection.tsx +311 -0
  66. package/features/landing/src/sections/HowItWorksSection.tsx +62 -0
  67. package/features/landing/src/sections/LogoCloudSection.tsx +58 -0
  68. package/features/landing/src/sections/ShowcaseSection.tsx +63 -0
  69. package/features/landing/src/sections/StatsSection.tsx +31 -0
  70. package/features/landing/src/sections/TeamSection.tsx +86 -0
  71. package/features/landing/src/sections/TestimonialsSection.tsx +68 -0
  72. package/features/landing/src/sections/VideoSection.tsx +71 -0
  73. package/features/leaderboard/feature.json +33 -0
  74. package/features/leaderboard/src/LeaderboardPage.tsx +372 -0
  75. package/features/leaderboard/src/leaderboard-constants.ts +21 -0
  76. package/features/leaderboard/src/leaderboard-schema.ts +36 -0
  77. package/features/messaging/feature.json +111 -0
  78. package/features/messaging/src/ChatMultiPage.tsx +300 -0
  79. package/features/messaging/src/ChatPage.tsx +75 -0
  80. package/features/messaging/src/components/chat/ChatHeader.tsx +76 -0
  81. package/features/messaging/src/components/chat/MessageActionSheet.tsx +168 -0
  82. package/features/messaging/src/components/chat/MessageInput.tsx +65 -0
  83. package/features/messaging/src/components/chat/MessageItem.tsx +352 -0
  84. package/features/messaging/src/components/chat/MessageList.tsx +248 -0
  85. package/features/messaging/src/components/chat/ReactionPicker.tsx +2 -0
  86. package/features/messaging/src/components/chat/ThreadPanel.tsx +107 -0
  87. package/features/messaging/src/components/chat/UserProfilePopover.tsx +158 -0
  88. package/features/messaging/src/components/chat-multi/AddMemberModal.tsx +188 -0
  89. package/features/messaging/src/components/chat-multi/BrowseGroupsModal.tsx +163 -0
  90. package/features/messaging/src/components/chat-multi/ChannelListItem.tsx +147 -0
  91. package/features/messaging/src/components/chat-multi/ChannelSettingsPanel.tsx +344 -0
  92. package/features/messaging/src/components/chat-multi/ChannelSidebar.tsx +460 -0
  93. package/features/messaging/src/components/chat-multi/CreateGroupModal.tsx +190 -0
  94. package/features/messaging/src/components/chat-multi/InvitationList.tsx +149 -0
  95. package/features/messaging/src/components/chat-multi/NewMessageModal.tsx +159 -0
  96. package/features/messaging/src/hooks/useChatChannel.ts +64 -0
  97. package/features/messaging/src/hooks/useLongPress.ts +74 -0
  98. package/features/messaging/src/hooks/useMultiChannel.ts +142 -0
  99. package/features/messaging/src/messaging-schema.ts +25 -0
  100. package/features/presence-test/feature.json +21 -0
  101. package/features/presence-test/src/PresenceTestPage.tsx +120 -0
  102. package/features/sidebar/feature.json +19 -0
  103. package/features/sidebar/src/AppSidebar.tsx +558 -0
  104. package/features/tasks/feature.json +33 -0
  105. package/features/tasks/src/TasksPage.tsx +598 -0
  106. package/features/tasks/src/tasks-constants.ts +43 -0
  107. package/features/tasks/src/tasks-schema.ts +56 -0
  108. package/features/testing/feature.json +49 -0
  109. package/features/testing/src/TestPage.tsx +145 -0
  110. package/features/testing/src/cpu-burn-action.ts +51 -0
  111. package/features/testing/src/subrequest-burn-action.ts +53 -0
  112. package/features/testing/src/test-schema.ts +23 -0
  113. package/features/topbar/feature.json +18 -0
  114. package/features/topbar/src/Topbar.tsx +353 -0
  115. package/features/tree/feature.json +26 -0
  116. package/features/tree/src/TreeSidebar.tsx +607 -0
  117. package/features/tree/src/tree-constants.ts +68 -0
  118. package/features/tree/src/tree-hooks.ts +258 -0
  119. package/package.json +4 -2
  120. package/scripts/add-feature.cjs +654 -0
@@ -0,0 +1,9 @@
1
+ /**
2
+ * File Attachments Feature - Constants
3
+ *
4
+ * For file category detection, use getFileCategory from useMimeTypeDetection.ts.
5
+ * For human-readable sizes, import `formatFileSize` from 'deepspace'.
6
+ */
7
+
8
+ /** Max upload size (50MB) */
9
+ export const MAX_FILE_SIZE = 50 * 1024 * 1024
@@ -0,0 +1,45 @@
1
+ /**
2
+ * File Attachments Feature - Schema
3
+ *
4
+ * Stores file metadata (name, type, size, R2 key) in a collection.
5
+ * Actual binary data lives in R2 via useR2Files.
6
+ *
7
+ * Demonstrates:
8
+ * - ownerField for 'own' permission checks
9
+ * - userBound fields that auto-populate with current user ID
10
+ * - Pairing collection metadata with R2 file storage
11
+ */
12
+
13
+ import type { CollectionSchema } from 'deepspace/worker'
14
+
15
+ export const attachmentsSchema: CollectionSchema = {
16
+ name: 'attachments',
17
+ columns: [
18
+ { name: 'fileName', storage: 'text', interpretation: 'plain', required: true },
19
+ { name: 'fileKey', storage: 'text', interpretation: 'plain', required: true },
20
+ { name: 'mimeType', storage: 'text', interpretation: 'plain', required: true },
21
+ { name: 'fileSize', storage: 'number', interpretation: 'plain', required: true },
22
+ { name: 'ownerId', storage: 'text', interpretation: 'plain', required: true, userBound: true, immutable: true },
23
+ ],
24
+ ownerField: 'ownerId',
25
+ // SECURITY: `read: 'own'` for member/viewer scopes the realtime subscription
26
+ // to records the caller owns. With `read: true` (the previous setting),
27
+ // every signed-in user received every attachment row — including other
28
+ // users' fileKeys, which makes the R2 binaries reachable via the per-app
29
+ // /api/files/<key> endpoint. Admins still see all rows so they can moderate.
30
+ permissions: {
31
+ viewer: {
32
+ read: 'own',
33
+ create: false,
34
+ update: false,
35
+ delete: false,
36
+ },
37
+ member: {
38
+ read: 'own',
39
+ create: true,
40
+ update: 'own',
41
+ delete: 'own',
42
+ },
43
+ admin: { read: true, create: true, update: true, delete: true },
44
+ },
45
+ }
@@ -0,0 +1,471 @@
1
+ /**
2
+ * useFilePreview — Centralized file preview hook with dynamic imports.
3
+ *
4
+ * Mirrors the main app's pattern:
5
+ * - Image, video, audio, PDF → native browser elements (no extra deps)
6
+ * - Code/text → CodeMirror (dynamically imported)
7
+ * - CSV → PapaParse (dynamically imported)
8
+ * - DOCX → docx-preview (dynamically imported)
9
+ * - XLSX → ExcelJS (dynamically imported)
10
+ *
11
+ * Heavy libraries are only loaded when needed, keeping the base bundle small.
12
+ *
13
+ * @example
14
+ * ```tsx
15
+ * const { PreviewComponent, isLoading, error } = useFilePreview({
16
+ * url: 'https://...',
17
+ * fileName: 'report.csv',
18
+ * mimeType: 'text/csv',
19
+ * })
20
+ *
21
+ * return <PreviewComponent />
22
+ * ```
23
+ */
24
+
25
+ import { useState, useEffect, useCallback, useRef } from 'react'
26
+ import { getFileCategory, getExtension, type FileCategory } from './useMimeTypeDetection'
27
+
28
+ // ============================================================================
29
+ // Types
30
+ // ============================================================================
31
+
32
+ export interface FilePreviewInput {
33
+ /** URL to fetch the file content from (R2 download URL or blob URL) */
34
+ url: string
35
+ /** Original file name (used for extension-based detection) */
36
+ fileName: string
37
+ /** MIME type of the file */
38
+ mimeType: string
39
+ }
40
+
41
+ export interface FilePreviewResult {
42
+ /** The file category determined from MIME type + extension */
43
+ category: FileCategory
44
+ /** Whether the file type supports inline preview */
45
+ canPreview: boolean
46
+ /** Whether preview content is still loading */
47
+ isLoading: boolean
48
+ /** Error message if preview failed to load */
49
+ error: string | null
50
+ /** Loaded text content (for code and plain-text files; CSV uses csvData) */
51
+ textContent: string | null
52
+ /** Parsed CSV data: [headerRow, ...dataRows] */
53
+ csvData: { headers: string[]; rows: string[][]; truncated: boolean } | null
54
+ /** Loaded CodeMirror editor kit (dynamically imported) */
55
+ codeEditorKit: CodeEditorKit | null
56
+ /** Fetched DOCX ArrayBuffer (render with docx-preview in a callback ref) */
57
+ docxData: ArrayBuffer | null
58
+ /** Dynamically loaded docx-preview module */
59
+ docxModule: typeof import('docx-preview') | null
60
+ /** Parsed XLSX data with multiple sheets */
61
+ xlsxData: XlsxData | null
62
+ /** Rendered PDF page canvases (via PDF.js) */
63
+ pdfPages: HTMLCanvasElement[] | null
64
+ }
65
+
66
+ export interface CodeEditorKit {
67
+ Component: React.ComponentType<any>
68
+ langExtensions: any[]
69
+ darkTheme: any
70
+ }
71
+
72
+ export interface XlsxSheet {
73
+ name: string
74
+ headers: string[]
75
+ rows: string[][]
76
+ truncated: boolean
77
+ }
78
+
79
+ export interface XlsxData {
80
+ sheets: XlsxSheet[]
81
+ activeSheet: number
82
+ }
83
+
84
+ // ============================================================================
85
+ // Constants
86
+ // ============================================================================
87
+
88
+ const MAX_PREVIEW_ROWS = 500
89
+
90
+ // ============================================================================
91
+ // Language mode detection for CodeMirror
92
+ // ============================================================================
93
+
94
+ type LanguageMode = 'javascript' | 'typescript' | 'plain'
95
+
96
+ function getLanguageMode(fileName: string): LanguageMode {
97
+ const ext = getExtension(fileName)
98
+ switch (ext) {
99
+ case 'js':
100
+ case 'jsx':
101
+ return 'javascript'
102
+ case 'ts':
103
+ case 'tsx':
104
+ return 'typescript'
105
+ default:
106
+ return 'plain'
107
+ }
108
+ }
109
+
110
+ // ============================================================================
111
+ // Hook
112
+ // ============================================================================
113
+
114
+ export function useFilePreview({ url, fileName, mimeType }: FilePreviewInput): FilePreviewResult {
115
+ const category = getFileCategory(mimeType, fileName)
116
+ const extension = getExtension(fileName)
117
+ const previewable = isPreviewable(category, extension)
118
+
119
+ const [isLoading, setIsLoading] = useState(false)
120
+ const [error, setError] = useState<string | null>(null)
121
+ const [textContent, setTextContent] = useState<string | null>(null)
122
+ const [csvData, setCsvData] = useState<FilePreviewResult['csvData']>(null)
123
+ const [codeEditorKit, setCodeEditorKit] = useState<CodeEditorKit | null>(null)
124
+ const [xlsxData, setXlsxData] = useState<XlsxData | null>(null)
125
+ const [docxData, setDocxData] = useState<ArrayBuffer | null>(null)
126
+ const [docxModule, setDocxModule] = useState<typeof import('docx-preview') | null>(null)
127
+ const [pdfPages, setPdfPages] = useState<HTMLCanvasElement[] | null>(null)
128
+
129
+ // Load preview content based on category
130
+ useEffect(() => {
131
+ if (!previewable || !url) return
132
+
133
+ // Native browser previews (image, video, audio) don't need fetching
134
+ // PDF uses PDF.js now (rendered to canvas) to avoid iframe sandbox issues
135
+ if (category === 'image' || category === 'video' || category === 'audio') {
136
+ return
137
+ }
138
+
139
+ const controller = new AbortController()
140
+ let cancelled = false
141
+
142
+ setIsLoading(true)
143
+ setError(null)
144
+ setTextContent(null)
145
+ setCsvData(null)
146
+ setXlsxData(null)
147
+ setDocxData(null)
148
+ setDocxModule(null)
149
+ setPdfPages(null)
150
+
151
+ async function loadPreview() {
152
+ try {
153
+ if (category === 'pdf') {
154
+ await loadPdfPreview(url, controller.signal, cancelled, setPdfPages, setError)
155
+ } else if (category === 'code' || category === 'text') {
156
+ await loadCodePreview(url, fileName, controller.signal, cancelled, setTextContent, setCodeEditorKit)
157
+ } else if (extension === 'csv' || extension === 'tsv') {
158
+ await loadCsvPreview(url, controller.signal, cancelled, setCsvData)
159
+ } else if (extension === 'docx') {
160
+ await loadDocxPreview(url, controller.signal, cancelled, setDocxData, setDocxModule)
161
+ } else if (extension === 'xlsx' || extension === 'xls') {
162
+ await loadXlsxPreview(url, controller.signal, cancelled, setXlsxData)
163
+ }
164
+ } catch (err) {
165
+ if (!cancelled && !(err instanceof DOMException && err.name === 'AbortError')) {
166
+ setError(err instanceof Error ? err.message : 'Failed to load preview')
167
+ }
168
+ } finally {
169
+ if (!cancelled) setIsLoading(false)
170
+ }
171
+ }
172
+
173
+ loadPreview()
174
+
175
+ return () => {
176
+ cancelled = true
177
+ controller.abort()
178
+ }
179
+ }, [url, fileName, mimeType, category, extension, previewable])
180
+
181
+ return {
182
+ category,
183
+ canPreview: previewable,
184
+ isLoading,
185
+ error,
186
+ textContent,
187
+ csvData,
188
+ codeEditorKit,
189
+ docxData,
190
+ docxModule,
191
+ xlsxData,
192
+ pdfPages,
193
+ }
194
+ }
195
+
196
+ // ============================================================================
197
+ // Preview loaders (each dynamically imports its heavy dependency)
198
+ // ============================================================================
199
+
200
+ function isPreviewable(category: FileCategory, extension: string): boolean {
201
+ if (['image', 'video', 'audio', 'pdf', 'code', 'text'].includes(category)) return true
202
+ if (['csv', 'tsv', 'docx', 'xlsx', 'xls'].includes(extension)) return true
203
+ return false
204
+ }
205
+
206
+ async function loadCodePreview(
207
+ url: string,
208
+ fileName: string,
209
+ signal: AbortSignal,
210
+ cancelled: boolean,
211
+ setTextContent: (v: string) => void,
212
+ setCodeEditorKit: (v: CodeEditorKit) => void,
213
+ ) {
214
+ const [response, ...editorModules] = await Promise.all([
215
+ fetch(url, { signal }),
216
+ import('@uiw/react-codemirror').catch(() => null),
217
+ import('@codemirror/lang-javascript').catch(() => null),
218
+ import('@codemirror/theme-one-dark').catch(() => null),
219
+ ])
220
+
221
+ if (cancelled) return
222
+ if (!response.ok) throw new Error(`HTTP ${response.status}`)
223
+
224
+ const text = await response.text()
225
+ if (cancelled) return
226
+
227
+ // Pretty-print minified JSON before handing it to CodeMirror. Files like
228
+ // `package-lock.json` or build manifests are often shipped as one long
229
+ // line, which CodeMirror has no way to re-indent — so the user sees a
230
+ // single horizontal-scroll bar. JSON.parse → JSON.stringify with 2-space
231
+ // indent gives a properly-formatted view at the cost of a transient
232
+ // memory peak; we cap the input at 5 MB to keep that peak bounded. On
233
+ // any failure (invalid JSON, oversize) we render the original text as-is
234
+ // instead of erroring — the user still gets a preview, just unformatted.
235
+ let displayText = text
236
+ if (fileName.toLowerCase().endsWith('.json') && text.length < 5_000_000) {
237
+ try {
238
+ displayText = JSON.stringify(JSON.parse(text), null, 2)
239
+ } catch {
240
+ // Not valid JSON — leave as-is.
241
+ }
242
+ }
243
+ setTextContent(displayText)
244
+
245
+ const [cmModule, jsModule, themeModule] = editorModules
246
+ if (cmModule && jsModule && themeModule) {
247
+ const mode = getLanguageMode(fileName)
248
+ const langExtensions: any[] = []
249
+
250
+ if (mode === 'javascript') {
251
+ langExtensions.push(jsModule.javascript({ jsx: true }))
252
+ } else if (mode === 'typescript') {
253
+ langExtensions.push(jsModule.javascript({ jsx: true, typescript: true }))
254
+ }
255
+
256
+ setCodeEditorKit({
257
+ Component: cmModule.default,
258
+ langExtensions,
259
+ darkTheme: themeModule.oneDark,
260
+ })
261
+ }
262
+ }
263
+
264
+ async function loadCsvPreview(
265
+ url: string,
266
+ signal: AbortSignal,
267
+ cancelled: boolean,
268
+ setCsvData: (v: FilePreviewResult['csvData']) => void,
269
+ ) {
270
+ const [response, Papa] = await Promise.all([
271
+ fetch(url, { signal }),
272
+ import('papaparse'),
273
+ ])
274
+
275
+ if (cancelled) return
276
+ if (!response.ok) throw new Error(`HTTP ${response.status}`)
277
+
278
+ const text = await response.text()
279
+ if (cancelled) return
280
+
281
+ const result = Papa.default.parse<string[]>(text, { skipEmptyLines: true })
282
+ const allRows = result.data
283
+
284
+ if (allRows.length === 0) {
285
+ setCsvData({ headers: [], rows: [], truncated: false })
286
+ return
287
+ }
288
+
289
+ const headers = allRows[0]
290
+ const truncated = allRows.length > MAX_PREVIEW_ROWS + 1
291
+ const rows = truncated ? allRows.slice(1, MAX_PREVIEW_ROWS + 1) : allRows.slice(1)
292
+
293
+ setCsvData({ headers, rows, truncated })
294
+ }
295
+
296
+ async function loadDocxPreview(
297
+ url: string,
298
+ signal: AbortSignal,
299
+ cancelled: boolean,
300
+ setDocxData: (v: ArrayBuffer) => void,
301
+ setDocxModule: (v: typeof import('docx-preview')) => void,
302
+ ) {
303
+ const [response, docxPreviewModule] = await Promise.all([
304
+ fetch(url, { signal }),
305
+ import('docx-preview'),
306
+ ])
307
+
308
+ if (cancelled) return
309
+ if (!response.ok) throw new Error(`HTTP ${response.status}`)
310
+
311
+ const arrayBuffer = await response.arrayBuffer()
312
+ if (cancelled) return
313
+
314
+ setDocxData(arrayBuffer)
315
+ setDocxModule(docxPreviewModule)
316
+ }
317
+
318
+ async function loadXlsxPreview(
319
+ url: string,
320
+ signal: AbortSignal,
321
+ cancelled: boolean,
322
+ setXlsxData: (v: XlsxData) => void,
323
+ ) {
324
+ const [response, ExcelJSModule] = await Promise.all([
325
+ fetch(url, { signal }),
326
+ import('exceljs'),
327
+ ])
328
+
329
+ if (cancelled) return
330
+ if (!response.ok) throw new Error(`HTTP ${response.status}`)
331
+
332
+ const arrayBuffer = await response.arrayBuffer()
333
+ if (cancelled) return
334
+
335
+ const ExcelJS = (ExcelJSModule as { default?: typeof import('exceljs') }).default ?? ExcelJSModule
336
+ const workbook = new ExcelJS.Workbook()
337
+ await workbook.xlsx.load(arrayBuffer)
338
+ if (cancelled) return
339
+
340
+ // Bounded recursion: a malformed XLSX could in theory produce
341
+ // `{result: {result: {result: ...}}}` chains that blow the stack.
342
+ // ExcelJS doesn't surface cycles today, but a depth cap is essentially
343
+ // free insurance against a crafted-input DoS.
344
+ const MAX_CELL_DEPTH = 8
345
+ const cellToString = (value: unknown, depth = 0): string => {
346
+ if (value == null) return ''
347
+ if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
348
+ return String(value)
349
+ }
350
+ if (value instanceof Date) return value.toISOString()
351
+ if (depth >= MAX_CELL_DEPTH) return ''
352
+ if (typeof value === 'object') {
353
+ const v = value as { result?: unknown; text?: unknown; richText?: Array<{ text: string }>; hyperlink?: string }
354
+ if (v.result != null) return cellToString(v.result, depth + 1)
355
+ if (Array.isArray(v.richText)) return v.richText.map(r => r.text).join('')
356
+ if (typeof v.text === 'string') return v.text
357
+ if (typeof v.hyperlink === 'string') return v.hyperlink
358
+ }
359
+ return String(value)
360
+ }
361
+
362
+ const sheets: XlsxSheet[] = workbook.worksheets.map(ws => {
363
+ const allRows: string[][] = []
364
+ ws.eachRow({ includeEmpty: false }, (row) => {
365
+ const cells: string[] = []
366
+ // row.values is 1-indexed; slice(1) gives a dense-ish array
367
+ const values = Array.isArray(row.values) ? row.values.slice(1) : []
368
+ for (const v of values) cells.push(cellToString(v))
369
+ allRows.push(cells)
370
+ })
371
+
372
+ if (allRows.length === 0) {
373
+ return { name: ws.name, headers: [], rows: [], truncated: false }
374
+ }
375
+
376
+ const headers = allRows[0]
377
+ const truncated = allRows.length > MAX_PREVIEW_ROWS + 1
378
+ const rows = truncated
379
+ ? allRows.slice(1, MAX_PREVIEW_ROWS + 1)
380
+ : allRows.slice(1)
381
+
382
+ return { name: ws.name, headers, rows, truncated }
383
+ })
384
+
385
+ setXlsxData({ sheets, activeSheet: 0 })
386
+ }
387
+
388
+ // ============================================================================
389
+ // PDF.js loader — renders PDF pages to canvas (avoids iframe sandbox issues).
390
+ //
391
+ // Bundled via `pdfjs-dist` (declared in this feature's feature.json
392
+ // dependencies) rather than fetched from a third-party CDN at runtime.
393
+ // Loading from cdnjs without SRI would have meant any cdnjs compromise =
394
+ // arbitrary JS in the app origin with full auth/R2 access.
395
+ //
396
+ // The worker is imported via Vite's `?worker` suffix, which returns a
397
+ // `Worker` constructor that Vite builds and bundles separately. We hand
398
+ // the instantiated worker to pdfjs as `workerPort`. This is more robust
399
+ // than `workerSrc` + a URL because it sidesteps Cloudflare's static
400
+ // asset handler, which serves `.mjs` with a generic MIME and breaks
401
+ // the dynamic-import path pdfjs uses to bootstrap its fake worker.
402
+ //
403
+ // Both the lib and worker module are dynamic-imported so they're
404
+ // code-split into their own chunks — the base bundle stays small, and
405
+ // the worker module only downloads when a user actually previews a PDF.
406
+ // ============================================================================
407
+
408
+ let pdfjsLoadPromise: Promise<typeof import('pdfjs-dist')> | null = null
409
+
410
+ async function loadPdfJs(): Promise<typeof import('pdfjs-dist')> {
411
+ if (pdfjsLoadPromise) return pdfjsLoadPromise
412
+
413
+ pdfjsLoadPromise = (async () => {
414
+ const [pdfjsLib, workerModule] = await Promise.all([
415
+ import('pdfjs-dist'),
416
+ // @ts-expect-error — Vite's `?worker` suffix yields a Worker constructor.
417
+ import('pdfjs-dist/build/pdf.worker.min.mjs?worker'),
418
+ ])
419
+ const PdfWorker = (workerModule as { default: new () => Worker }).default
420
+ pdfjsLib.GlobalWorkerOptions.workerPort = new PdfWorker()
421
+ return pdfjsLib
422
+ })()
423
+
424
+ return pdfjsLoadPromise
425
+ }
426
+
427
+ async function loadPdfPreview(
428
+ url: string,
429
+ signal: AbortSignal,
430
+ cancelled: boolean,
431
+ setPdfPages: (v: HTMLCanvasElement[]) => void,
432
+ setError: (v: string) => void,
433
+ ) {
434
+ try {
435
+ const pdfjsLib = await loadPdfJs()
436
+ if (cancelled) return
437
+
438
+ const response = await fetch(url, { signal })
439
+ if (cancelled) return
440
+ if (!response.ok) throw new Error(`HTTP ${response.status}`)
441
+
442
+ const arrayBuffer = await response.arrayBuffer()
443
+ if (cancelled) return
444
+
445
+ const pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise
446
+ if (cancelled) return
447
+
448
+ const canvases: HTMLCanvasElement[] = []
449
+ const SCALE = 1.5
450
+
451
+ for (let i = 1; i <= pdf.numPages; i++) {
452
+ if (cancelled) return
453
+ const page = await pdf.getPage(i)
454
+ const viewport = page.getViewport({ scale: SCALE })
455
+
456
+ const canvas = document.createElement('canvas')
457
+ canvas.width = viewport.width
458
+ canvas.height = viewport.height
459
+
460
+ const ctx = canvas.getContext('2d')!
461
+ await page.render({ canvasContext: ctx, viewport }).promise
462
+ canvases.push(canvas)
463
+ }
464
+
465
+ if (!cancelled) setPdfPages(canvases)
466
+ } catch (err) {
467
+ if (!cancelled && !(err instanceof DOMException && err.name === 'AbortError')) {
468
+ setError(err instanceof Error ? err.message : 'Failed to load PDF')
469
+ }
470
+ }
471
+ }
@@ -0,0 +1,138 @@
1
+ /**
2
+ * useMimeTypeDetection — MIME type detection and file categorization.
3
+ *
4
+ * Mirrors the main app's approach:
5
+ * 1. Magic byte detection via `file-type` library (dynamically imported)
6
+ * 2. Fallback to browser-provided MIME type (extension-based)
7
+ * 3. Extension-based category mapping for 11 file categories
8
+ *
9
+ * @example
10
+ * ```tsx
11
+ * const { detectMimeType, getFileCategory, getExtension } = useMimeTypeDetection()
12
+ *
13
+ * // Detect MIME type from a File object (uses magic bytes)
14
+ * const mimeType = await detectMimeType(file)
15
+ *
16
+ * // Categorize by MIME type + file name
17
+ * const category = getFileCategory('application/pdf', 'report.pdf') // → 'pdf'
18
+ * const category = getFileCategory('text/plain', 'app.tsx') // → 'code'
19
+ * ```
20
+ */
21
+
22
+ import { useCallback } from 'react'
23
+
24
+ // ============================================================================
25
+ // Types
26
+ // ============================================================================
27
+
28
+ export type FileCategory =
29
+ | 'image'
30
+ | 'video'
31
+ | 'audio'
32
+ | 'pdf'
33
+ | 'document'
34
+ | 'spreadsheet'
35
+ | 'presentation'
36
+ | 'archive'
37
+ | 'code'
38
+ | 'text'
39
+ | 'other'
40
+
41
+ export interface MimeTypeDetection {
42
+ /** Detect MIME type from a File using magic bytes, falling back to file.type */
43
+ detectMimeType: (file: File) => Promise<string>
44
+ /** Map MIME type + file name to a display category */
45
+ getFileCategory: (mimeType: string, fileName: string) => FileCategory
46
+ /** Extract lowercase extension from a file name */
47
+ getExtension: (fileName: string) => string
48
+ /** Check if a category supports inline preview */
49
+ canPreview: (category: FileCategory) => boolean
50
+ }
51
+
52
+ // ============================================================================
53
+ // Extension sets for category detection
54
+ // ============================================================================
55
+
56
+ const VIDEO_EXTENSIONS = new Set(['mp4', 'mov', 'webm', 'ogg', 'ogv', 'avi', 'mkv', 'm4v'])
57
+ const AUDIO_EXTENSIONS = new Set(['mp3', 'wav', 'flac', 'aac', 'm4a', 'wma', 'ogg'])
58
+ const DOCUMENT_EXTENSIONS = new Set(['doc', 'docx', 'rtf'])
59
+ const SPREADSHEET_EXTENSIONS = new Set(['xls', 'xlsx', 'csv', 'tsv'])
60
+ const PRESENTATION_EXTENSIONS = new Set(['ppt', 'pptx'])
61
+ const ARCHIVE_EXTENSIONS = new Set(['zip', 'rar', '7z', 'tar', 'gz', 'bz2'])
62
+ const CODE_EXTENSIONS = new Set([
63
+ 'js', 'ts', 'jsx', 'tsx', 'json', 'css', 'scss', 'less',
64
+ 'html', 'xml', 'svg', 'py', 'java', 'cpp', 'c', 'h',
65
+ 'php', 'rb', 'go', 'rs', 'swift', 'kt', 'sh', 'bash',
66
+ 'yaml', 'yml', 'toml', 'sql', 'graphql', 'vue', 'svelte',
67
+ ])
68
+ const TEXT_EXTENSIONS = new Set(['txt', 'md', 'log', 'ini', 'cfg', 'env'])
69
+
70
+ // ============================================================================
71
+ // Pure utility functions
72
+ // ============================================================================
73
+
74
+ function getExtension(fileName: string): string {
75
+ return fileName.split('.').pop()?.toLowerCase() ?? ''
76
+ }
77
+
78
+ function getFileCategory(mimeType: string, fileName: string): FileCategory {
79
+ const ext = getExtension(fileName)
80
+
81
+ if (mimeType.startsWith('image/')) return 'image'
82
+ if (mimeType.startsWith('video/') || VIDEO_EXTENSIONS.has(ext)) return 'video'
83
+ if (mimeType.startsWith('audio/') || AUDIO_EXTENSIONS.has(ext)) return 'audio'
84
+ if (mimeType === 'application/pdf' || ext === 'pdf') return 'pdf'
85
+ if (mimeType.includes('excel') || mimeType.includes('spreadsheetml') || mimeType.includes('spreadsheet') || SPREADSHEET_EXTENSIONS.has(ext)) return 'spreadsheet'
86
+ if (mimeType.includes('powerpoint') || mimeType.includes('presentationml') || mimeType.includes('presentation') || PRESENTATION_EXTENSIONS.has(ext)) return 'presentation'
87
+ if (mimeType.includes('word') || mimeType.includes('wordprocessingml') || DOCUMENT_EXTENSIONS.has(ext)) return 'document'
88
+ if (mimeType.includes('zip') || mimeType.includes('archive') || mimeType.includes('compressed') || ARCHIVE_EXTENSIONS.has(ext)) return 'archive'
89
+ if (mimeType.includes('javascript') || mimeType.includes('typescript') || mimeType.includes('json') || CODE_EXTENSIONS.has(ext)) return 'code'
90
+ if (mimeType.startsWith('text/') || TEXT_EXTENSIONS.has(ext)) return 'text'
91
+
92
+ return 'other'
93
+ }
94
+
95
+ function canPreview(category: FileCategory): boolean {
96
+ return category === 'image'
97
+ || category === 'video'
98
+ || category === 'audio'
99
+ || category === 'pdf'
100
+ || category === 'code'
101
+ || category === 'text'
102
+ || category === 'spreadsheet'
103
+ || category === 'document'
104
+ }
105
+
106
+ async function detectMimeType(file: File): Promise<string> {
107
+ try {
108
+ const { fileTypeFromBuffer } = await import('file-type')
109
+ const arrayBuffer = await file.arrayBuffer()
110
+ const result = await fileTypeFromBuffer(new Uint8Array(arrayBuffer))
111
+ if (result) return result.mime
112
+ } catch {
113
+ // Can fail on zero-length files, security-restricted blobs, or if module fails to load
114
+ }
115
+
116
+ return file.type || 'application/octet-stream'
117
+ }
118
+
119
+ // ============================================================================
120
+ // Hook
121
+ // ============================================================================
122
+
123
+ export function useMimeTypeDetection(): MimeTypeDetection {
124
+ const detect = useCallback((file: File) => detectMimeType(file), [])
125
+ const categorize = useCallback((mimeType: string, fileName: string) => getFileCategory(mimeType, fileName), [])
126
+ const ext = useCallback((fileName: string) => getExtension(fileName), [])
127
+ const preview = useCallback((category: FileCategory) => canPreview(category), [])
128
+
129
+ return {
130
+ detectMimeType: detect,
131
+ getFileCategory: categorize,
132
+ getExtension: ext,
133
+ canPreview: preview,
134
+ }
135
+ }
136
+
137
+ // Also export the pure functions for non-React contexts
138
+ export { detectMimeType, getFileCategory, getExtension, canPreview }
@@ -0,0 +1,24 @@
1
+ {
2
+ "id": "file-manager",
3
+ "name": "File Manager",
4
+ "category": "data",
5
+ "description": "Upload, browse, preview, and manage files with R2 storage",
6
+ "details": "Full file management UI using the useR2Files hook. Supports drag-and-drop upload, file listing with size and date, inline image preview, download, and delete. Files are scoped per-user via the platform worker's R2 storage.",
7
+ "files": [
8
+ {
9
+ "src": "src/FileManagerPage.tsx",
10
+ "dest": "src/pages/file-manager.tsx"
11
+ }
12
+ ],
13
+ "route": {
14
+ "path": "/file-manager",
15
+ "protected": true
16
+ },
17
+ "requires": [],
18
+ "instructions": [],
19
+ "patterns": [
20
+ "useR2Files() for upload, list, download, delete",
21
+ "Drag-and-drop file upload zone",
22
+ "Inline image preview with getUrl()"
23
+ ]
24
+ }