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/extension-youtube": "^3.22.2",
|
||||||
"@tiptap/react": "^3.22.2",
|
"@tiptap/react": "^3.22.2",
|
||||||
"@tiptap/starter-kit": "^3.22.2",
|
"@tiptap/starter-kit": "^3.22.2",
|
||||||
|
"browser-image-compression": "^2.0.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dexie": "^4.4.2",
|
"dexie": "^4.4.2",
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import { useEffect, useMemo } from "react";
|
import { useEffect, useMemo, useRef, useState, type ChangeEvent } from "react";
|
||||||
import type { JSONContent } from "@tiptap/react";
|
import imageCompression from "browser-image-compression";
|
||||||
import { EditorContent, useEditor } from "@tiptap/react";
|
|
||||||
import Image from "@tiptap/extension-image";
|
import Image from "@tiptap/extension-image";
|
||||||
import Link from "@tiptap/extension-link";
|
import Link from "@tiptap/extension-link";
|
||||||
import StarterKit from "@tiptap/starter-kit";
|
import StarterKit from "@tiptap/starter-kit";
|
||||||
import Youtube from "@tiptap/extension-youtube";
|
import Youtube from "@tiptap/extension-youtube";
|
||||||
|
import { EditorContent, type JSONContent, useEditor } from "@tiptap/react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const MAX_IMAGE_UPLOAD_BYTES = 20 * 1024 * 1024;
|
||||||
|
const MAX_VIDEO_UPLOAD_BYTES = 10 * 1024 * 1024;
|
||||||
|
|
||||||
type TaskRichEditorProps = {
|
type TaskRichEditorProps = {
|
||||||
valueJson: string | null;
|
valueJson: string | null;
|
||||||
textFallback: string;
|
textFallback: string;
|
||||||
@@ -54,7 +57,23 @@ function resolveEditorContent(
|
|||||||
return textFallback;
|
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) {
|
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(
|
const content = useMemo(
|
||||||
() => resolveEditorContent(valueJson, textFallback),
|
() => resolveEditorContent(valueJson, textFallback),
|
||||||
[valueJson, textFallback]
|
[valueJson, textFallback]
|
||||||
@@ -99,8 +118,76 @@ export function TaskRichEditor({ valueJson, textFallback, onChange }: TaskRichEd
|
|||||||
editor.commands.setContent(content, { emitUpdate: false });
|
editor.commands.setContent(content, { emitUpdate: false });
|
||||||
}, [content, editor]);
|
}, [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 (
|
return (
|
||||||
<div>
|
<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">
|
<div className="flex flex-wrap gap-1 rounded-t-lg border border-input border-b-0 bg-muted/30 px-2 py-2">
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
label="粗体"
|
label="粗体"
|
||||||
@@ -144,7 +231,7 @@ export function TaskRichEditor({ valueJson, textFallback, onChange }: TaskRichEd
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
label="图片"
|
label="图片URL"
|
||||||
disabled={!editor}
|
disabled={!editor}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!editor) {
|
if (!editor) {
|
||||||
@@ -160,7 +247,12 @@ export function TaskRichEditor({ valueJson, textFallback, onChange }: TaskRichEd
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
label="视频"
|
label="上传图片"
|
||||||
|
disabled={!editor}
|
||||||
|
onClick={() => imageInputRef.current?.click()}
|
||||||
|
/>
|
||||||
|
<ToolbarButton
|
||||||
|
label="视频URL"
|
||||||
disabled={!editor}
|
disabled={!editor}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!editor) {
|
if (!editor) {
|
||||||
@@ -175,8 +267,14 @@ export function TaskRichEditor({ valueJson, textFallback, onChange }: TaskRichEd
|
|||||||
editor.chain().focus().setYoutubeVideo({ src: url }).run();
|
editor.chain().focus().setYoutubeVideo({ src: url }).run();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<ToolbarButton
|
||||||
|
label="上传视频"
|
||||||
|
disabled={!editor}
|
||||||
|
onClick={() => videoInputRef.current?.click()}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<EditorContent editor={editor} />
|
<EditorContent editor={editor} />
|
||||||
|
{mediaHint ? <p className="mt-2 text-xs text-muted-foreground">{mediaHint}</p> : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
listLocalTasksByUser,
|
listLocalTasksByUser,
|
||||||
updateLocalTask
|
updateLocalTask
|
||||||
} from "@/services/local-task-repo";
|
} from "@/services/local-task-repo";
|
||||||
|
import { formatStorageSize, getStorageQuotaSnapshot } from "@/services/storage-quota";
|
||||||
import type { WebSession } from "@/services/session-storage";
|
import type { WebSession } from "@/services/session-storage";
|
||||||
|
|
||||||
type TodoShellPageProps = {
|
type TodoShellPageProps = {
|
||||||
@@ -99,6 +100,14 @@ export function TodoShellPage({ session }: TodoShellPageProps) {
|
|||||||
return listLocalTasksByUser(userId);
|
return listLocalTasksByUser(userId);
|
||||||
}, [userId]);
|
}, [userId]);
|
||||||
|
|
||||||
|
const quotaSnapshot = useLiveQuery(async () => {
|
||||||
|
if (!userId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return getStorageQuotaSnapshot(userId);
|
||||||
|
}, [userId]);
|
||||||
|
|
||||||
const selectedTask = useLiveQuery(async () => {
|
const selectedTask = useLiveQuery(async () => {
|
||||||
if (!selectedTaskId) {
|
if (!selectedTaskId) {
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -228,6 +237,18 @@ export function TodoShellPage({ session }: TodoShellPageProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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 ? (
|
{taskList.length === 0 ? (
|
||||||
<p className="rounded-xl border border-dashed border-border bg-muted/40 p-4 text-sm text-muted-foreground">
|
<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":
|
"@tiptap/starter-kit":
|
||||||
specifier: ^3.22.2
|
specifier: ^3.22.2
|
||||||
version: 3.22.2
|
version: 3.22.2
|
||||||
|
browser-image-compression:
|
||||||
|
specifier: ^2.0.2
|
||||||
|
version: 2.0.2
|
||||||
class-variance-authority:
|
class-variance-authority:
|
||||||
specifier: ^0.7.1
|
specifier: ^0.7.1
|
||||||
version: 0.7.1
|
version: 0.7.1
|
||||||
@@ -3717,6 +3720,12 @@ packages:
|
|||||||
}
|
}
|
||||||
engines: { node: ">=8" }
|
engines: { node: ">=8" }
|
||||||
|
|
||||||
|
browser-image-compression@2.0.2:
|
||||||
|
resolution:
|
||||||
|
{
|
||||||
|
integrity: sha512-pBLlQyUf6yB8SmmngrcOw3EoS4RpQ1BcylI3T9Yqn7+4nrQTXJD4sJDe5ODnJdrvNMaio5OicFo75rDyJD2Ucw==
|
||||||
|
}
|
||||||
|
|
||||||
browserslist@4.28.2:
|
browserslist@4.28.2:
|
||||||
resolution:
|
resolution:
|
||||||
{
|
{
|
||||||
@@ -8316,6 +8325,12 @@ packages:
|
|||||||
}
|
}
|
||||||
engines: { node: ">= 0.4.0" }
|
engines: { node: ">= 0.4.0" }
|
||||||
|
|
||||||
|
uzip@0.20201231.0:
|
||||||
|
resolution:
|
||||||
|
{
|
||||||
|
integrity: sha512-OZeJfZP+R0z9D6TmBgLq2LHzSSptGMGDGigGiEe0pr8UBe/7fdflgHlHBNDASTXB5jnFuxHpNaJywSg8YFeGng==
|
||||||
|
}
|
||||||
|
|
||||||
v8-compile-cache-lib@3.0.1:
|
v8-compile-cache-lib@3.0.1:
|
||||||
resolution:
|
resolution:
|
||||||
{
|
{
|
||||||
@@ -11306,6 +11321,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
fill-range: 7.1.1
|
fill-range: 7.1.1
|
||||||
|
|
||||||
|
browser-image-compression@2.0.2:
|
||||||
|
dependencies:
|
||||||
|
uzip: 0.20201231.0
|
||||||
|
|
||||||
browserslist@4.28.2:
|
browserslist@4.28.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
baseline-browser-mapping: 2.10.14
|
baseline-browser-mapping: 2.10.14
|
||||||
@@ -14189,6 +14208,8 @@ snapshots:
|
|||||||
|
|
||||||
utils-merge@1.0.1: {}
|
utils-merge@1.0.1: {}
|
||||||
|
|
||||||
|
uzip@0.20201231.0: {}
|
||||||
|
|
||||||
v8-compile-cache-lib@3.0.1: {}
|
v8-compile-cache-lib@3.0.1: {}
|
||||||
|
|
||||||
v8-to-istanbul@9.3.0:
|
v8-to-istanbul@9.3.0:
|
||||||
|
|||||||
Reference in New Issue
Block a user