@startupjs-ui/file-input 0.1.3

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/CHANGELOG.md ADDED
@@ -0,0 +1,22 @@
1
+ # Change Log
2
+
3
+ All notable changes to this project will be documented in this file.
4
+ See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
+
6
+ ## [0.1.3](https://github.com/startupjs/startupjs-ui/compare/v0.1.2...v0.1.3) (2025-12-29)
7
+
8
+ **Note:** Version bump only for package @startupjs-ui/file-input
9
+
10
+
11
+
12
+
13
+
14
+ ## [0.1.2](https://github.com/startupjs/startupjs-ui/compare/v0.1.1...v0.1.2) (2025-12-29)
15
+
16
+
17
+ ### Features
18
+
19
+ * add mdx and docs packages. Refactor docs to get rid of any @startupjs/ui usage and use startupjs-ui instead ([703c926](https://github.com/startupjs/startupjs-ui/commit/703c92636efb0421ffd11783f692fc892b74018f))
20
+ * **docs:** support static web bundling (each route as a separate html page) ([5e4738c](https://github.com/startupjs/startupjs-ui/commit/5e4738c50157ae37e14d8dc36cb43d6a45a008ad))
21
+ * **file-input:** refactor FileInput component ([ab87527](https://github.com/startupjs/startupjs-ui/commit/ab87527a6a9dba34b3106034cc33d311f88f8e93))
22
+ * **file-input:** update example ([e019ed2](https://github.com/startupjs/startupjs-ui/commit/e019ed26886d7b7e9d499e76e3c1eca802864774))
package/README.mdx ADDED
@@ -0,0 +1,82 @@
1
+ import { useState } from 'react'
2
+ import { $, useSub } from 'startupjs'
3
+ import FileInput, { _PropsJsonSchema as FileInputPropsJsonSchema } from './index'
4
+ import Div from '@startupjs-ui/div'
5
+ import Avatar from '@startupjs-ui/avatar'
6
+ import Span from '@startupjs-ui/span'
7
+ import { Sandbox } from '@startupjs-ui/docs'
8
+
9
+ # FileInput
10
+
11
+ FileInput lets users pick a file (or image) and upload it to your StartupJS backend. The `value` is a `fileId` pointing to a document in the `files` model.
12
+
13
+ ```jsx
14
+ import { FileInput } from 'startupjs-ui'
15
+ ```
16
+
17
+ ## Basic usage
18
+
19
+ ```jsx example
20
+ const [fileId, setFileId] = useState()
21
+ return (
22
+ <FileInput
23
+ value={fileId}
24
+ onChange={setFileId}
25
+ />
26
+ )
27
+ ```
28
+
29
+ ## Image picker
30
+
31
+ ```jsx
32
+ const $avatarFileId = $()
33
+ const $file = useSub($.files[$avatarFileId.get() ?? '__DUMMY__'])
34
+ return (
35
+ <Div gap>
36
+ <Span>Your avatar:</Span>
37
+ {
38
+ $avatarFileId.get()
39
+ ? <Avatar src={$file.getUrl() + '?' + $file.updatedAt.get()}>{name}</Avatar>
40
+ : <Span italic>No avatar uploaded</Span>
41
+ }
42
+ <FileInput
43
+ image
44
+ value={$avatarFileId.get()}
45
+ onChange={fileId => $avatarFileId.set(fileId)}
46
+ />
47
+ </Div>
48
+ )
49
+ ```
50
+
51
+ **Note:** the trick with appending `?` and `updatedAt` timestamp to the URL is optional. It might be needed in some cases (like on the page of changing the profile picture of a user) to force reloading the image after it has been changed, otherwise the cached version may be shown in the browser.
52
+
53
+ ## Storage providers
54
+
55
+ FileInput supports multiple storage backends. By default it uses SQLite, but if `MONGO_URL` is configured (MongoDB is used by StartupJS), the default storage becomes MongoDB (GridFS). You can force a specific provider with `DEFAULT_STORAGE_TYPE`.
56
+
57
+ ### SQLite (default)
58
+
59
+ - Default when no `MONGO_URL` is configured.
60
+ - Force with `DEFAULT_STORAGE_TYPE=sqlite`.
61
+
62
+ ### MongoDB (GridFS)
63
+
64
+ - Default when `MONGO_URL` is configured.
65
+ - Force with `DEFAULT_STORAGE_TYPE=mongo`.
66
+
67
+ ### Azure Blob Storage
68
+
69
+ - Force with `DEFAULT_STORAGE_TYPE=azureblob`.
70
+ - Requires `AZURE_BLOB_STORAGE_CONNECTION_STRING`.
71
+ - Install the Azure SDK: `yarn add @azure/storage-blob`.
72
+
73
+ ## Sandbox
74
+
75
+ <Sandbox
76
+ Component={FileInput}
77
+ propsJsonSchema={FileInputPropsJsonSchema}
78
+ props={{
79
+ onChange: fileId => alert('Uploaded fileId: ' + fileId)
80
+ }}
81
+ block
82
+ />
package/constants.d.ts ADDED
@@ -0,0 +1,8 @@
1
+ export const GET_FILE_URL: string
2
+ export function getFileUrl (id: string, extension?: string): string
3
+
4
+ export const UPLOAD_SINGLE_FILE_URL: string
5
+ export function getUploadFileUrl (id?: string): string
6
+
7
+ export const DELETE_FILE_URL: string
8
+ export function getDeleteFileUrl (id: string): string
package/constants.js ADDED
@@ -0,0 +1,16 @@
1
+ export const GET_FILE_URL = '/api/__ui__/files/get/:fileId'
2
+ export const getFileUrl = (id, extension) => {
3
+ if (!id) throw Error('[ui/FileInput] getFileUrl: fileId is required')
4
+ return GET_FILE_URL.replace(':fileId', id) + (extension ? `.${extension}` : '')
5
+ }
6
+
7
+ export const UPLOAD_SINGLE_FILE_URL = '/api/__ui__/files/upload/single/:fileId?'
8
+ export const getUploadFileUrl = id => {
9
+ return UPLOAD_SINGLE_FILE_URL.replace('/:fileId?', id ? `/${id}` : '')
10
+ }
11
+
12
+ export const DELETE_FILE_URL = '/api/__ui__/files/delete/:fileId'
13
+ export const getDeleteFileUrl = id => {
14
+ if (!id) throw Error('[ui/FileInput] getDeleteFileUrl: fileId is required')
15
+ return DELETE_FILE_URL.replace(':fileId', id)
16
+ }
package/deleteFile.ts ADDED
@@ -0,0 +1,16 @@
1
+ import { axios, BASE_URL } from 'startupjs'
2
+ import alert from '@startupjs-ui/dialogs/alert'
3
+ import { getDeleteFileUrl } from './constants.js'
4
+
5
+ export default async function deleteFile (fileId?: string): Promise<boolean | undefined> {
6
+ if (!fileId) return
7
+ try {
8
+ const res = await axios.post(BASE_URL + getDeleteFileUrl(fileId))
9
+ fileId = res.data?.fileId
10
+ if (!fileId) throw Error('File delete failed. No deleted fileId returned from server')
11
+ return true
12
+ } catch (err) {
13
+ console.error(err)
14
+ await alert('Error deleting file')
15
+ }
16
+ }
@@ -0,0 +1,360 @@
1
+ import { $, BASE_URL, serverOnly, Signal, sub } from 'startupjs'
2
+ import { createPlugin } from 'startupjs/registry'
3
+ import busboy from 'busboy'
4
+ import sharp from 'sharp'
5
+ import { DELETE_FILE_URL, GET_FILE_URL, getDeleteFileUrl, getFileUrl, getUploadFileUrl, UPLOAD_SINGLE_FILE_URL } from './constants.js'
6
+ import { deleteFile, getDefaultStorageType, getFileBlob, getFileSize, saveFileBlob } from './providers/index.js'
7
+
8
+ export default createPlugin({
9
+ name: 'files',
10
+ enabled: true,
11
+ order: 'system ui',
12
+ isomorphic: () => ({
13
+ models: models => {
14
+ return {
15
+ ...models,
16
+ files: {
17
+ default: FilesModel,
18
+ schema,
19
+ ...models.files
20
+ },
21
+ 'files.*': {
22
+ default: FileModel,
23
+ ...models['files.*']
24
+ }
25
+ }
26
+ }
27
+ }),
28
+ server: () => ({
29
+ serverRoutes: expressApp => {
30
+ expressApp.get(GET_FILE_URL, async (req, res) => {
31
+ let { fileId } = req.params
32
+
33
+ // Extract video file detection early for Range support
34
+ const isVideoRequest = req.url.includes('.mp4') || req.url.includes('.mov') || req.url.includes('.avi')
35
+ // if id has extension, remove it
36
+ // (extension is sometimes added for client libraries to properly handle the file)
37
+ fileId = fileId.replace(/\\.[^.]+$/, '')
38
+ // url might have ?download=true which means we should force download
39
+ const download = (req.query?.download != null)
40
+ const $file = await sub($.files[fileId])
41
+ const file = $file.get()
42
+ if (!file) return res.status(404).send(ERRORS.fileNotFound)
43
+ const { mimeType, storageType, filename, updatedAt } = file
44
+ if (!mimeType) return res.status(500).send(ERRORS.fileMimeTypeNotSet)
45
+ const isVideo = mimeType.startsWith('video/') || isVideoRequest
46
+ if (!storageType) return res.status(500).send(ERRORS.fileStorageTypeNotSet)
47
+
48
+ // handle client-side caching of files
49
+ const clientEtag = req.get('If-None-Match')
50
+ const etag = `"${updatedAt}"`
51
+ // lastModified and ifModifiedSince both use UTC time with seconds precision
52
+ const ifModifiedSince = req.get('If-Modified-Since')
53
+ const lastModified = new Date(updatedAt).toUTCString()
54
+
55
+ function setCacheHeaders () {
56
+ res.setHeader('Etag', etag)
57
+ res.setHeader('Last-Modified', lastModified)
58
+ if (process.env.NODE_ENV === 'production') {
59
+ res.setHeader('Cache-Control', `public, max-age=${5 * 60}`) // cache on client for 5 mins
60
+ } else {
61
+ res.setHeader('Cache-Control', 'no-cache') // always validate cache in development
62
+ }
63
+ // the following headers are set by expo (metro) dev server.
64
+ // We don't want them since we're setting our own cache headers
65
+ // and a single Cache-Control header fully replaces them.
66
+ res.removeHeader('Pragma')
67
+ res.removeHeader('Surrogate-Control')
68
+ res.removeHeader('Expires')
69
+
70
+ // Add Range support for video files (required for iOS AVPlayer)
71
+ if (isVideo) {
72
+ res.setHeader('Accept-Ranges', 'bytes')
73
+ }
74
+ }
75
+
76
+ if (
77
+ clientEtag === etag ||
78
+ (ifModifiedSince && +new Date(ifModifiedSince) >= +new Date(lastModified))
79
+ ) {
80
+ setCacheHeaders()
81
+ return res.status(304).send() // Not Modified
82
+ }
83
+
84
+ try {
85
+ // Performance optimization: True streaming for Range requests
86
+ if (isVideo && req.headers.range) {
87
+ const range = req.headers.range
88
+ console.log('[StartupJS Files] Processing Range request with TRUE streaming:', { fileId, range })
89
+
90
+ try {
91
+ const parts = range.replace(/bytes=/, '').split('-')
92
+ const start = parseInt(parts[0], 10) || 0
93
+
94
+ // Get file size efficiently without loading full file
95
+ const fileSize = await getFileSize(storageType, fileId)
96
+ let end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1
97
+
98
+ console.log('[StartupJS Files] Processing range:', { range, start, end, fileSize, parts })
99
+
100
+ // Fix off-by-one error in range validation
101
+ if (start >= fileSize || start > end) {
102
+ console.log('[StartupJS Files] Invalid range:', { start, end, fileSize })
103
+ res.status(416)
104
+ res.setHeader('Content-Range', `bytes */${fileSize}`)
105
+ return res.send('Range Not Satisfiable')
106
+ }
107
+
108
+ // Adjust end to file bounds
109
+ if (end >= fileSize) {
110
+ end = fileSize - 1
111
+ }
112
+
113
+ // TRUE STREAMING: get only the requested range from storage
114
+ const rangeBlob = await getFileBlob(storageType, fileId, { start, end })
115
+
116
+ // Handle empty responses from MongoDB GridFS
117
+ // This can happen for the last byte due to GridFS chunking behavior
118
+ if (rangeBlob.length === 0) {
119
+ // For the last byte, return a fake byte to satisfy video players
120
+ // HTTP 416 for last byte can prevent video playback entirely
121
+ if (start === fileSize - 1) {
122
+ console.log('[StartupJS Files] Last byte unavailable, returning fake byte for video compatibility:', { start, end, fileSize })
123
+ res.status(206)
124
+ res.setHeader('Content-Range', `bytes ${start}-${end}/${fileSize}`)
125
+ res.setHeader('Content-Length', '1')
126
+ res.type(mimeType)
127
+ setCacheHeaders()
128
+ return res.send(Buffer.from([0x00])) // Fake last byte for video compatibility
129
+ }
130
+
131
+ console.log('[StartupJS Files] Empty response from GridFS, returning 416:', { start, end, fileSize })
132
+ res.status(416)
133
+ res.setHeader('Content-Range', `bytes */${fileSize}`)
134
+ return res.send('Range Not Satisfiable')
135
+ }
136
+
137
+ const chunksize = (end - start) + 1
138
+
139
+ res.status(206) // Partial Content
140
+ res.setHeader('Content-Range', `bytes ${start}-${end}/${fileSize}`)
141
+ res.setHeader('Content-Length', rangeBlob.length.toString())
142
+ res.type(mimeType)
143
+
144
+ if (download) res.setHeader('Content-Disposition', `attachment; filename="${filename}"`)
145
+ setCacheHeaders()
146
+
147
+ console.log('[StartupJS Files] Sending TRUE streaming response:', {
148
+ start,
149
+ end,
150
+ chunksize,
151
+ totalSize: fileSize,
152
+ actualChunkSize: rangeBlob.length,
153
+ contentLength: rangeBlob.length.toString(),
154
+ isLastByte: start === fileSize - 1
155
+ })
156
+ return res.send(rangeBlob)
157
+ } catch (err) {
158
+ console.error('[StartupJS Files] Range request error:', err)
159
+ // Fallback to full file if range processing fails
160
+ }
161
+ }
162
+
163
+ // Load file for non-Range requests (download functionality preserved)
164
+ const blob = await getFileBlob(storageType, fileId)
165
+ const fileBuffer = (blob instanceof Buffer) ? blob : Buffer.from(blob) // avoid unnecessary copy
166
+
167
+ // set the Content-Type header
168
+ res.type(mimeType)
169
+
170
+ // force the file to be downloaded by setting the Content-Disposition header
171
+ if (download) res.setHeader('Content-Disposition', `attachment; filename="${filename}"`)
172
+
173
+ setCacheHeaders()
174
+
175
+ // send the actual file
176
+ res.send(fileBuffer)
177
+ } catch (err) {
178
+ console.error(err)
179
+ res.status(500).send('Error getting file')
180
+ }
181
+ })
182
+
183
+ // this handles both creating and updating a file
184
+ expressApp.post(UPLOAD_SINGLE_FILE_URL, async (req, res) => {
185
+ let { fileId, storageType } = req.params
186
+ try {
187
+ storageType ??= await getDefaultStorageType()
188
+ } catch (err) {
189
+ console.error(err)
190
+ return res.status(500).send('Error getting default storage type')
191
+ }
192
+ const bb = busboy({ headers: req.headers })
193
+
194
+ let blob
195
+ let meta
196
+ bb.on('file', (fieldname, file, { filename, mimeType, encoding }) => {
197
+ if (blob) return res.status(500).send('Only one file is allowed')
198
+
199
+ const buffers = []
200
+ let stream = file
201
+
202
+ if (mimeType.startsWith('image/')) {
203
+ // If it's an image, pipe it through sharp for resizing and conversion
204
+ stream = file.pipe(sharp()
205
+ .rotate()
206
+ .resize(1000, 1000, {
207
+ fit: sharp.fit.inside,
208
+ withoutEnlargement: true
209
+ })
210
+ .toFormat('jpeg', { quality: 80 })) // Convert to JPEG with 85% quality
211
+
212
+ filename = filename.replace(/\\.[^.]+$/, '.jpg') // Change extension to .jpg
213
+ mimeType = 'image/jpeg'
214
+ }
215
+
216
+ // Regardless of whether it's an image or not, collect the data
217
+ stream.on('data', data => buffers.push(data))
218
+
219
+ stream.on('end', async () => {
220
+ blob = Buffer.concat(buffers)
221
+ meta = { filename, mimeType, encoding, storageType } // Update meta here to ensure it includes modifications for images
222
+
223
+ if (!blob) return res.status(500).send('No file was uploaded')
224
+
225
+ // extract extension from filename
226
+ console.log('meta.filename', meta.filename)
227
+ const extension = meta.filename?.match(/\\.([^.]+)$/)?.[1]
228
+ if (extension) meta.extension = extension
229
+ const create = !fileId
230
+ if (!fileId) fileId = $.id()
231
+ // try to save file to sqlite first to do an early exit if it fails
232
+ try {
233
+ await saveFileBlob(storageType, fileId, blob)
234
+ } catch (err) {
235
+ console.error(err)
236
+ return res.status(500).send('Error saving file')
237
+ }
238
+ if (create) {
239
+ const doc = { id: fileId, ...meta }
240
+ // if some of the meta fields were undefined, remove them from the doc
241
+ for (const key in meta) {
242
+ if (meta[key] == null) delete doc[key]
243
+ }
244
+ await $.files.addNew(doc)
245
+ } else {
246
+ const $file = await sub($.files[fileId])
247
+
248
+ // when changing storageType we should delete the file from the old storageType
249
+ const oldStorageType = $file.storageType.get()
250
+ if (oldStorageType !== meta.storageType) {
251
+ try {
252
+ await deleteFile(oldStorageType, fileId)
253
+ } catch (err) {
254
+ console.error(err)
255
+ return res.status(500).send(`Error deleting file from old storageType ${oldStorageType}`)
256
+ }
257
+ }
258
+
259
+ const doc = { ...$file.get(), ...meta, updatedAt: Date.now() }
260
+ // if some of the meta fields were undefined, remove them from the doc
261
+ for (const key in meta) {
262
+ if (meta[key] == null) delete doc[key]
263
+ }
264
+ await $file.set(doc)
265
+ }
266
+ console.log(`Uploaded file to ${storageType}`, fileId)
267
+ res.json({ fileId })
268
+ })
269
+ })
270
+
271
+ return req.pipe(bb)
272
+ })
273
+
274
+ expressApp.post(DELETE_FILE_URL, async (req, res) => {
275
+ const { fileId } = req.params
276
+ const $file = await sub($.files[fileId])
277
+ const file = $file.get()
278
+ if (!file) return res.status(404).send(ERRORS.fileNotFound)
279
+ const { storageType } = file
280
+ if (!storageType) return res.status(500).send(ERRORS.fileStorageTypeNotSet)
281
+ try {
282
+ await deleteFile(storageType, fileId)
283
+ await $file.del()
284
+ res.json({ fileId })
285
+ } catch (err) {
286
+ console.error(err)
287
+ res.status(500).send('Error deleting file')
288
+ }
289
+ })
290
+ }
291
+ })
292
+ })
293
+
294
+ const schema = {
295
+ storageType: { type: 'string', required: true },
296
+ mimeType: { type: 'string', required: true },
297
+ filename: { type: 'string' }, // original filename with extension
298
+ encoding: { type: 'string' },
299
+ extension: { type: 'string' },
300
+ createdAt: { type: 'number', required: true },
301
+ // updatedAt is used to determine whether the underlying file
302
+ // stored in the storageType provider has changed.
303
+ // This is used to properly cache files on the client side.
304
+ updatedAt: { type: 'number', required: true }
305
+ }
306
+
307
+ class FilesModel extends Signal {
308
+ async addNew (file) {
309
+ const now = Date.now()
310
+ return await this.add({
311
+ ...file,
312
+ createdAt: now,
313
+ updatedAt: now
314
+ })
315
+ }
316
+
317
+ getUrl (fileId, extension) {
318
+ return BASE_URL + getFileUrl(fileId, extension)
319
+ }
320
+
321
+ getDownloadUrl (fileId, extension) {
322
+ return BASE_URL + getFileUrl(fileId, extension) + '?download=true'
323
+ }
324
+
325
+ getUploadUrl (fileId) {
326
+ return BASE_URL + getUploadFileUrl(fileId)
327
+ }
328
+
329
+ getDeleteUrl (fileId) {
330
+ return BASE_URL + getDeleteFileUrl(fileId)
331
+ }
332
+ }
333
+
334
+ class FileModel extends Signal {
335
+ getUrl () {
336
+ return BASE_URL + getFileUrl(this.getId(), this.extension.get())
337
+ }
338
+
339
+ getDownloadUrl () {
340
+ return this.getUrl() + '?download=true'
341
+ }
342
+
343
+ getUploadUrl () {
344
+ return BASE_URL + getUploadFileUrl(this.getId())
345
+ }
346
+
347
+ getDeleteUrl () {
348
+ return BASE_URL + getDeleteFileUrl(this.getId())
349
+ }
350
+
351
+ getBlob = serverOnly(function () {
352
+ return getFileBlob(this.storageType.get(), this.getId())
353
+ })
354
+ }
355
+
356
+ const ERRORS = {
357
+ fileNotFound: 'File not found',
358
+ fileMimeTypeNotSet: 'File mimeType is not set. This should never happen',
359
+ fileStorageTypeNotSet: 'File storageType is not set. This should never happen'
360
+ }
package/index.d.ts ADDED
@@ -0,0 +1,36 @@
1
+ /* eslint-disable */
2
+ // DO NOT MODIFY THIS FILE - IT IS AUTOMATICALLY GENERATED ON COMMITS.
3
+
4
+ import { type ReactNode, type RefObject } from 'react';
5
+ export { default } from './input';
6
+ export declare const _PropsJsonSchema: {};
7
+ export interface FileInputProps {
8
+ /** Ref object to access imperative methods (`pickFile`, `deleteFile`, `uploadFile`) */
9
+ ref?: RefObject<FileInputRef>;
10
+ /** Current fileId (stored in `files` model on the server) */
11
+ value?: string;
12
+ /** MIME types accepted by the picker (passed to `expo-document-picker`) */
13
+ mimeTypes?: string | string[];
14
+ /** When true, opens image picker instead of document picker */
15
+ image?: boolean;
16
+ /** Upload selected file immediately after picking @default true */
17
+ uploadImmediately?: boolean;
18
+ /** Hook called before upload starts (can be async) */
19
+ beforeUpload?: () => any;
20
+ /** Hook called after upload finishes (can be async) */
21
+ afterUpload?: () => any;
22
+ /** Called with new fileId after successful upload */
23
+ onChange?: (fileId?: string) => void;
24
+ /** Custom renderer instead of the default Upload/Change/Delete buttons */
25
+ render?: () => ReactNode;
26
+ /** Custom styles (reserved for future use) */
27
+ style?: any;
28
+ }
29
+ export interface FileInputRef {
30
+ /** Opens the native picker and (optionally) uploads the selected file */
31
+ pickFile: () => Promise<any>;
32
+ /** Deletes the currently selected file */
33
+ deleteFile: () => Promise<void>;
34
+ /** Uploads a picked asset to server and returns new fileId */
35
+ uploadFile: (asset: any, fileId?: string) => Promise<string | undefined>;
36
+ }
package/index.tsx ADDED
@@ -0,0 +1,37 @@
1
+ import { type ReactNode, type RefObject } from 'react'
2
+
3
+ export { default } from './input'
4
+
5
+ export const _PropsJsonSchema = {/* FileInputProps */}
6
+
7
+ export interface FileInputProps {
8
+ /** Ref object to access imperative methods (`pickFile`, `deleteFile`, `uploadFile`) */
9
+ ref?: RefObject<FileInputRef>
10
+ /** Current fileId (stored in `files` model on the server) */
11
+ value?: string
12
+ /** MIME types accepted by the picker (passed to `expo-document-picker`) */
13
+ mimeTypes?: string | string[]
14
+ /** When true, opens image picker instead of document picker */
15
+ image?: boolean
16
+ /** Upload selected file immediately after picking @default true */
17
+ uploadImmediately?: boolean
18
+ /** Hook called before upload starts (can be async) */
19
+ beforeUpload?: () => any
20
+ /** Hook called after upload finishes (can be async) */
21
+ afterUpload?: () => any
22
+ /** Called with new fileId after successful upload */
23
+ onChange?: (fileId?: string) => void
24
+ /** Custom renderer instead of the default Upload/Change/Delete buttons */
25
+ render?: () => ReactNode
26
+ /** Custom styles (reserved for future use) */
27
+ style?: any
28
+ }
29
+
30
+ export interface FileInputRef {
31
+ /** Opens the native picker and (optionally) uploads the selected file */
32
+ pickFile: () => Promise<any>
33
+ /** Deletes the currently selected file */
34
+ deleteFile: () => Promise<void>
35
+ /** Uploads a picked asset to server and returns new fileId */
36
+ uploadFile: (asset: any, fileId?: string) => Promise<string | undefined>
37
+ }
package/input.expo.tsx ADDED
@@ -0,0 +1,101 @@
1
+ import { useImperativeHandle, type ReactNode } from 'react'
2
+ import { pug, observer } from 'startupjs'
3
+ import Button from '@startupjs-ui/button'
4
+ import Div from '@startupjs-ui/div'
5
+ import { themed } from '@startupjs-ui/core'
6
+ import confirm from '@startupjs-ui/dialogs/confirm'
7
+ import * as DocumentPicker from 'expo-document-picker'
8
+ import * as ImagePicker from 'expo-image-picker'
9
+ import { faTrashAlt } from '@fortawesome/free-solid-svg-icons/faTrashAlt'
10
+ import deleteFileApi from './deleteFile'
11
+ import uploadFileApi from './uploadFile'
12
+ import type { FileInputProps, FileInputRef } from './index'
13
+
14
+ export default observer(themed('FileInput', FileInput))
15
+
16
+ function FileInput ({
17
+ value: initialFileId,
18
+ mimeTypes,
19
+ image,
20
+ uploadImmediately = true,
21
+ beforeUpload,
22
+ afterUpload,
23
+ onChange = () => undefined,
24
+ render,
25
+ ref
26
+ }: FileInputProps): ReactNode {
27
+ let fileId = initialFileId
28
+
29
+ useImperativeHandle(ref, (): FileInputRef => {
30
+ return {
31
+ pickFile,
32
+ deleteFile: handleDeleteFile,
33
+ uploadFile: uploadFileApi
34
+ }
35
+ })
36
+
37
+ async function pickFile () {
38
+ let result: any
39
+ if (image) {
40
+ result = await ImagePicker.launchImageLibraryAsync({
41
+ mediaTypes: ImagePicker.MediaTypeOptions.Images,
42
+ allowsEditing: true,
43
+ aspect: [4, 3],
44
+ quality: 1
45
+ })
46
+ } else {
47
+ result = await DocumentPicker.getDocumentAsync({ type: mimeTypes as any })
48
+ }
49
+
50
+ const cancelled = result?.cancelled ?? result?.canceled
51
+ const assets = result?.assets
52
+ if (cancelled || !assets) return
53
+
54
+ if (!uploadImmediately) return assets[0]
55
+
56
+ if (beforeUpload) {
57
+ const beforeUploadResult = beforeUpload()
58
+ if (beforeUploadResult?.then) await beforeUploadResult
59
+ }
60
+
61
+ let handled
62
+ for (const asset of assets) {
63
+ if (handled) throw Error('Only one file is allowed')
64
+ fileId = await uploadFileApi(asset, fileId)
65
+ if (!fileId) return
66
+ onChange(fileId)
67
+ handled = true
68
+ }
69
+
70
+ if (afterUpload) {
71
+ const afterUploadResult = afterUpload()
72
+ if (afterUploadResult?.then) await afterUploadResult
73
+ }
74
+ }
75
+
76
+ async function handleDeleteFile () {
77
+ if (!fileId) return
78
+ if (!await confirm('Are you sure you want to delete this file?')) return
79
+ const deleted = await deleteFileApi(fileId)
80
+ if (!deleted) return
81
+ onChange(undefined)
82
+ }
83
+
84
+ function renderDefault (): ReactNode {
85
+ return pug`
86
+ if fileId
87
+ Div(row)
88
+ Button(onPress=pickFile) Change
89
+ Button(pushed onPress=handleDeleteFile variant='text' icon=faTrashAlt)
90
+ else
91
+ Button(onPress=pickFile) Upload file
92
+ `
93
+ }
94
+
95
+ return pug`
96
+ if render
97
+ = render()
98
+ else
99
+ = renderDefault()
100
+ `
101
+ }
package/input.tsx ADDED
@@ -0,0 +1,12 @@
1
+ import { type ReactNode } from 'react'
2
+ import { observer } from 'startupjs'
3
+ import { themed } from '@startupjs-ui/core'
4
+ import type { FileInputProps } from './index'
5
+
6
+ export default observer(themed('FileInput', FileInput))
7
+
8
+ function FileInput (props: FileInputProps): ReactNode {
9
+ throw Error(`
10
+ <FileInput /> is only available in Expo projects
11
+ `)
12
+ }
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@startupjs-ui/file-input",
3
+ "version": "0.1.3",
4
+ "publishConfig": {
5
+ "access": "public"
6
+ },
7
+ "main": "index.tsx",
8
+ "types": "index.d.ts",
9
+ "type": "module",
10
+ "exports": {
11
+ ".": "./index.tsx",
12
+ "./constants": "./constants.js",
13
+ "./deleteFile": "./deleteFile.ts",
14
+ "./uploadFile": "./uploadFile.ts",
15
+ "./providers": "./providers/index.js",
16
+ "./files.plugin": "./files.plugin.js"
17
+ },
18
+ "dependencies": {
19
+ "@fortawesome/free-solid-svg-icons": "^5.12.0",
20
+ "@startupjs-ui/button": "^0.1.3",
21
+ "@startupjs-ui/core": "^0.1.3",
22
+ "@startupjs-ui/dialogs": "^0.1.3",
23
+ "@startupjs-ui/div": "^0.1.3",
24
+ "busboy": "^1.6.0",
25
+ "expo-document-picker": ">=14.0.0",
26
+ "expo-image-picker": ">=17.0.0",
27
+ "sharp": "^0.34.5"
28
+ },
29
+ "peerDependencies": {
30
+ "@azure/storage-blob": "*",
31
+ "mongodb": "*",
32
+ "react": "*",
33
+ "react-native": "*",
34
+ "startupjs": "*"
35
+ },
36
+ "peerDependenciesMeta": {
37
+ "@azure/storage-blob": {
38
+ "optional": true
39
+ },
40
+ "mongodb": {
41
+ "optional": true
42
+ }
43
+ },
44
+ "gitHead": "fd964ebc3892d3dd0a6c85438c0af619cc50c3f0"
45
+ }
@@ -0,0 +1,158 @@
1
+ import { BlobServiceClient } from '@azure/storage-blob'
2
+
3
+ // Replace with your Azure Blob Storage connection string or Azurite connection string
4
+ const AZURE_BLOB_STORAGE_CONNECTION_STRING = process.env.AZURE_BLOB_STORAGE_CONNECTION_STRING
5
+ const CONTAINER_NAME = 'files' // Container name for storing blobs
6
+
7
+ let blobServiceClient
8
+ let containerClient
9
+
10
+ export function validateSupport () {
11
+ if (!AZURE_BLOB_STORAGE_CONNECTION_STRING) {
12
+ throw new Error(ERRORS.azureNotAvailable)
13
+ }
14
+ // Initialize the BlobServiceClient and ContainerClient once
15
+ if (!blobServiceClient) {
16
+ blobServiceClient = BlobServiceClient.fromConnectionString(AZURE_BLOB_STORAGE_CONNECTION_STRING)
17
+ containerClient = blobServiceClient.getContainerClient(CONTAINER_NAME)
18
+ // Ensure container exists
19
+ containerClient.createIfNotExists().catch((err) => {
20
+ console.error('[Azure Blob Storage] Failed to create container:', err)
21
+ throw err
22
+ })
23
+ }
24
+ }
25
+
26
+ export async function getFileBlob (fileId, range) {
27
+ validateSupport()
28
+ const blobClient = containerClient.getBlobClient(fileId)
29
+
30
+ try {
31
+ // Check if blob exists
32
+ const properties = await blobClient.getProperties()
33
+ const actualFileSize = properties.contentLength
34
+
35
+ if (range) {
36
+ console.log('[Azure Blob Storage] Using Range request for optimal streaming:', { fileId, range })
37
+
38
+ // Validate range boundaries
39
+ if (range.start >= actualFileSize || range.start < 0) {
40
+ console.log('[Azure Blob Storage] Range start out of bounds:', { start: range.start, actualFileSize })
41
+ throw new Error('Range start out of bounds')
42
+ }
43
+
44
+ // Ensure end is within file bounds
45
+ const adjustedEnd = Math.min(range.end, actualFileSize - 1)
46
+
47
+ // Ensure end is not before start
48
+ if (adjustedEnd < range.start) {
49
+ console.log('[Azure Blob Storage] Invalid range:', { start: range.start, end: adjustedEnd, actualFileSize })
50
+ throw new Error('Invalid range')
51
+ }
52
+
53
+ console.log('[Azure Blob Storage] Downloading blob with range:', {
54
+ start: range.start,
55
+ end: adjustedEnd,
56
+ actualFileSize,
57
+ originalEnd: range.end
58
+ })
59
+
60
+ // Download blob with range
61
+ const response = await blobClient.download(range.start, adjustedEnd - range.start + 1)
62
+ const chunks = []
63
+
64
+ for await (const chunk of response.readableStreamBody) {
65
+ chunks.push(chunk)
66
+ }
67
+
68
+ const result = Buffer.concat(chunks)
69
+ const expectedSize = adjustedEnd - range.start + 1
70
+
71
+ console.log('[Azure Blob Storage] Range response:', {
72
+ expected: expectedSize,
73
+ actual: result.length,
74
+ start: range.start,
75
+ end: adjustedEnd,
76
+ fileId
77
+ })
78
+
79
+ if (result.length === 0) {
80
+ console.warn('[Azure Blob Storage] Empty range response - this may indicate a problem')
81
+ }
82
+
83
+ return result
84
+ } else {
85
+ // Regular download for non-Range requests
86
+ const response = await blobClient.download()
87
+ const chunks = []
88
+
89
+ for await (const chunk of response.readableStreamBody) {
90
+ chunks.push(chunk)
91
+ }
92
+
93
+ return Buffer.concat(chunks)
94
+ }
95
+ } catch (error) {
96
+ if (error.statusCode === 404) {
97
+ throw new Error(ERRORS.fileNotFound)
98
+ }
99
+ console.error('[Azure Blob Storage] Error downloading blob:', error)
100
+ throw error
101
+ }
102
+ }
103
+
104
+ export async function getFileSize (fileId) {
105
+ validateSupport()
106
+ const blobClient = containerClient.getBlobClient(fileId)
107
+
108
+ try {
109
+ const properties = await blobClient.getProperties()
110
+ return properties.contentLength
111
+ } catch (error) {
112
+ if (error.statusCode === 404) {
113
+ throw new Error(ERRORS.fileNotFound)
114
+ }
115
+ console.error('[Azure Blob Storage] Error getting blob size:', error)
116
+ throw error
117
+ }
118
+ }
119
+
120
+ export async function saveFileBlob (fileId, blob) {
121
+ validateSupport()
122
+ const blobClient = containerClient.getBlockBlobClient(fileId)
123
+
124
+ try {
125
+ // Upload blob (overwrites if exists)
126
+ await blobClient.upload(blob, blob.length)
127
+ console.log('[Azure Blob Storage] Blob uploaded successfully:', fileId)
128
+ } catch (error) {
129
+ console.error('[Azure Blob Storage] Error uploading blob:', error)
130
+ throw error
131
+ }
132
+ }
133
+
134
+ export async function deleteFile (fileId) {
135
+ validateSupport()
136
+ const blobClient = containerClient.getBlobClient(fileId)
137
+
138
+ try {
139
+ await blobClient.deleteIfExists()
140
+ console.log('[Azure Blob Storage] Blob deleted successfully:', fileId)
141
+ } catch (error) {
142
+ if (error.statusCode === 404) {
143
+ throw new Error(ERRORS.fileNotFound)
144
+ }
145
+ console.error('[Azure Blob Storage] Error deleting blob:', error)
146
+ throw error
147
+ }
148
+ }
149
+
150
+ const ERRORS = {
151
+ azureNotAvailable: `
152
+ Azure Blob Storage connection is not available.
153
+ Make sure you have configured the AZURE_BLOB_STORAGE_CONNECTION_STRING environment variable.
154
+ `,
155
+ fileNotFound: `
156
+ File not found in Azure Blob Storage.
157
+ `
158
+ }
@@ -0,0 +1,60 @@
1
+ import { mongo, sqlite } from 'startupjs/server'
2
+
3
+ export async function getFileBlob (storageType, fileId, range) {
4
+ return (await getStorageProvider(storageType)).getFileBlob(fileId, range)
5
+ }
6
+
7
+ export async function getFileSize (storageType, fileId) {
8
+ return (await getStorageProvider(storageType)).getFileSize(fileId)
9
+ }
10
+
11
+ export async function saveFileBlob (storageType, fileId, blob) {
12
+ return (await getStorageProvider(storageType)).saveFileBlob(fileId, blob)
13
+ }
14
+
15
+ export async function deleteFile (storageType, fileId) {
16
+ return (await getStorageProvider(storageType)).deleteFile(fileId)
17
+ }
18
+
19
+ export async function getDefaultStorageType () {
20
+ const storage = process.env.DEFAULT_STORAGE_TYPE
21
+
22
+ if (storage) return storage
23
+ if (mongo) return 'mongo'
24
+ if (sqlite) return 'sqlite'
25
+ throw Error(ERRORS.noDefaultStorageProvider)
26
+ }
27
+
28
+ const moduleCache = {}
29
+
30
+ async function getStorageProvider (storageType) {
31
+ if (moduleCache[storageType]) return moduleCache[storageType]
32
+
33
+ let theModule
34
+ if (storageType === 'sqlite') {
35
+ theModule = await import('./sqlite.js')
36
+ } else if (storageType === 'mongo') {
37
+ theModule = await import('./mongo.js')
38
+ } else if (storageType === 'azureblob') {
39
+ theModule = await import('./azureblob.js')
40
+ } else {
41
+ throw Error(ERRORS.unsupportedStorageType(storageType))
42
+ }
43
+
44
+ await theModule.validateSupport?.()
45
+
46
+ moduleCache[storageType] = theModule
47
+ return theModule
48
+ }
49
+
50
+ const ERRORS = {
51
+ unsupportedStorageType: storageType => `
52
+ [@startupjs/ui] FileInput: You tried getting file from storageType '${storageType}',
53
+ but it's not supported.
54
+ This should never happen.
55
+ `,
56
+ noDefaultStorageProvider: `
57
+ [@startupjs/ui] FileInput: No default storage provider can be used.
58
+ Neither MongoDB is used in your project nor SQLite (persistent mingo).
59
+ `
60
+ }
@@ -0,0 +1,187 @@
1
+ import { mongo } from 'startupjs/server'
2
+ import { GridFSBucket } from 'mongodb'
3
+
4
+ let bucket
5
+
6
+ export function validateSupport () {
7
+ if (!mongo) throw Error(ERRORS.mongoNotAvailable)
8
+ // Initialize the GridFSBucket once
9
+ if (!bucket) {
10
+ bucket = new GridFSBucket(mongo)
11
+ }
12
+ }
13
+
14
+ export async function getFileBlob (fileId, range) {
15
+ validateSupport()
16
+ const files = await bucket.find({ filename: fileId }).toArray()
17
+ if (!files || files.length === 0) {
18
+ throw new Error(ERRORS.fileNotFound)
19
+ }
20
+
21
+ return new Promise((resolve, reject) => {
22
+ // Performance optimization: use Range requests for partial content
23
+ let downloadStream
24
+ if (range) {
25
+ console.log('[MongoDB GridFS] Using Range request for optimal streaming:', { fileId, range })
26
+
27
+ // Validate range boundaries
28
+ const actualFileSize = files[0].length
29
+ if (range.start >= actualFileSize || range.start < 0) {
30
+ console.log('[MongoDB GridFS] Range start out of bounds:', { start: range.start, actualFileSize })
31
+ return reject(new Error('Range start out of bounds'))
32
+ }
33
+
34
+ // Ensure end is within file bounds
35
+ const adjustedEnd = Math.min(range.end, actualFileSize - 1)
36
+
37
+ // Ensure end is not before start
38
+ if (adjustedEnd < range.start) {
39
+ console.log('[MongoDB GridFS] Invalid range:', { start: range.start, end: adjustedEnd, actualFileSize })
40
+ return reject(new Error('Invalid range'))
41
+ }
42
+
43
+ console.log('[MongoDB GridFS] Opening download stream with range:', {
44
+ start: range.start,
45
+ end: adjustedEnd,
46
+ actualFileSize,
47
+ originalEnd: range.end
48
+ })
49
+
50
+ downloadStream = bucket.openDownloadStreamByName(fileId, {
51
+ start: range.start,
52
+ end: adjustedEnd
53
+ })
54
+
55
+ // Add error handling for stream errors
56
+ downloadStream.on('error', (error) => {
57
+ console.error('[MongoDB GridFS] Stream error:', error)
58
+ reject(error)
59
+ })
60
+ } else {
61
+ // Regular download for non-Range requests (download functionality)
62
+ downloadStream = bucket.openDownloadStreamByName(fileId)
63
+ }
64
+
65
+ const chunks = []
66
+
67
+ downloadStream.on('data', (chunk) => {
68
+ chunks.push(chunk)
69
+ })
70
+
71
+ downloadStream.on('error', (err) => {
72
+ reject(err)
73
+ })
74
+
75
+ downloadStream.on('end', () => {
76
+ const result = Buffer.concat(chunks)
77
+ if (range) {
78
+ const expectedSize = range.end - range.start + 1
79
+ console.log('[MongoDB GridFS] Range response:', {
80
+ expected: expectedSize,
81
+ actual: result.length,
82
+ start: range.start,
83
+ end: range.end,
84
+ fileId
85
+ })
86
+
87
+ // Validate that we got the expected data
88
+ if (result.length === 0) {
89
+ console.warn('[MongoDB GridFS] Empty range response - this may indicate a problem')
90
+ }
91
+ }
92
+ resolve(result)
93
+ })
94
+ })
95
+ }
96
+
97
+ export async function getFileSize (fileId) {
98
+ validateSupport()
99
+ const files = await bucket.find({ filename: fileId }).toArray()
100
+ if (!files || files.length === 0) {
101
+ throw new Error(ERRORS.fileNotFound)
102
+ }
103
+ return files[0].length
104
+ }
105
+
106
+ export async function saveFileBlob (fileId, blob) {
107
+ console.log('[MongoDB GridFS] Saving file:', {
108
+ fileId,
109
+ blobType: blob ? blob.constructor.name : 'undefined',
110
+ blobLength: blob instanceof Buffer ? blob.length : 'N/A'
111
+ })
112
+
113
+ validateSupport()
114
+ // Delete existing files with the same filename
115
+ const files = await bucket.find({ filename: fileId }).toArray()
116
+ for (const file of files) {
117
+ console.log('[MongoDB GridFS] Deleting existing files')
118
+ await bucket.delete(file._id)
119
+ }
120
+
121
+ console.log('db', mongo)
122
+
123
+ return new Promise((resolve, reject) => {
124
+ console.log('[MongoDB GridFS] Opening upload stream')
125
+ const uploadStream = bucket.openUploadStream(fileId)
126
+
127
+ uploadStream.on('data', (chunk) => {
128
+ console.log('[MongoDB GridFS] Stream data received:', { fileId, chunkLength: chunk.length })
129
+ })
130
+ uploadStream.on('end', () => {
131
+ console.log('[MongoDB GridFS] Stream end event:', fileId)
132
+ })
133
+ uploadStream.on('close', () => {
134
+ console.log('[MongoDB GridFS] Stream closed:', fileId)
135
+ })
136
+
137
+ uploadStream.on('error', (err) => {
138
+ console.warn('[MongoDB GridFS] error writing to stream', err)
139
+ reject(err)
140
+ })
141
+
142
+ uploadStream.on('finish', () => {
143
+ console.log('[MongoDB GridFS] Finished writing to stream')
144
+ resolve()
145
+ })
146
+
147
+ try {
148
+ if (blob instanceof Buffer) {
149
+ uploadStream.write(blob)
150
+ uploadStream.end()
151
+ } else if (blob.readable) {
152
+ blob.pipe(uploadStream)
153
+ } else {
154
+ const err = new Error('Unsupported blob type')
155
+ console.error('[MongoDB GridFS] Error:', err)
156
+ reject(err)
157
+ }
158
+ } catch (err) {
159
+ console.error('[MongoDB GridFS] Error writing blob:', err)
160
+ reject(err)
161
+ }
162
+
163
+ console.log('here we go')
164
+ })
165
+ }
166
+
167
+ export async function deleteFile (fileId) {
168
+ validateSupport()
169
+ const files = await bucket.find({ filename: fileId }).toArray()
170
+ if (!files || files.length === 0) {
171
+ throw new Error(ERRORS.fileNotFound)
172
+ }
173
+
174
+ for (const file of files) {
175
+ await bucket.delete(file._id)
176
+ }
177
+ }
178
+
179
+ const ERRORS = {
180
+ mongoNotAvailable: `
181
+ [@startupjs/ui] FileInput: MongoDB connection is not available.
182
+ Make sure you have connected to MongoDB before using this function.
183
+ `,
184
+ fileNotFound: `
185
+ File not found in MongoDB GridFS.
186
+ `
187
+ }
@@ -0,0 +1,113 @@
1
+ import { sqlite } from 'startupjs/server'
2
+
3
+ export async function validateSupport () {
4
+ if (!sqlite) throw Error(ERRORS.disabled)
5
+ }
6
+
7
+ export async function getFileBlob (fileId, range) {
8
+ return await new Promise((resolve, reject) => {
9
+ sqlite.get('SELECT * FROM files WHERE id = ?', [fileId], (err, row) => {
10
+ if (err) return reject(err)
11
+ if (!row) return reject(new Error(ERRORS.fileNotFoundInSqlite))
12
+ if (!row.data) return reject(new Error(ERRORS.fileDataNotFoundInSqlite))
13
+
14
+ const blob = row.data
15
+ const actualFileSize = blob.length
16
+
17
+ if (range) {
18
+ console.log('[SQLite] Using Range request for optimal streaming:', { fileId, range })
19
+
20
+ // Validate range boundaries
21
+ if (range.start >= actualFileSize || range.start < 0) {
22
+ console.log('[SQLite] Range start out of bounds:', { start: range.start, actualFileSize })
23
+ return reject(new Error('Range start out of bounds'))
24
+ }
25
+
26
+ // Ensure end is within file bounds
27
+ const adjustedEnd = Math.min(range.end, actualFileSize - 1)
28
+
29
+ // Ensure end is not before start
30
+ if (adjustedEnd < range.start) {
31
+ console.log('[SQLite] Invalid range:', { start: range.start, end: adjustedEnd, actualFileSize })
32
+ return reject(new Error('Invalid range'))
33
+ }
34
+
35
+ console.log('[SQLite] Retrieving blob with range:', {
36
+ start: range.start,
37
+ end: adjustedEnd,
38
+ actualFileSize,
39
+ originalEnd: range.end
40
+ })
41
+
42
+ // Extract the requested range from the blob
43
+ const result = blob.slice(range.start, adjustedEnd + 1)
44
+ const expectedSize = adjustedEnd - range.start + 1
45
+
46
+ console.log('[SQLite] Range response:', {
47
+ expected: expectedSize,
48
+ actual: result.length,
49
+ start: range.start,
50
+ end: adjustedEnd,
51
+ fileId
52
+ })
53
+
54
+ if (result.length === 0) {
55
+ console.warn('[SQLite] Empty range response - this may indicate a problem')
56
+ }
57
+
58
+ resolve(result)
59
+ } else {
60
+ // Return full blob for non-range requests
61
+ resolve(blob)
62
+ }
63
+ })
64
+ })
65
+ }
66
+
67
+ export async function saveFileBlob (fileId, blob) {
68
+ return await new Promise((resolve, reject) => {
69
+ sqlite.run('INSERT OR REPLACE INTO files (id, data) VALUES (?, ?)', [fileId, blob], err => {
70
+ if (err) return reject(err)
71
+ resolve()
72
+ })
73
+ })
74
+ }
75
+
76
+ export async function deleteFile (fileId) {
77
+ return await new Promise((resolve, reject) => {
78
+ sqlite.run('DELETE FROM files WHERE id = ?', [fileId], err => {
79
+ if (err) return reject(err)
80
+ resolve()
81
+ })
82
+ })
83
+ }
84
+
85
+ export async function getFileSize (fileId) {
86
+ await validateSupport()
87
+ return await new Promise((resolve, reject) => {
88
+ sqlite.get('SELECT data FROM files WHERE id = ?', [fileId], (err, row) => {
89
+ if (err) return reject(err)
90
+ if (!row) return reject(new Error(ERRORS.fileNotFoundInSqlite))
91
+ if (!row.data) return reject(new Error(ERRORS.fileDataNotFoundInSqlite))
92
+ resolve(row.data.length)
93
+ })
94
+ })
95
+ }
96
+
97
+ const ERRORS = {
98
+ disabled: `
99
+ [@startupjs/ui] FileInput: You tried getting file from SQLite,
100
+ but it's not used in your project.
101
+ ('storageType' is set to 'sqlite' in the 'files' document).
102
+ This should never happen.
103
+ If you migrated your DB from local SQLite in dev
104
+ to MongoDB in production, you must reupload all your files from SQLite to MongoDB
105
+ while also changing 'storageType' to 'mongo' in your 'files' collection.
106
+ `,
107
+ fileNotFoundInSqlite: `
108
+ File not found in SQLite.
109
+ `,
110
+ fileDataNotFoundInSqlite: `
111
+ File data not found in SQLite.
112
+ `
113
+ }
package/uploadFile.ts ADDED
@@ -0,0 +1,72 @@
1
+ import { Platform } from 'react-native'
2
+ import { axios, BASE_URL } from 'startupjs'
3
+ import alert from '@startupjs-ui/dialogs/alert'
4
+ import { getUploadFileUrl } from './constants.js'
5
+
6
+ const isWeb = Platform.OS === 'web'
7
+
8
+ export default async function uploadFile (asset: any, fileId?: string): Promise<string | undefined> {
9
+ try {
10
+ const formData = new FormData()
11
+ const uri: string | undefined = asset?.uri
12
+ if (!uri) throw Error('File upload failed. No asset.uri provided')
13
+ let type = asset.mimeType
14
+ const name = asset.name || asset.fileName || getFilenameFromUri(uri)
15
+ if (!type) {
16
+ if (asset.type === 'image') type = getImageMimeType(uri || asset.fileName || asset.name || '')
17
+ }
18
+
19
+ if (isWeb) {
20
+ // on web we'll receive it as a uri blob
21
+ const blob = await (await fetch(uri)).blob()
22
+ formData.append('file', blob, name)
23
+ } else {
24
+ formData.append('file', {
25
+ uri,
26
+ name,
27
+ type
28
+ } as any)
29
+ }
30
+ const res = await axios.post(BASE_URL + getUploadFileUrl(fileId), formData, {
31
+ headers: {
32
+ 'Content-Type': 'multipart/form-data'
33
+ }
34
+ })
35
+ fileId = res.data?.fileId
36
+ if (!fileId) throw Error('File upload failed. No fileId returned from server')
37
+ console.log('Uploaded file:', fileId)
38
+ return fileId
39
+ } catch (err) {
40
+ console.error(err)
41
+ await alert('Error uploading file')
42
+ }
43
+ }
44
+
45
+ function getFilenameFromUri (uri: string) {
46
+ if (uri.length > 1000) return 'file' // if it's a base64 encoded uri
47
+ return (uri.split(/[/\\]/).pop() ?? 'file').toLowerCase()
48
+ }
49
+
50
+ function getImageMimeType (filename: string) {
51
+ // Extract the file extension from the filename
52
+ const extension = (filename.split('.').pop() ?? '').toLowerCase()
53
+
54
+ // Map of image extensions to MIME types
55
+ const mimeTypes: Record<string, string> = {
56
+ jpg: 'image/jpeg',
57
+ jpeg: 'image/jpeg',
58
+ png: 'image/png',
59
+ gif: 'image/gif',
60
+ bmp: 'image/bmp',
61
+ webp: 'image/webp',
62
+ tiff: 'image/tiff',
63
+ svg: 'image/svg+xml',
64
+ ico: 'image/vnd.microsoft.icon',
65
+ heic: 'image/heic',
66
+ heif: 'image/heif',
67
+ avif: 'image/avif'
68
+ }
69
+
70
+ // Return the corresponding MIME type or default to image/{extension}
71
+ return mimeTypes[extension] || (extension ? `image/${extension}` : 'image')
72
+ }