feat(web-storage): implement media upload with quota hints

This commit is contained in:
2026-04-05 17:30:39 +08:00
parent 60dbd1be9d
commit 5d71f3b527
5 changed files with 203 additions and 5 deletions
+1
View File
@@ -17,6 +17,7 @@
"@tiptap/extension-youtube": "^3.22.2",
"@tiptap/react": "^3.22.2",
"@tiptap/starter-kit": "^3.22.2",
"browser-image-compression": "^2.0.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dexie": "^4.4.2",
+103 -5
View File
@@ -1,12 +1,15 @@
import { useEffect, useMemo } from "react";
import type { JSONContent } from "@tiptap/react";
import { EditorContent, useEditor } from "@tiptap/react";
import { useEffect, useMemo, useRef, useState, type ChangeEvent } from "react";
import imageCompression from "browser-image-compression";
import Image from "@tiptap/extension-image";
import Link from "@tiptap/extension-link";
import StarterKit from "@tiptap/starter-kit";
import Youtube from "@tiptap/extension-youtube";
import { EditorContent, type JSONContent, useEditor } from "@tiptap/react";
import { cn } from "@/lib/utils";
const MAX_IMAGE_UPLOAD_BYTES = 20 * 1024 * 1024;
const MAX_VIDEO_UPLOAD_BYTES = 10 * 1024 * 1024;
type TaskRichEditorProps = {
valueJson: string | null;
textFallback: string;
@@ -54,7 +57,23 @@ function resolveEditorContent(
return textFallback;
}
function formatBytes(bytes: number): string {
if (bytes < 1024) {
return `${bytes} B`;
}
if (bytes < 1024 * 1024) {
return `${(bytes / 1024).toFixed(1)} KB`;
}
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
}
export function TaskRichEditor({ valueJson, textFallback, onChange }: TaskRichEditorProps) {
const [mediaHint, setMediaHint] = useState<string | null>(null);
const imageInputRef = useRef<HTMLInputElement | null>(null);
const videoInputRef = useRef<HTMLInputElement | null>(null);
const content = useMemo(
() => resolveEditorContent(valueJson, textFallback),
[valueJson, textFallback]
@@ -99,8 +118,76 @@ export function TaskRichEditor({ valueJson, textFallback, onChange }: TaskRichEd
editor.commands.setContent(content, { emitUpdate: false });
}, [content, editor]);
async function handleImageFileChange(event: ChangeEvent<HTMLInputElement>): Promise<void> {
const file = event.target.files?.[0];
event.target.value = "";
if (!file || !editor) {
return;
}
if (file.size > MAX_IMAGE_UPLOAD_BYTES) {
setMediaHint(`图片过大,请选择小于 ${formatBytes(MAX_IMAGE_UPLOAD_BYTES)} 的文件。`);
return;
}
try {
const compressedImage = await imageCompression(file, {
maxSizeMB: 1,
maxWidthOrHeight: 1920,
useWebWorker: true,
initialQuality: 0.8
});
const imageSource = await imageCompression.getDataUrlFromFile(compressedImage);
editor.chain().focus().setImage({ src: imageSource, alt: file.name }).run();
setMediaHint(
`图片已插入:${formatBytes(file.size)} -> ${formatBytes(compressedImage.size)}`
);
} catch {
setMediaHint("图片处理失败,请重试。");
}
}
function handleVideoFileChange(event: ChangeEvent<HTMLInputElement>): void {
const file = event.target.files?.[0];
event.target.value = "";
if (!file || !editor) {
return;
}
if (file.size > MAX_VIDEO_UPLOAD_BYTES) {
setMediaHint(`视频过大,请选择小于 ${formatBytes(MAX_VIDEO_UPLOAD_BYTES)} 的文件。`);
return;
}
editor
.chain()
.focus()
.insertContent(`\n[视频待上传] ${file.name}${formatBytes(file.size)}\n`)
.run();
setMediaHint("视频已通过大小校验并插入占位文本,正式上传接口将在后续接入。");
}
return (
<div>
<input
ref={imageInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleImageFileChange}
/>
<input
ref={videoInputRef}
type="file"
accept="video/*"
className="hidden"
onChange={handleVideoFileChange}
/>
<div className="flex flex-wrap gap-1 rounded-t-lg border border-input border-b-0 bg-muted/30 px-2 py-2">
<ToolbarButton
label="粗体"
@@ -144,7 +231,7 @@ export function TaskRichEditor({ valueJson, textFallback, onChange }: TaskRichEd
}}
/>
<ToolbarButton
label="图片"
label="图片URL"
disabled={!editor}
onClick={() => {
if (!editor) {
@@ -160,7 +247,12 @@ export function TaskRichEditor({ valueJson, textFallback, onChange }: TaskRichEd
}}
/>
<ToolbarButton
label="视频"
label="上传图片"
disabled={!editor}
onClick={() => imageInputRef.current?.click()}
/>
<ToolbarButton
label="视频URL"
disabled={!editor}
onClick={() => {
if (!editor) {
@@ -175,8 +267,14 @@ export function TaskRichEditor({ valueJson, textFallback, onChange }: TaskRichEd
editor.chain().focus().setYoutubeVideo({ src: url }).run();
}}
/>
<ToolbarButton
label="上传视频"
disabled={!editor}
onClick={() => videoInputRef.current?.click()}
/>
</div>
<EditorContent editor={editor} />
{mediaHint ? <p className="mt-2 text-xs text-muted-foreground">{mediaHint}</p> : null}
</div>
);
}
+21
View File
@@ -11,6 +11,7 @@ import {
listLocalTasksByUser,
updateLocalTask
} from "@/services/local-task-repo";
import { formatStorageSize, getStorageQuotaSnapshot } from "@/services/storage-quota";
import type { WebSession } from "@/services/session-storage";
type TodoShellPageProps = {
@@ -99,6 +100,14 @@ export function TodoShellPage({ session }: TodoShellPageProps) {
return listLocalTasksByUser(userId);
}, [userId]);
const quotaSnapshot = useLiveQuery(async () => {
if (!userId) {
return null;
}
return getStorageQuotaSnapshot(userId);
}, [userId]);
const selectedTask = useLiveQuery(async () => {
if (!selectedTaskId) {
return undefined;
@@ -228,6 +237,18 @@ export function TodoShellPage({ session }: TodoShellPageProps) {
</Button>
</div>
{quotaSnapshot ? (
<p
className={cn(
"mb-3 text-xs",
quotaSnapshot.usedPercent >= 85 ? "text-destructive" : "text-muted-foreground"
)}
>
{formatStorageSize(quotaSnapshot.usedBytes)} /{" "}
{formatStorageSize(quotaSnapshot.quotaBytes)}{quotaSnapshot.usedPercent.toFixed(1)}%
</p>
) : null}
{taskList.length === 0 ? (
<p className="rounded-xl border border-dashed border-border bg-muted/40 p-4 text-sm text-muted-foreground">
+57
View File
@@ -0,0 +1,57 @@
import { localDb } from "@/services/local-db";
export const DEFAULT_CLOUD_QUOTA_BYTES = 100 * 1024 * 1024;
type StorageQuotaSnapshot = {
usedBytes: number;
quotaBytes: number;
remainingBytes: number;
usedPercent: number;
};
function measureTextBytes(value: string | null): number {
if (!value) {
return 0;
}
return new Blob([value]).size;
}
export async function getStorageQuotaSnapshot(userId: string): Promise<StorageQuotaSnapshot> {
const tasks = await localDb.tasks.where("userId").equals(userId).toArray();
const usedBytes = tasks.reduce((total, task) => {
if (task.deletedAt !== null) {
return total;
}
return (
total +
measureTextBytes(task.title) +
measureTextBytes(task.contentText) +
measureTextBytes(task.contentJson)
);
}, 0);
const remainingBytes = Math.max(DEFAULT_CLOUD_QUOTA_BYTES - usedBytes, 0);
const usedPercent = Math.min((usedBytes / DEFAULT_CLOUD_QUOTA_BYTES) * 100, 100);
return {
usedBytes,
quotaBytes: DEFAULT_CLOUD_QUOTA_BYTES,
remainingBytes,
usedPercent
};
}
export function formatStorageSize(bytes: number): string {
if (bytes < 1024) {
return `${bytes} B`;
}
if (bytes < 1024 * 1024) {
return `${(bytes / 1024).toFixed(1)} KB`;
}
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
}
+21
View File
@@ -164,6 +164,9 @@ importers:
"@tiptap/starter-kit":
specifier: ^3.22.2
version: 3.22.2
browser-image-compression:
specifier: ^2.0.2
version: 2.0.2
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@@ -3717,6 +3720,12 @@ packages:
}
engines: { node: ">=8" }
browser-image-compression@2.0.2:
resolution:
{
integrity: sha512-pBLlQyUf6yB8SmmngrcOw3EoS4RpQ1BcylI3T9Yqn7+4nrQTXJD4sJDe5ODnJdrvNMaio5OicFo75rDyJD2Ucw==
}
browserslist@4.28.2:
resolution:
{
@@ -8316,6 +8325,12 @@ packages:
}
engines: { node: ">= 0.4.0" }
uzip@0.20201231.0:
resolution:
{
integrity: sha512-OZeJfZP+R0z9D6TmBgLq2LHzSSptGMGDGigGiEe0pr8UBe/7fdflgHlHBNDASTXB5jnFuxHpNaJywSg8YFeGng==
}
v8-compile-cache-lib@3.0.1:
resolution:
{
@@ -11306,6 +11321,10 @@ snapshots:
dependencies:
fill-range: 7.1.1
browser-image-compression@2.0.2:
dependencies:
uzip: 0.20201231.0
browserslist@4.28.2:
dependencies:
baseline-browser-mapping: 2.10.14
@@ -14189,6 +14208,8 @@ snapshots:
utils-merge@1.0.1: {}
uzip@0.20201231.0: {}
v8-compile-cache-lib@3.0.1: {}
v8-to-istanbul@9.3.0: