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.
- package/dist/cli.js +331 -115
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +199 -16
- package/dist/index.js +354 -166
- package/dist/index.js.map +1 -1
- package/dist/server.d.ts +242 -73
- package/dist/server.js +985 -374
- package/dist/server.js.map +1 -1
- package/dist/worker.d.ts +322 -73
- package/dist/worker.js +961 -343
- package/dist/worker.js.map +1 -1
- package/features/admin-page/feature.json +29 -0
- package/features/admin-page/src/AdminPage.tsx +292 -0
- package/features/admin-page/src/admin-schema.ts +20 -0
- package/features/ai-chat/feature.json +41 -0
- package/features/ai-chat/src/AiChatPage.tsx +692 -0
- package/features/ai-chat/src/ChatPanel.tsx +1098 -0
- package/features/ai-chat/src/ai-chat-schema.ts +11 -0
- package/features/canvas/feature.json +45 -0
- package/features/canvas/src/CanvasEditorPage.tsx +87 -0
- package/features/canvas/src/CanvasListPage.tsx +202 -0
- package/features/canvas/src/canvas-schema.ts +33 -0
- package/features/canvas/src/components/CanvasToolbar.tsx +170 -0
- package/features/canvas/src/components/CanvasView.tsx +369 -0
- package/features/canvas/src/components/ShapeRenderer.tsx +114 -0
- package/features/cron/feature.json +33 -0
- package/features/cron/src/CronLogPage.tsx +100 -0
- package/features/cron/src/cron.ts +25 -0
- package/features/docs/feature.json +96 -0
- package/features/docs/src/DocsEditorPage.tsx +1012 -0
- package/features/docs/src/DocsListPage.tsx +951 -0
- package/features/docs/src/DocsOutlinePanel.tsx +68 -0
- package/features/docs/src/DocsPresence.tsx +138 -0
- package/features/docs/src/InviteDialog.tsx +336 -0
- package/features/docs/src/LibrarySidebar.tsx +464 -0
- package/features/docs/src/docs-favorites.ts +18 -0
- package/features/docs/src/docs-library-types.ts +15 -0
- package/features/docs/src/docs-schema.ts +80 -0
- package/features/docs/src/docs-ui.css +309 -0
- package/features/docs/src/editor/DocEditorSurface.tsx +257 -0
- package/features/docs/src/editor/DocsTiptapToolbar.tsx +560 -0
- package/features/docs/src/editor/useDocEditor.ts +206 -0
- package/features/docs/src/use-yjs-room-with-awareness.ts +285 -0
- package/features/file-attachments/feature.json +42 -0
- package/features/file-attachments/src/AttachmentsPage.tsx +864 -0
- package/features/file-attachments/src/attachments-constants.ts +9 -0
- package/features/file-attachments/src/attachments-schema.ts +45 -0
- package/features/file-attachments/src/useFilePreview.ts +471 -0
- package/features/file-attachments/src/useMimeTypeDetection.ts +138 -0
- package/features/file-manager/feature.json +24 -0
- package/features/file-manager/src/FileManagerPage.tsx +330 -0
- package/features/integration-test/feature.json +18 -0
- package/features/integration-test/src/IntegrationTestPage.tsx +423 -0
- package/features/integration-test/src/integration.spec.ts +42 -0
- package/features/items/feature.json +33 -0
- package/features/items/src/ItemsPage.tsx +325 -0
- package/features/items/src/items-constants.ts +10 -0
- package/features/items/src/items-schema.ts +27 -0
- package/features/kanban/feature.json +18 -0
- package/features/kanban/src/Kanban.tsx +283 -0
- package/features/landing/feature.json +62 -0
- package/features/landing/src/LandingPage.tsx +627 -0
- package/features/landing/src/primitives.tsx +389 -0
- package/features/landing/src/sections/FAQSection.tsx +110 -0
- package/features/landing/src/sections/FeaturesGridSection.tsx +311 -0
- package/features/landing/src/sections/HowItWorksSection.tsx +62 -0
- package/features/landing/src/sections/LogoCloudSection.tsx +58 -0
- package/features/landing/src/sections/ShowcaseSection.tsx +63 -0
- package/features/landing/src/sections/StatsSection.tsx +31 -0
- package/features/landing/src/sections/TeamSection.tsx +86 -0
- package/features/landing/src/sections/TestimonialsSection.tsx +68 -0
- package/features/landing/src/sections/VideoSection.tsx +71 -0
- package/features/leaderboard/feature.json +33 -0
- package/features/leaderboard/src/LeaderboardPage.tsx +372 -0
- package/features/leaderboard/src/leaderboard-constants.ts +21 -0
- package/features/leaderboard/src/leaderboard-schema.ts +36 -0
- package/features/messaging/feature.json +111 -0
- package/features/messaging/src/ChatMultiPage.tsx +300 -0
- package/features/messaging/src/ChatPage.tsx +75 -0
- package/features/messaging/src/components/chat/ChatHeader.tsx +76 -0
- package/features/messaging/src/components/chat/MessageActionSheet.tsx +168 -0
- package/features/messaging/src/components/chat/MessageInput.tsx +65 -0
- package/features/messaging/src/components/chat/MessageItem.tsx +352 -0
- package/features/messaging/src/components/chat/MessageList.tsx +248 -0
- package/features/messaging/src/components/chat/ReactionPicker.tsx +2 -0
- package/features/messaging/src/components/chat/ThreadPanel.tsx +107 -0
- package/features/messaging/src/components/chat/UserProfilePopover.tsx +158 -0
- package/features/messaging/src/components/chat-multi/AddMemberModal.tsx +188 -0
- package/features/messaging/src/components/chat-multi/BrowseGroupsModal.tsx +163 -0
- package/features/messaging/src/components/chat-multi/ChannelListItem.tsx +147 -0
- package/features/messaging/src/components/chat-multi/ChannelSettingsPanel.tsx +344 -0
- package/features/messaging/src/components/chat-multi/ChannelSidebar.tsx +460 -0
- package/features/messaging/src/components/chat-multi/CreateGroupModal.tsx +190 -0
- package/features/messaging/src/components/chat-multi/InvitationList.tsx +149 -0
- package/features/messaging/src/components/chat-multi/NewMessageModal.tsx +159 -0
- package/features/messaging/src/hooks/useChatChannel.ts +64 -0
- package/features/messaging/src/hooks/useLongPress.ts +74 -0
- package/features/messaging/src/hooks/useMultiChannel.ts +142 -0
- package/features/messaging/src/messaging-schema.ts +25 -0
- package/features/presence-test/feature.json +21 -0
- package/features/presence-test/src/PresenceTestPage.tsx +120 -0
- package/features/sidebar/feature.json +19 -0
- package/features/sidebar/src/AppSidebar.tsx +558 -0
- package/features/tasks/feature.json +33 -0
- package/features/tasks/src/TasksPage.tsx +598 -0
- package/features/tasks/src/tasks-constants.ts +43 -0
- package/features/tasks/src/tasks-schema.ts +56 -0
- package/features/testing/feature.json +49 -0
- package/features/testing/src/TestPage.tsx +145 -0
- package/features/testing/src/cpu-burn-action.ts +51 -0
- package/features/testing/src/subrequest-burn-action.ts +53 -0
- package/features/testing/src/test-schema.ts +23 -0
- package/features/topbar/feature.json +18 -0
- package/features/topbar/src/Topbar.tsx +353 -0
- package/features/tree/feature.json +26 -0
- package/features/tree/src/TreeSidebar.tsx +607 -0
- package/features/tree/src/tree-constants.ts +68 -0
- package/features/tree/src/tree-hooks.ts +258 -0
- package/package.json +4 -2
- 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
|
+
}
|