feat(web-storage): implement media upload with quota hints
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
还没有任务,点击右上角“新建任务”。
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
Generated
+21
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user