From 1e7f23a7253f6ef98e6ea868937b9879b5893ceb Mon Sep 17 00:00:00 2001 From: lyonbot Date: Wed, 22 Jan 2025 14:36:14 +0800 Subject: [PATCH 1/4] refactor: use isolated indexedDB table to store files --- web/src/App.tsx | 18 ++--- web/src/components/ContentInput.tsx | 85 +++++++--------------- web/src/database/connect.ts | 51 +++++++++++++ web/src/database/files.ts | 106 ++++++++++++++++++++++++++++ web/src/database/kv.ts | 65 +++++++++++++++++ web/src/store/auth.ts | 6 +- web/src/store/input.ts | 58 +++++++++++---- web/src/store/kvdb.ts | 67 ------------------ web/src/sw-client.tsx | 11 ++- web/src/sw.ts | 29 +++++++- web/src/utils/debounce.ts | 45 +++++++----- web/src/utils/fileEntry.ts | 7 +- web/src/utils/promise.ts | 9 +++ 13 files changed, 374 insertions(+), 183 deletions(-) create mode 100644 web/src/database/connect.ts create mode 100644 web/src/database/files.ts create mode 100644 web/src/database/kv.ts delete mode 100644 web/src/store/kvdb.ts create mode 100644 web/src/utils/promise.ts diff --git a/web/src/App.tsx b/web/src/App.tsx index 3f90bc1..cb1e346 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -6,18 +6,18 @@ 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 { clearFiles, inputFilesAtom, inputTextAtom } from './store/input'; const App = () => { const [progress, setProgress] = useState(0); const [text, setText] = useAtom(inputTextAtom); - const [files, setFiles] = useAtom(inputFilesAtom); + const [files] = useAtom(inputFilesAtom); - function startUpload(text: string, files: File[]) { + function startUpload() { 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('files', file.blob)); files.forEach((file) => body.append('thumbnails', file.thumbnail || '')); const xhr = new XMLHttpRequest(); @@ -40,7 +40,7 @@ const App = () => { setProgress(0); window.dispatchEvent(new Event('records-updated')); setText(''); - setFiles([]); + clearFiles(); resolve(); return; } @@ -59,13 +59,7 @@ const App = () => { return (
- +
diff --git a/web/src/components/ContentInput.tsx b/web/src/components/ContentInput.tsx index f8cecbc..d647c4d 100644 --- a/web/src/components/ContentInput.tsx +++ b/web/src/components/ContentInput.tsx @@ -1,51 +1,25 @@ 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'; interface Props { - text?: string; - files?: File[]; - onTextChange?: (text: string) => void; - onFilesChange?: (files: File[]) => void; - onSend?: (text: string, files: File[]) => Promise; + onSend?: () => Promise; } export const ContentInput = memo((props) => { - const [files, setFiles] = useState(props.files?.slice() ?? []); - const [text, setText] = useState(props.text ?? ''); + 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 +81,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,40 +93,34 @@ 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) + props.onSend?.() }); 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 (
((props) => { Add file - {files.map((file, index) => )} + {files.map((file) => )} + + {files.map((file) => )} - -
{!!isDragOver && ( @@ -194,7 +194,7 @@ function AttachedFileItem({ file, removeFile }: { file: FileStoreItem; removeFil return () => URL.revokeObjectURL(url); }, [file]); - return
+ return
{file.thumbnail ? ( ) : ( diff --git a/web/src/components/PopoverConfirm.tsx b/web/src/components/PopoverConfirm.tsx new file mode 100644 index 0000000..e2369f2 --- /dev/null +++ b/web/src/components/PopoverConfirm.tsx @@ -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(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 ( +
+
setVisible(true)}>{children}
+ + { + visible && (
+ + +
+
) + } +
+ ) +}) \ No newline at end of file diff --git a/web/src/components/UploadRecords.tsx b/web/src/components/UploadRecords.tsx index 7130ab1..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); @@ -100,11 +101,15 @@ 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 (
@@ -176,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: {