Merge branch 'main' of github.com:lyonbot/cf-drop

This commit is contained in:
lyonbot committed 2025-02-08 20:34:23 +08:00
commit bfa7160b44
21 files changed
+833 -292

No files matched your search

+1
View File
@@ -29,6 +29,7 @@ export interface RecordFileItem {
size: number;
path: string;
thumbnail?: string;
type?: string;
}
function fromDB(data: any): UploadRecord {
+154 -32
View File
@@ -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<void>[] = [];
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) => {
+40 -2
View File
@@ -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);
}
}
}
+15 -66
View File
@@ -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<void>((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 (
<div className="bg-gray-2 h-vh flex flex-col">
<ContentInput
onSend={startUpload}
files={files}
text={text}
onFilesChange={setFiles}
onTextChange={setText}
/>
<SimpleProgressBar progress={progress} />
<div className="flex-1 overflow-auto">
<ContentInput />
<SimpleProgressBar />
<div className="flex-1 withScrollbar">
<UploadRecords />
</div>
<PasswordInput />
@@ -75,12 +18,18 @@ const App = () => {
);
};
function SimpleProgressBar(props: { progress: number }) {
return (
<div className="bg-gray-3 h-2 rounded overflow-hidden">
<div className="bg-brand-6 h-full" style={{ width: `${props.progress}%` }} />
function SimpleProgressBar() {
const [progress] = useAtom(uploadingProgressAtom)
const [error] = useAtom(uploadingErrorAtom)
return (<>
{!!error && <div className="bg-red-1 p-2 text-red-6 text-sm">
{error}
</div>}
<div className="bg-gray-3 h-2 overflow-hidden">
<div className="bg-brand-6 h-full" style={{ width: `${progress}%` }} />
</div>
);
</>);
}
export default App;
+77
View File
@@ -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<void>((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);
});
}
+38 -65
View File
@@ -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<any>;
}
export const ContentInput = memo<Props>((props) => {
const [files, setFiles] = useState<File[]>(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<HTMLTextAreaElement>(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>((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>((props) => {
};
}, [isDragOver]);
const handleTextChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const handleTextChange = useConsistCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
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 (
<div
tabIndex={-1}
className="p-4 outline-0"
className="outline-0 max-h-[30vh] withScrollbar"
onKeyDown={(e) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
handleSend();
@@ -187,11 +157,15 @@ export const ContentInput = memo<Props>((props) => {
Add file
</button>
{files.map((file, index) => <AttachedFileItem file={file} index={index} removeFile={removeFile} key={index} />)}
<button onClick={doClear} className='btn-gray' key='clearBtn'>
Clear
</button>
{files.map((file) => <AttachedFileItem
key={file.id}
file={file}
removeFile={handleRemoveFile}
/>)}
</div>
{!!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<string>();
useEffect(() => {
const url = URL.createObjectURL(file);
const url = URL.createObjectURL(file.blob);
setUrl(url);
return () => URL.revokeObjectURL(url);
}, [file]);
return <div key={index} className="bg-brand-4 text-white rounded-md flex items-center text-sm overflow-hidden">
return <div className="btn !p-0 cursor-default">
{file.thumbnail ? (
<img src={file.thumbnail} className="w-[2em] h-[2em] rounded-md mx-1" />
) : (
@@ -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}</a>
<button
onClick={() => removeFile(index)}
onClick={() => removeFile(file.id)}
className="text-white hover:bg-red-400 transition-colors border-0 bg-transparent flex items-center self-stretch px-3"
>
<i className="i-mdi-close"></i>
@@ -253,4 +227,3 @@ export async function addThumbnail(file: File) {
return true;
}
+33
View File
@@ -0,0 +1,33 @@
import { memo, useEffect, useRef, useState } from "react";
export const PopoverConfirm = memo(({ children, onConfirm }: { children: React.ReactNode, onConfirm: () => void }) => {
const [visible, setVisible] = useState(false);
const confirmBtnRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
const el = visible && confirmBtnRef.current;
if (!el) return;
const onBlur = () => {
setVisible(false);
}
el.addEventListener('blur', onBlur);
el.focus();
return () => el.removeEventListener('blur', onBlur);
}, [visible])
return (
<div className="relative">
<div onClick={() => setVisible(true)}>{children}</div>
{
visible && (<div className="absolute right-0 bottom-full z-10 rounded shadow p-4 bg-white flex gap-2 b-2 b-solid b-red-9 mb-4">
<button className="btn-gray" onClick={() => setVisible(false)}>No</button>
<button className="btn" onClick={onConfirm} ref={confirmBtnRef}>Yes</button>
<div className="b-8 b-solid b-transparent b-t-red-9 absolute bottom-[-16px] right-4 w-0 h-0"></div>
</div>)
}
</div>
)
})
+15 -12
View File
@@ -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>((props) => {
}, [mutate]);
return (
<div className="p-4">
<div>
{error && <div className="text-red-500 mb-4">Error: {error.message}</div>}
{data?.map((page, i) => (
<div key={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 = <div className="flex gap-2 p-2 text-xs text-gray">
const meta = <div className="flex gap-2 mb-2 text-xs text-gray">
<span>
<i className="i-mdi-user mr-1"></i>
{props.record.uploader}
@@ -100,20 +101,23 @@ const UploadRecordItem = memo((props: { record: UploadRecord }) => {
)
}
<a className={`${actionLink} hover:text-red`} onClick={(e) => (e.preventDefault(), deleteRecord(props.record.id))} href='#' role='button'>
<i className="i-mdi-trash mr-1"></i>
Delete
</a>
</div>
<PopoverConfirm onConfirm={() => deleteRecord(props.record.id)}>
<a
className={`${actionLink} hover:text-red`}
onClick={(e) => e.preventDefault()} href='#' role='button'>
<i className="i-mdi-trash mr-1"></i>
Delete
</a>
</PopoverConfirm>
</div>
return (
<div className="rounded-lg bg-white shadow mb-2">
<div className='max-h-sm overflow-auto'>
<div className='max-h-sm withScrollbar'>
{meta}
{!!props.record.message && <pre className="ws-pre-wrap m-0 p-4 py-2 text-sm">{props.record.message}</pre>}
{!!props.record.message && <pre className="ws-pre-wrap m-0 mb-2 text-sm">{props.record.message}</pre>}
{files.length > 0 && (
<div className="flex flex-wrap m-2 mt-0">
<div className="flex flex-wrap ml--2 mb--2">
{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: {
+51
View File
@@ -0,0 +1,51 @@
import { createPromise } from "../utils/promise";
export const enum TableName {
KV = 'kv',
Files = 'files'
}
let dbPromise: Promise<IDBDatabase> | undefined;
export async function connect(force?: boolean) {
if (dbPromise && !force) return dbPromise;
return dbPromise = new Promise<IDBDatabase>((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));
};
});
}
+106
View File
@@ -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<FileStoreItem> {
const item: Omit<FileStoreItem, 'id'> = {
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<FileStoreItem>) {
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;
+65
View File
@@ -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<K extends KvKey>(key: K): Promise<KvValues[K]> {
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<K extends KvKey>(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<T> {
get(): Promise<T>;
set(value: T): Promise<void>;
setDebounced(value: T): void;
flush(): void | Promise<void>;
}
const KvStore = Object.fromEntries(
Object.keys(KvDefaults).map((key) => [key, createKvItem(key as KvKey)])
) as { [K in KvKey]: KvStoreItem<KvValues[K]> };
export default KvStore;
function createKvItem<K extends KvKey>(key: K): KvStoreItem<KvValues[K]> {
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,
};
}
+3 -3
View File
@@ -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;
});
+44 -14
View File
@@ -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<File[]>([]);
export const inputFilesAtom = atom<FileStoreItem[]>([]);
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, []);
}
-67
View File
@@ -1,67 +0,0 @@
let dbPromise: Promise<IDBDatabase> | 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<IDBDatabase>((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<T>(key: string, defaultValue: T): Promise<T> {
const db = await connect();
return new Promise<T>((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<T>(key: string, value: T) {
const db = await connect();
return new Promise<void>((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();
};
});
}
+49
View File
@@ -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<string | null>(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);
}
}
+8 -3
View File
@@ -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;
}
+32 -4
View File
@@ -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',
+26 -19
View File
@@ -1,24 +1,31 @@
export function debounce(fn: () => void, delay = 1000) {
/**
* make a debounced function. last args will apply
*/
export function debounce<T extends any[]>(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;
}
+2 -5
View File
@@ -1,12 +1,9 @@
export async function getFilesFromDataTransfer(dataTransfer?: DataTransfer | null): Promise<File[]> {
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;
}
+65
View File
@@ -0,0 +1,65 @@
export function getFormDataStream(formData: FormData, onReadProgress: (sent: number, total: number) => void): { size: number, boundary: string, stream: ReadableStream<Uint8Array> } {
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<Uint8Array> | undefined;
const stream = new ReadableStream<Uint8Array>({
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 };
}
+9
View File
@@ -0,0 +1,9 @@
export function createPromise<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: any) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}