Merge branch 'main' of github.com:lyonbot/cf-drop
This commit is contained in:
commit
bfa7160b44
21 files changed
+833
-292
No files matched your search
@@ -29,6 +29,7 @@ export interface RecordFileItem {
|
||||
size: number;
|
||||
path: string;
|
||||
thumbnail?: string;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
function fromDB(data: any): UploadRecord {
|
||||
|
||||
+154
-32
@@ -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
@@ -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
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
})
|
||||
@@ -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: {
|
||||
|
||||
@@ -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));
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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, []);
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Block a user