diff --git a/src/database.ts b/src/database.ts index f34a4db..3d6d8e3 100644 --- a/src/database.ts +++ b/src/database.ts @@ -29,6 +29,7 @@ export interface RecordFileItem { size: number; path: string; thumbnail?: string; + type?: string; } function fromDB(data: any): UploadRecord { diff --git a/src/index.ts b/src/index.ts index 8b78e60..df26096 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,5 @@ import { Hono } from "hono"; +// import { parseMultipartRequest } from '@mjackson/multipart-parser'; import { createUploadRecord, deleteRecord, @@ -6,6 +7,7 @@ import { getUploadRecordBySlug, migrateTables, purgeRecordsBeforeId, + RecordFileItem, } from "./database"; import { H } from "hono/types"; import { createSeekableTarball } from "./stream-tarball"; @@ -45,45 +47,165 @@ app.get("/api/list", authWithPassword, async (c) => { // return c.json(r) }); -app.post("/api/upload", authWithPassword, async (c) => { +const timingMiddleware: H = async (c, next) => { + const start = Date.now(); + const resp = await next(); + console.log(`[${c.req.method}] ${c.req.url} ${Date.now() - start}ms`); + return resp; +}; + +app.post("/api/upload", timingMiddleware, authWithPassword, async (c) => { + const reqContentType = String(c.req.header('Content-Type')); const uploader = c.req.header("x-uploader") || "unknown"; - const body = await c.req.formData(); + const filePathPrefix = `cf_drop/${Date.now()}`; - const files = body.getAll("files").filter((file) => file instanceof File); - const thumbnails = body.getAll("thumbnails") as string[]; - const message = String(body.get("message") || ""); + let message = ''; + let fileInfos: RecordFileItem[] = []; - if (!files.length && !message) { - return c.json({ error: "No files or message" }); + const rollbacks: (() => void)[] = []; + const filePutPromises: Promise[] = []; + const errors: { error: Error, fileInfo: RecordFileItem }[] = []; + + try { + if (false && reqContentType.startsWith('multipart/form-data')) { + // stream upload -- kinda slower than native formData() parser + + /* + const fields = parseMultipartRequest(c.req.raw); + for await (const field of fields) { + switch (field.name) { + case 'message': + message = await field.text(); + break; + case 'fileInfos': + initializeFileInfos(JSON.parse(await field.text())); + break; + case 'files': { + const info = fileInfos[filePutPromises.length]; + if (info) { + let stopped = false; + let readLength = 0; + let reader = field.body.getReader(); + const stream = new ReadableStream({ + expectedLength: info.size, + cancel(reason) { + stopped = true; + reader.cancel(reason); + }, + async start(controller) { + while (!stopped) { + const { done, value } = await reader.read(); + if (done) { + controller.close(); + break; + } + + readLength += value.length; + const remaining = info.size - readLength; + if (remaining <= 0) { + controller.enqueue(remaining !== 0 ? value.subarray(0, remaining) : value); + controller.close(); + reader.cancel(); + stopped = true; + break; + } + controller.enqueue(value); + } + } + }) + queueNextFileToUpload(stream); + } + break; + } + } + } + */ + } else { + // native formData() parser + const body = await c.req.formData(); + message = String(body.get("message") || ""); + initializeFileInfos(JSON.parse(body.get("fileInfos")!.toString())); + body.getAll("files").forEach((file) => { + if (file instanceof File) queueNextFileToUpload(file); + }); + } + + if (!fileInfos.length && !message) { + return c.json({ error: "No files or message" }); + } + + // create record + await Promise.all(filePutPromises); + + if (filePutPromises.length !== fileInfos.length) { + throw new Error('Invalid fileInfos'); + } + + if (errors.length) { + rollbacks.forEach((fn) => fn()); + c.status(500); + return c.json({ + message: 'Failed to upload files', + errors: errors.map(it => ({ + error: String(it.error), + file: it.fileInfo.name, + })), + }); + } + + const record = await createUploadRecord(c.env.DB, { + uploader, + size: fileInfos.reduce((acc, file) => acc + file.size, 0), + files: fileInfos, + message, + }); + + return c.json({ record }); + } catch (err) { + rollbacks.forEach((fn) => fn()); + c.status(500); + return c.json({ + message: 'Failed to upload files', + errors: [{ error: String(err), file: '' }], + }); } // upload files to bucket - const filePathPrefix = `cf_drop/${Date.now()}`; - const uploadedFiles = await Promise.all( - files.map(async (file, index) => { - const fileName = file.name; - const filePath = `${filePathPrefix}/${fileName}`; - const r = await c.env.MY_BUCKET.put(filePath, file, { - httpMetadata: { contentType: file.type }, + function queueNextFileToUpload(body: ReadableStream | Blob) { + const index = filePutPromises.length; + const item = fileInfos[index]; + if (!item) throw new Error('Invalid index of file'); + + const { path, type } = item; + const rollback = () => c.env.MY_BUCKET.delete(path).catch(() => { }); + rollbacks.push(rollback); + + filePutPromises.push((async () => { + try { + const r = await c.env.MY_BUCKET.put(path, body, { + httpMetadata: { contentType: type }, + }) + item.size = r.size; + } catch (e) { + errors.push({ error: e as Error, fileInfo: item }); + } + })()); + } + + function initializeFileInfos(tmp: any) { + if (!Array.isArray(tmp)) throw new Error('Invalid fileInfos'); + + fileInfos.length = 0; + for (const file of tmp) { + fileInfos.push({ + name: String(file.name), + size: +file.size, + thumbnail: String(file.thumbnail || ''), + path: `${filePathPrefix}/${fileInfos.length}_${file.name}`, + type: String(file.type || 'application/octet-stream'), }); - return { - name: fileName, - path: filePath, - size: r.size, - thumbnail: thumbnails[index] || "", - }; - }) - ); - - // create record - const record = await createUploadRecord(c.env.DB, { - uploader, - size: uploadedFiles.reduce((acc, file) => acc + file.size, 0), - files: uploadedFiles, - message, - }); - - return c.json({ record }); + } + } }); app.get("/api/download/:slug/message", async (c) => { diff --git a/web/src/App.scss b/web/src/App.scss index caf4eb9..0fc5701 100644 --- a/web/src/App.scss +++ b/web/src/App.scss @@ -22,12 +22,12 @@ textarea { } } -button { +button, .btn { position: relative; @apply cursor-pointer disabled:cursor-default; @apply bg-brand hover:bg-brand-4 disabled:bg-brand-3 text-white rounded-md border-0 transition-colors; - @apply flex items-center justify-center gap-1 px-4 py-2; + @apply flex items-center justify-center gap-1 px-4 py-2 leading-none; &.btn-gray { @apply bg-coolGray-5 hover:bg-coolGray-4 disabled:bg-coolGray-3; @@ -52,3 +52,41 @@ input[type='text'] { font-size: inherit; font-family: Menlo, Consolas, Roboto Mono, 'Source Code Pro', 'Courier New', Courier, monospace; } + +body { + --x-padding: 16px; + --x-scrollbar-width: 12px; + --x-padding-right-with-scrollbar: calc(var(--x-padding) - var(--x-scrollbar-width)); + @media (hover: none) { + // touch devices + body { + // touch device's scrollbar doesn't occupy space + --x-padding-right-with-scrollbar: var(--x-padding); + } + } +} + +.withScrollbar { + overflow-y: scroll; + padding: var(--x-padding); + padding-right: var(--x-padding-right-with-scrollbar); + + &::-webkit-scrollbar { + width: var(--x-scrollbar-width); + } + + &::-webkit-scrollbar-track { + background: transparent; + } + + &::-webkit-scrollbar-thumb { + background: transparent; + border-radius: 8px; + } + + &:is(:hover, :focus-within) { + &::-webkit-scrollbar-thumb { + background: rgba(0, 0, 0, 0.15); + } + } +} diff --git a/web/src/App.tsx b/web/src/App.tsx index 3f90bc1..5fb2f59 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,73 +1,16 @@ -import { useState } from 'react'; import './App.scss'; import { ContentInput } from './components/ContentInput'; import { UploadRecords } from './components/UploadRecords'; -import { passwordAtom } from './store/auth'; -import { store } from './store'; import { PasswordInput } from './components/PasswordInput'; import { useAtom } from 'jotai'; -import { inputFilesAtom, inputTextAtom } from './store/input'; +import { uploadingErrorAtom, uploadingProgressAtom } from './store/uploading'; const App = () => { - const [progress, setProgress] = useState(0); - const [text, setText] = useAtom(inputTextAtom); - const [files, setFiles] = useAtom(inputFilesAtom); - - function startUpload(text: string, files: File[]) { - return new Promise((resolve, reject) => { - const body = new FormData(); - body.append('message', text); - files.forEach((file) => body.append('files', file)); - files.forEach((file) => body.append('thumbnails', file.thumbnail || '')); - - const xhr = new XMLHttpRequest(); - xhr.open('POST', '/api/upload'); - xhr.setRequestHeader('x-uploader', 'yon'); - xhr.setRequestHeader('x-password', store.get(passwordAtom)); - - xhr.upload.onprogress = (e) => { - if (e.lengthComputable) { - const percentComplete = (e.loaded / e.total) * 100; - setProgress(percentComplete); - } - }; - - xhr.onload = () => { - console.log('upload response', xhr.responseText); - - if (xhr.status === 200) { - // const data = JSON.parse(xhr.responseText); - setProgress(0); - window.dispatchEvent(new Event('records-updated')); - setText(''); - setFiles([]); - resolve(); - return; - } - - reject(new Error('Upload failed')); - }; - - xhr.onerror = (e) => { - console.error('upload error', e); - reject(new Error('Upload failed')); - }; - - xhr.send(body); - }); - } - return (
- - -
+ + +
@@ -75,12 +18,18 @@ const App = () => { ); }; -function SimpleProgressBar(props: { progress: number }) { - return ( -
-
+function SimpleProgressBar() { + const [progress] = useAtom(uploadingProgressAtom) + const [error] = useAtom(uploadingErrorAtom) + + return (<> + {!!error &&
+ {error} +
} +
+
- ); + ); } export default App; diff --git a/web/src/actions/upload.ts b/web/src/actions/upload.ts new file mode 100644 index 0000000..d1e0719 --- /dev/null +++ b/web/src/actions/upload.ts @@ -0,0 +1,77 @@ +import type { FileStoreItem } from "../database/files"; + +/** + * The actual upload logic, it invokes API /api/upload + * + * can be used in either client or service worker. + */ +export async function invokeUploadAPI(opts: { + text: string, + files: FileStoreItem[], + password: string, + onProgress(percent: number): void, +}) { + const { text, files, password, onProgress } = opts; + const uploader = 'yon'; // TODO: change it + const fileInfosStr = JSON.stringify(files.map(f => ({ + thumbnail: f.thumbnail, + size: f.blob.size, + type: f.blob.type, + name: f.name, + }))) + + const body = new FormData(); + body.append('message', text); + body.append('fileInfos', fileInfosStr); + files.forEach((file) => body.append('files', file.blob)); + + // (not works -- upload progress is not supported on http/1.1) + // const bodyStream = getFormDataStream(body, (sent, total) => onProgress(sent / total * 100)); + // const res = await fetch('/api/upload', { + // method: 'POST', + // headers: { + // 'x-uploader': uploader, + // 'x-password': password, + // 'content-type': `multipart/form-data; boundary=${bodyStream.boundary}`, + // 'content-length': String(bodyStream.size), + // }, + // body: bodyStream.stream, + // duplex: 'half', + // }); + // const resText = await res.text(); + + return new Promise((resolve, reject) => { + // ---- xhr way ---- + + const xhr = new XMLHttpRequest(); + xhr.open('POST', '/api/upload'); + xhr.setRequestHeader('x-uploader', uploader); + xhr.setRequestHeader('x-password', password); + + xhr.upload.onprogress = (e) => { + if (e.lengthComputable) { + const percentComplete = (e.loaded / e.total) * 100; + onProgress(percentComplete); + } + }; + + xhr.onload = () => { + console.log('upload response', xhr.responseText); + + if (xhr.status === 200) { + onProgress(100); + resolve(JSON.parse(xhr.responseText)); + return; + } + + reject(new Error('Upload failed')); + }; + + xhr.onerror = (e) => { + console.error('upload error', e); + reject(new Error('Upload failed')); + }; + + xhr.send(body); + }); +} diff --git a/web/src/components/ContentInput.tsx b/web/src/components/ContentInput.tsx index f8cecbc..7636528 100644 --- a/web/src/components/ContentInput.tsx +++ b/web/src/components/ContentInput.tsx @@ -1,51 +1,22 @@ import { memo, useEffect, useRef, useState } from 'react'; import { useConsistCallback } from '../utils/useConsistCallback'; -import { isEqual } from '../utils/isEqual'; import { createThumbnail } from '../utils/createThumbnail'; import { getFilesFromDataTransfer } from '../utils/fileEntry'; +import { FileStoreItem } from '../database/files'; +import { addFiles, clearFiles, inputFilesAtom, inputTextAtom, removeFile } from '../store/input'; +import { useAtom } from 'jotai'; +import { startUpload } from '../store/uploading'; -interface Props { - text?: string; - files?: File[]; - onTextChange?: (text: string) => void; - onFilesChange?: (files: File[]) => void; - onSend?: (text: string, files: File[]) => Promise; -} - -export const ContentInput = memo((props) => { - const [files, setFiles] = useState(props.files?.slice() ?? []); - const [text, setText] = useState(props.text ?? ''); +export const ContentInput = memo(() => { + const [files] = useAtom(inputFilesAtom) + const [text, setText] = useAtom(inputTextAtom); const textAreaRef = useRef(null); - const handleFilesChange = useConsistCallback((cb: (files: File[]) => File[]) => { - let files: File[]; - setFiles((oldFiles) => { - files = cb(oldFiles.slice()); - return files; - }); - - setTimeout(async () => { - const thumbnailAdded = await Promise.all(files.map(addThumbnail)); - if (thumbnailAdded.some((added) => added)) setFiles(files.slice()); - - props.onFilesChange?.(files); - const totalSize = files.reduce((acc, file) => acc + file.size, 0); - if (totalSize > 0 && totalSize < 10e6 && !text) { - // Send if files are uploaded and total size is less than 10MB - props.onSend?.(text, files) - } - }, 0); - }); - const [isDragOver, setIsDragOver] = useState(false); useEffect(() => { const handlePaste = (e: ClipboardEvent) => { // Handle pasted files - getFilesFromDataTransfer(e.clipboardData).then((pastedFiles) => { - if (pastedFiles.length) { - handleFilesChange((prev) => [...prev, ...pastedFiles]); - } - }); + getFilesFromDataTransfer(e.clipboardData).then((droppedFiles) => addFiles(droppedFiles)); // Handle pasted text const pastedText = e.clipboardData?.getData('text/plain'); @@ -107,11 +78,7 @@ export const ContentInput = memo((props) => { e.preventDefault(); e.stopPropagation(); - getFilesFromDataTransfer(e.dataTransfer).then((droppedFiles) => { - if (droppedFiles.length) { - handleFilesChange((prev) => [...prev, ...droppedFiles]); - } - }); + getFilesFromDataTransfer(e.dataTransfer).then((droppedFiles) => addFiles(droppedFiles)); }; window.addEventListener('drop', handleDrop, true); @@ -123,44 +90,47 @@ export const ContentInput = memo((props) => { }; }, [isDragOver]); - const handleTextChange = (e: React.ChangeEvent) => { + const handleTextChange = useConsistCallback((e: React.ChangeEvent) => { setText(e.target.value); - props.onTextChange?.(e.target.value); - }; + }); - const openFilePicker = () => { + const openFilePicker = useConsistCallback(() => { const input = document.createElement('input'); input.type = 'file'; input.multiple = true; input.click(); input.onchange = (e: any) => { const files = Array.from(e.target.files) as File[]; - handleFilesChange((prev) => [...prev, ...files]); + addFiles(files).then(() => { input.value = '' }); }; - }; + }); - const removeFile = useConsistCallback((index: number) => { - handleFilesChange((files) => files.filter((_, i) => i !== index)); + const handleRemoveFile = useConsistCallback((id: number) => { + removeFile(id); }); const handleSend = useConsistCallback(() => { - props.onSend?.(text, files) + startUpload(); }); + useEffect(() => { + function handleKeyDown(e: KeyboardEvent) { + if (e.key === 'Enter' && (e.metaKey || e.ctrlKey) && !e.isComposing) { + startUpload(); + } + } + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [startUpload]); const doClear = useConsistCallback(() => { - setFiles([]); setText(''); - props.onTextChange?.(''); - props.onFilesChange?.([]); + clearFiles(); }); - useEffect(() => { if (props.text !== undefined && props.text !== text) setText(props.text); }, [props.text]); - useEffect(() => { if (props.files !== undefined && !isEqual(props.files, files)) setFiles(props.files); }, [props.files]); - return (
{ if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { handleSend(); @@ -187,11 +157,15 @@ export const ContentInput = memo((props) => { Add file - {files.map((file, index) => )} - + + {files.map((file) => )}
{!!isDragOver && ( @@ -212,15 +186,15 @@ declare global { } } -function AttachedFileItem({ index, file, removeFile }: { index: number; file: File; removeFile: (index: number) => void; }) { +function AttachedFileItem({ file, removeFile }: { file: FileStoreItem; removeFile: (id: number) => void; }) { const [url, setUrl] = useState(); useEffect(() => { - const url = URL.createObjectURL(file); + const url = URL.createObjectURL(file.blob); setUrl(url); return () => URL.revokeObjectURL(url); }, [file]); - return
+ return
{file.thumbnail ? ( ) : ( @@ -232,7 +206,7 @@ function AttachedFileItem({ index, file, removeFile }: { index: number; file: Fi title={file.name} className="truncate max-w-48 text-inherit decoration-none hover:decoration-underline">{file.name} + +
+
) + } +
+ ) +}) \ No newline at end of file diff --git a/web/src/components/UploadRecords.tsx b/web/src/components/UploadRecords.tsx index e36fe7c..49fcce1 100644 --- a/web/src/components/UploadRecords.tsx +++ b/web/src/components/UploadRecords.tsx @@ -5,6 +5,7 @@ import relativeTime from 'dayjs/plugin/relativeTime'; import type { UploadRecord } from '../../../src/database'; import { fetchAPI } from '../store/auth'; +import { PopoverConfirm } from './PopoverConfirm'; dayjs.extend(relativeTime); @@ -27,7 +28,7 @@ export const UploadRecords = memo((props) => { }, [mutate]); return ( -
+
{error &&
Error: {error.message}
} {data?.map((page, i) => (
@@ -49,7 +50,7 @@ const UploadRecordItem = memo((props: { record: UploadRecord }) => { const actionLink = 'cursor-pointer p-2 m--2 text-inherit decoration-none hover:text-brand-6 hover:bg-brand-1 rounded-lg'; - const meta =
+ const meta =
{props.record.uploader} @@ -100,20 +101,23 @@ const UploadRecordItem = memo((props: { record: UploadRecord }) => { ) } - (e.preventDefault(), deleteRecord(props.record.id))} href='#' role='button'> - - Delete - -
+ deleteRecord(props.record.id)}> + e.preventDefault()} href='#' role='button'> + + Delete + + +
return (
-
+
{meta} - {!!props.record.message &&
{props.record.message}
} - + {!!props.record.message &&
{props.record.message}
} {files.length > 0 && ( -
+
{files.map((file, index) => { const link = `/api/download/${props.record.slug}/${index}`; return ( @@ -177,7 +181,6 @@ function copyToClipboard(text: string) { } function deleteRecord(id: number) { - if (!confirm('Are you sure you want to delete this record?')) return; fetchAPI('/api/delete', { method: 'POST', headers: { diff --git a/web/src/database/connect.ts b/web/src/database/connect.ts new file mode 100644 index 0000000..8949845 --- /dev/null +++ b/web/src/database/connect.ts @@ -0,0 +1,51 @@ +import { createPromise } from "../utils/promise"; + +export const enum TableName { + KV = 'kv', + Files = 'files' +} + +let dbPromise: Promise | undefined; + +export async function connect(force?: boolean) { + if (dbPromise && !force) return dbPromise; + return dbPromise = new Promise((resolve, reject) => { + const request = indexedDB.open('cf-drop-kvdb', 2); + let upgradePromise = Promise.resolve(); + request.onerror = (e) => { + console.error('IndexedDB error', e); + reject(e); + }; + request.onupgradeneeded = (e) => { + const db = (e as any).target.result as IDBDatabase; + let transaction: undefined | IDBTransaction; + + upgradePromise = (async () => { + switch (e.oldVersion) { + case 0: { + // since ver 1, create a "kv" table + const store = db.createObjectStore(TableName.KV, { keyPath: 'key' }); + transaction = store.transaction + } + + case 1: { + // since ver 2, create a "files" table to store files to be uploaded + const store = db.createObjectStore(TableName.Files, { keyPath: 'id', autoIncrement: true }); + transaction = store.transaction + } + } + + if (transaction) { + const p = createPromise(); + transaction.oncomplete = p.resolve; + transaction.onerror = p.reject; + await p.promise; + } + })(); + }; + request.onsuccess = (e: any) => { + const db = e.target.result as IDBDatabase; + upgradePromise.then(() => resolve(db)); + }; + }); +} diff --git a/web/src/database/files.ts b/web/src/database/files.ts new file mode 100644 index 0000000..2c78568 --- /dev/null +++ b/web/src/database/files.ts @@ -0,0 +1,106 @@ +import { createPromise } from "../utils/promise"; +import { connect, TableName } from "./connect"; + +export interface FileStoreItem { + id: number; + name: string; + blob: Blob; + thumbnail: string; + ctime: number; +} + +async function listFiles() { + const db = await connect(); + const transaction = db.transaction(TableName.Files, 'readonly'); + const store = transaction.objectStore(TableName.Files); + const request = store.getAll(); + + const p = createPromise(); + request.onerror = p.reject; + request.onsuccess = p.resolve; + await p.promise; + + return request.result as FileStoreItem[]; +} + +async function addFile(info: { name: string, blob: Blob, thumbnail?: string }): Promise { + const item: Omit = { + name: info.name, + blob: info.blob, + thumbnail: info.thumbnail || '', + ctime: Date.now(), + }; + + const db = await connect(); + const transaction = db.transaction(TableName.Files, 'readwrite'); + const store = transaction.objectStore(TableName.Files); + const request = store.add(item); + + const p = createPromise(); + request.onerror = p.reject; + request.onsuccess = p.resolve; + await p.promise; + + return { + id: request.result as number, + ...item, + }; +} + +async function updateFile(id: number, info: Partial) { + const db = await connect(); + const transaction = db.transaction(TableName.Files, 'readwrite'); + const store = transaction.objectStore(TableName.Files); + const request = store.get(id); + + const p = createPromise(); + request.onerror = p.reject; + request.onsuccess = p.resolve; + await p.promise; + + const item = request.result as FileStoreItem; + if (!item) { + throw new Error(`File not found: ${id}`); + } + + const updateRequest = store.put({ ...item, ...info }, id); + + const p2 = createPromise(); + updateRequest.onerror = p2.reject; + updateRequest.onsuccess = p2.resolve; + await p2.promise; +} + +async function deleteFile(id: number) { + const db = await connect(); + const transaction = db.transaction(TableName.Files, 'readwrite'); + const store = transaction.objectStore(TableName.Files); + const request = store.delete(id); + + const p = createPromise(); + request.onerror = p.reject; + request.onsuccess = p.resolve; + await p.promise; +} + +async function clearFiles() { + const db = await connect(); + const transaction = db.transaction(TableName.Files, 'readwrite'); + const store = transaction.objectStore(TableName.Files); + const request = store.clear(); + + const p = createPromise(); + request.onerror = p.reject; + request.onsuccess = p.resolve; + await p.promise; +} + +const FileStore = { + list: listFiles, + add: addFile, + update: updateFile, + delete: deleteFile, + clear: clearFiles, +}; + +export default FileStore; diff --git a/web/src/database/kv.ts b/web/src/database/kv.ts new file mode 100644 index 0000000..54a7d87 --- /dev/null +++ b/web/src/database/kv.ts @@ -0,0 +1,65 @@ +import { debounce } from "../utils/debounce"; +import { createPromise } from "../utils/promise"; +import { connect, TableName } from "./connect"; + +const KvDefaults = { + password: '', + inputText: '', +} + +export type KvKey = keyof typeof KvDefaults; +export type KvValues = typeof KvDefaults; + +async function kvGet(key: K): Promise { + const db = await connect(); + const transaction = db.transaction(TableName.KV, 'readonly'); + const store = transaction.objectStore(TableName.KV); + const request = store.get(key); + + const p = createPromise(); + request.onerror = p.reject; + request.onsuccess = p.resolve; + await p.promise; + + const val = request.result?.value; + return (val === undefined ? KvDefaults[key] : val); +} + +async function kvSet(key: K, value: KvValues[K]) { + const db = await connect(); + const transaction = db.transaction(TableName.KV, 'readwrite'); + const store = transaction.objectStore(TableName.KV); + const request = store.put({ key, value }); + + const p = createPromise(); + request.onerror = p.reject; + request.onsuccess = p.resolve; + await p.promise; +} + +interface KvStoreItem { + get(): Promise; + set(value: T): Promise; + setDebounced(value: T): void; + flush(): void | Promise; +} + +const KvStore = Object.fromEntries( + Object.keys(KvDefaults).map((key) => [key, createKvItem(key as KvKey)]) +) as { [K in KvKey]: KvStoreItem }; + +export default KvStore; + +function createKvItem(key: K): KvStoreItem { + const debouncedSet = debounce((value: KvValues[K]) => kvSet(key, value), 1000); + + return { + get: () => kvGet(key), + setDebounced: debouncedSet, + set(value) { + debouncedSet.cancel(); + return kvSet(key, value); + }, + flush: debouncedSet.flush, + }; +} diff --git a/web/src/store/auth.ts b/web/src/store/auth.ts index f727888..0f81619 100644 --- a/web/src/store/auth.ts +++ b/web/src/store/auth.ts @@ -1,13 +1,13 @@ import { atom } from 'jotai'; import { store } from '.'; -import { kvGet, KvKey, kvSet } from './kvdb'; +import KvStore from '../database/kv'; export const passwordAtom = atom(''); export const passwordInvalidAtom = atom(false); -const passwordInitPromise = kvGet(KvKey.Password, '').then((password) => { +const passwordInitPromise = KvStore.password.get().then((password) => { store.set(passwordAtom, password); - store.sub(passwordAtom, () => { kvSet(KvKey.Password, store.get(passwordAtom)); }) + store.sub(passwordAtom, () => KvStore.password.setDebounced(store.get(passwordAtom))); return password; }); diff --git a/web/src/store/input.ts b/web/src/store/input.ts index 4644a6d..cc1542c 100644 --- a/web/src/store/input.ts +++ b/web/src/store/input.ts @@ -2,21 +2,51 @@ import { atom } from 'jotai'; import { store } from '.'; -import { kvGet, kvSet, KvKey } from './kvdb'; -import { debounce } from '../utils/debounce'; +import KvStore from '../database/kv'; +import FileStore, { FileStoreItem } from '../database/files'; +import { createThumbnail } from '../utils/createThumbnail'; export const inputTextAtom = atom(''); -export const inputFilesAtom = atom([]); +export const inputFilesAtom = atom([]); -kvGet(KvKey.InputText, '').then((text) => store.set(inputTextAtom, text)); -kvGet(KvKey.InputFiles, []).then((files: any[]) => store.set(inputFilesAtom, files.map(f => { - const file = f.file; - file.thumbnail = f.thumbnail; - return file; -}))); +KvStore.inputText.get().then((text) => { + store.set(inputTextAtom, text) + store.sub(inputTextAtom, () => KvStore.inputText.setDebounced(store.get(inputTextAtom))); +}); -store.sub(inputTextAtom, debounce(() => kvSet(KvKey.InputText, store.get(inputTextAtom)), 1000)); -store.sub(inputFilesAtom, debounce(() => { - const files = store.get(inputFilesAtom) - kvSet(KvKey.InputFiles, files.map(file => ({ file, thumbnail: file.thumbnail }))) -}, 500)); +FileStore.list().then((files) => { + store.set(inputFilesAtom, files); +}) + +export async function addFiles(files: File[]) { + for (const file of files) { + let thumbnail = ''; + if (file.type.startsWith('image/')) { + const url = URL.createObjectURL(file); + try { + thumbnail = await createThumbnail(url); + } catch (e) { + // failed to create thumbnail + } + URL.revokeObjectURL(url); + } + + const item = await FileStore.add({ + name: file.name, + blob: file, + thumbnail, + }); + + store.set(inputFilesAtom, (prev) => [...prev, item]); + } +} + +export async function removeFile(id: number) { + await FileStore.delete(id); + store.set(inputFilesAtom, (prev) => prev.filter((item) => item.id !== id)); +} + +export async function clearFiles() { + await FileStore.clear(); + store.set(inputFilesAtom, []); +} diff --git a/web/src/store/kvdb.ts b/web/src/store/kvdb.ts deleted file mode 100644 index 266f318..0000000 --- a/web/src/store/kvdb.ts +++ /dev/null @@ -1,67 +0,0 @@ -let dbPromise: Promise | undefined - -const tableName = 'kv'; - -export enum KvKey { - Password = 'password', - InputText = 'input-text', - InputFiles = 'input-files', -} - -async function connect(force?: boolean) { - if (dbPromise && !force) return dbPromise; - return dbPromise = new Promise((resolve, reject) => { - const request = indexedDB.open('cf-drop-kvdb', 1); - let upgradePromise = Promise.resolve(); - request.onerror = (e) => { - console.error('kvdb error', e); - reject(e); - }; - request.onupgradeneeded = (e) => { - const db = (e as any).target.result as IDBDatabase; - upgradePromise = new Promise((resolve, reject) => { - const store = db.createObjectStore(tableName, { keyPath: 'key' }) - store.transaction.oncomplete = () => resolve(); - store.transaction.onabort = reject; - store.transaction.onerror = reject; - }); - }; - request.onsuccess = (e: any) => { - const db = e.target.result as IDBDatabase; - upgradePromise.then(() => resolve(db)); - }; - }) -} - -export async function kvGet(key: string, defaultValue: T): Promise { - const db = await connect(); - return new Promise((resolve, reject) => { - const transaction = db.transaction(tableName, 'readonly'); - const store = transaction.objectStore(tableName); - const request = store.get(key); - request.onerror = (e) => { - console.error('kvdb get error', e); - reject(e); - }; - request.onsuccess = () => { - let val = request.result?.value; - resolve(val === undefined ? defaultValue : val); - }; - }); -} - -export async function kvSet(key: string, value: T) { - const db = await connect(); - return new Promise((resolve, reject) => { - const transaction = db.transaction(tableName, 'readwrite'); - const store = transaction.objectStore(tableName); - const request = store.put({ key, value }); - request.onerror = (e) => { - console.error('kvdb set error', e); - reject(e); - }; - request.onsuccess = () => { - resolve(); - }; - }); -} diff --git a/web/src/store/uploading.ts b/web/src/store/uploading.ts new file mode 100644 index 0000000..9a4f0db --- /dev/null +++ b/web/src/store/uploading.ts @@ -0,0 +1,49 @@ +import { atom } from "jotai"; +import { store } from "."; +import { invokeUploadAPI } from "../actions/upload"; +import { clearFiles, inputFilesAtom, inputTextAtom } from "./input"; +import { passwordAtom } from "./auth"; + +export const isUploadingAtom = atom(false); +export const uploadingProgressAtom = atom(0); +export const uploadingErrorAtom = atom(null); + +export function startUpload() { + if (store.get(isUploadingAtom)) return; // uploading + + const text = store.get(inputTextAtom); + const files = store.get(inputFilesAtom); + if (!text && !files.length) { + store.set(uploadingErrorAtom, 'No files or text'); + return; // nothing to upload + } + + store.set(isUploadingAtom, true); + store.set(uploadingErrorAtom, null); + + // upload in foreground + return invokeUploadAPI({ + text: text, + files: files, + password: store.get(passwordAtom), + onProgress(percent) { + store.set(uploadingProgressAtom, percent); + }, + }).then( + () => handleUploadEnd(), + error => handleUploadEnd(error), + ); +} + +function handleUploadEnd(error?: any) { + store.set(isUploadingAtom, false); + window.dispatchEvent(new Event('records-updated')); + if (error) { + console.error(error); + store.set(uploadingErrorAtom, String(error)); + } else { + store.set(inputTextAtom, ''); + clearFiles(); + store.set(uploadingProgressAtom, 0); + } +} diff --git a/web/src/sw-client.tsx b/web/src/sw-client.tsx index 45526a3..7c33026 100644 --- a/web/src/sw-client.tsx +++ b/web/src/sw-client.tsx @@ -1,18 +1,23 @@ import { Workbox } from 'workbox-window'; +let wb: Workbox | null = null; if ('serviceWorker' in navigator) { - const wb = new Workbox('/sw.js'); + wb = new Workbox('/sw.js'); wb.addEventListener('waiting', () => { const toast = document.createElement('div'); toast.className = 'bg-brand-6 text-white text-sm p-2 fixed top-0 left-0 right-0 z-50 animate-slide-in-down animate-duration-200 cursor-pointer'; toast.innerText = 'New version available. click to refresh'; document.body.appendChild(toast); toast.addEventListener('click', () => { - wb.addEventListener('controlling', () => { + wb!.addEventListener('controlling', () => { window.location.reload(); }) - wb.messageSkipWaiting(); + wb!.messageSkipWaiting(); }); }); wb.register(); } + +export function getWorkbox() { + return wb; +} diff --git a/web/src/sw.ts b/web/src/sw.ts index 6103066..7941210 100644 --- a/web/src/sw.ts +++ b/web/src/sw.ts @@ -1,17 +1,20 @@ import { registerRoute } from 'workbox-routing'; import { precacheAndRoute } from 'workbox-precaching'; +import KvStore from './database/kv'; +import FileStore from './database/files'; declare var self: ServiceWorkerGlobalScope & { __WB_MANIFEST: any }; precacheAndRoute(self.__WB_MANIFEST); addEventListener('message', (event) => { - if (event.data && event.data.type === 'SKIP_WAITING') { + const data = event.data; + if (data?.type === 'SKIP_WAITING') { self.skipWaiting(); } }); registerRoute( - '/api/post', + '/api/share_target/', async ({ request }) => { const formData = await request.formData(); const title = formData.get('title') || ''; @@ -19,9 +22,34 @@ registerRoute( const url = formData.get('url') || ''; const files = formData.getAll('files') || []; - console.log('recv', { title, text, url, files }); + console.log('from share', { title, text, url, files }); - const resp = new Response('hello world for ' + request.url); + // write message to indexedDB + const newMessage = [ + await KvStore.inputText.get(), + title, + text, + url, + ].filter(Boolean).join('\n\n'); + await KvStore.inputText.set(newMessage); + + // add files to indexedDB + for (const file of files) { + if (!(file instanceof File)) continue; + await FileStore.add({ + name: file.name, + blob: file, + // URL.createObjectURL is not supported in service worker + // so we can't create thumbnail + }); + } + + const resp = new Response('Redirecting to /', { + status: 302, + headers: { + location: '/', + }, + }); return resp; }, 'POST', diff --git a/web/src/utils/debounce.ts b/web/src/utils/debounce.ts index d2828b0..c97755a 100644 --- a/web/src/utils/debounce.ts +++ b/web/src/utils/debounce.ts @@ -1,24 +1,31 @@ -export function debounce(fn: () => void, delay = 1000) { +/** + * make a debounced function. last args will apply + */ +export function debounce(fn: (...args: T) => void, delay = 1000) { let timeout: number | undefined; - const debounced = () => { + let finalArgs: T | undefined; + + const reset = () => { + if (timeout) { + clearTimeout(timeout); + timeout = undefined; + finalArgs = undefined; + } + }; + + const flush = () => { + if (!timeout) return; + const args = finalArgs!; + reset(); + return fn(...args); + }; + + const debounced = (...args: T) => { if (timeout) clearTimeout(timeout); - timeout = window.setTimeout(() => { - timeout = undefined; - fn(); - }, delay); - }; - debounced.cancel = () => { - if (timeout) { - clearTimeout(timeout); - timeout = undefined; - } - }; - debounced.flush = () => { - if (timeout) { - clearTimeout(timeout); - timeout = undefined; - } - fn(); + finalArgs = args; + timeout = window.setTimeout(flush, delay); }; + debounced.cancel = reset; + debounced.flush = flush; return debounced; } diff --git a/web/src/utils/fileEntry.ts b/web/src/utils/fileEntry.ts index 1d89d8c..a1239af 100644 --- a/web/src/utils/fileEntry.ts +++ b/web/src/utils/fileEntry.ts @@ -1,12 +1,9 @@ export async function getFilesFromDataTransfer(dataTransfer?: DataTransfer | null): Promise { if (!dataTransfer) return []; - const items = Array.from(dataTransfer.items || []); const files: File[] = []; - for (const item of items) { - const file = await getFilesFromDataTransferItem(item); - files.push(...file); - } + const parts = await Promise.all(Array.from(dataTransfer.items || []).map(getFilesFromDataTransferItem)); + for (const part of parts) files.push(...part); return files; } diff --git a/web/src/utils/getFormDataStream.ts b/web/src/utils/getFormDataStream.ts new file mode 100644 index 0000000..9bb81a0 --- /dev/null +++ b/web/src/utils/getFormDataStream.ts @@ -0,0 +1,65 @@ +export function getFormDataStream(formData: FormData, onReadProgress: (sent: number, total: number) => void): { size: number, boundary: string, stream: ReadableStream } { + const boundary = `------------${Date.now().toString(36)}`; + + let size = 0; + const parts: (Blob | Uint8Array)[] = []; + + const encoder = new TextEncoder(); + const encode = (str: string) => encoder.encode(str); + + formData.forEach((value, key) => { + if (typeof value === 'string') { + const part = encode(`--${boundary}\r\nContent-Disposition: form-data; name="${key}"\r\n\r\n${value}\r\n`); + parts.push(part); + size += part.length; + return; + } + + const leading = encode(`--${boundary}\r\nContent-Disposition: form-data; name="${key}"; filename="${value.name}"\r\nContent-Type: ${value.type}\r\n\r\n`); + parts.push(leading); + size += leading.length; + + parts.push(value); + size += value.size; + + parts.push(encode(`\r\n`)); + size += 2; + }); + + const lastBoundary = encode(`--${boundary}--\r\n`); + parts.push(lastBoundary); + size += lastBoundary.length; + + let sent = 0; + let aborted = false; + let currentFileReader: ReadableStreamDefaultReader | undefined; + const stream = new ReadableStream({ + async start(controller) { + for (const part of parts) { + if (aborted) return; + if (part instanceof Uint8Array) { + controller.enqueue(part); + sent += part.length; + onReadProgress(sent, size); + } else { + currentFileReader = part.stream().getReader(); + while (!aborted) { + const { done, value } = await currentFileReader.read().catch(() => ({ value: undefined, done: true })) + if (done) break; + controller.enqueue(value!); + sent += value!.length; + onReadProgress(sent, size); + } + currentFileReader = undefined; + } + } + controller.close(); + }, + cancel(reason) { + aborted = true; + currentFileReader?.cancel(reason); + }, + }) + + return { size, boundary, stream }; +} diff --git a/web/src/utils/promise.ts b/web/src/utils/promise.ts new file mode 100644 index 0000000..af933e1 --- /dev/null +++ b/web/src/utils/promise.ts @@ -0,0 +1,9 @@ +export function createPromise() { + let resolve!: (value: T) => void; + let reject!: (reason?: any) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +}