feat(web-task): animate top feedback banner
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useLiveQuery } from "dexie-react-hooks";
|
||||
import { CheckCircle2, CircleAlert } from "lucide-react";
|
||||
import { TaskRichEditor } from "@/components/task-rich-editor";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -37,6 +38,11 @@ type TaskFormState = {
|
||||
ddlInput: string;
|
||||
};
|
||||
|
||||
type FeedbackNotice = {
|
||||
message: string;
|
||||
tone: "success" | "error";
|
||||
};
|
||||
|
||||
const DEFAULT_FORM_STATE: TaskFormState = {
|
||||
title: "",
|
||||
contentJson: null,
|
||||
@@ -124,7 +130,8 @@ export function TodoShellPage({ session }: TodoShellPageProps) {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [feedback, setFeedback] = useState<string | null>(null);
|
||||
const [feedback, setFeedback] = useState<FeedbackNotice | null>(null);
|
||||
const [feedbackVisible, setFeedbackVisible] = useState(false);
|
||||
const [draftReadyTaskId, setDraftReadyTaskId] = useState<string | null>(null);
|
||||
const savedTaskSnapshotRef = useRef(serializeFormState(DEFAULT_FORM_STATE));
|
||||
|
||||
@@ -236,6 +243,66 @@ export function TodoShellPage({ session }: TodoShellPageProps) {
|
||||
void persistDraft();
|
||||
}, [draftReadyTaskId, formState, selectedTask, selectedTaskId, userId]);
|
||||
|
||||
const showFeedback = useCallback((message: string, tone: FeedbackNotice["tone"]): void => {
|
||||
setFeedback({ message, tone });
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!feedback) {
|
||||
setFeedbackVisible(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setFeedbackVisible(false);
|
||||
const enterAnimationId = window.requestAnimationFrame(() => {
|
||||
setFeedbackVisible(true);
|
||||
});
|
||||
|
||||
const visibleDuration = feedback.tone === "success" ? 2200 : 3200;
|
||||
const hideTimeoutId = window.setTimeout(() => {
|
||||
setFeedbackVisible(false);
|
||||
}, visibleDuration);
|
||||
|
||||
const cleanupTimeoutId = window.setTimeout(() => {
|
||||
setFeedback((currentFeedback) =>
|
||||
currentFeedback?.message === feedback.message ? null : currentFeedback
|
||||
);
|
||||
}, visibleDuration + 260);
|
||||
|
||||
return () => {
|
||||
window.cancelAnimationFrame(enterAnimationId);
|
||||
window.clearTimeout(hideTimeoutId);
|
||||
window.clearTimeout(cleanupTimeoutId);
|
||||
};
|
||||
}, [feedback]);
|
||||
|
||||
function renderFeedbackBanner() {
|
||||
if (!feedback) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none fixed inset-x-0 top-0 z-50 flex justify-center px-4 pt-4">
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-w-[240px] max-w-[520px] items-center gap-3 rounded-2xl border px-4 py-3 shadow-[0_18px_50px_-24px_hsl(var(--foreground)/0.35)] backdrop-blur transition-all duration-300 ease-out",
|
||||
feedbackVisible ? "translate-y-0 opacity-100" : "-translate-y-6 opacity-0",
|
||||
feedback.tone === "success"
|
||||
? "border-emerald-200 bg-emerald-50/95 text-emerald-900"
|
||||
: "border-destructive/30 bg-background/95 text-foreground"
|
||||
)}
|
||||
>
|
||||
{feedback.tone === "success" ? (
|
||||
<CheckCircle2 className="h-5 w-5 shrink-0 text-emerald-600" />
|
||||
) : (
|
||||
<CircleAlert className="h-5 w-5 shrink-0 text-destructive" />
|
||||
)}
|
||||
<p className="text-sm font-medium">{feedback.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleCreateTask = useCallback(async (): Promise<void> => {
|
||||
if (creating || !userId) {
|
||||
return;
|
||||
@@ -245,11 +312,11 @@ export function TodoShellPage({ session }: TodoShellPageProps) {
|
||||
setCreating(true);
|
||||
const createdTask = await createLocalTask({ userId });
|
||||
setSelectedTaskId(createdTask.id);
|
||||
setFeedback("已创建新任务。");
|
||||
showFeedback("已创建新任务。", "success");
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
}, [creating, userId]);
|
||||
}, [creating, showFeedback, userId]);
|
||||
|
||||
const handleSaveTask = useCallback(async (): Promise<void> => {
|
||||
if (!selectedTaskId || saving) {
|
||||
@@ -269,17 +336,17 @@ export function TodoShellPage({ session }: TodoShellPageProps) {
|
||||
});
|
||||
|
||||
if (!updatedTask) {
|
||||
setFeedback("任务不存在或已被删除。");
|
||||
showFeedback("任务不存在或已被删除。", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
savedTaskSnapshotRef.current = serializeFormState(createFormStateFromTask(updatedTask));
|
||||
await deleteLocalTaskDraft(selectedTaskId);
|
||||
setFeedback("任务已保存。");
|
||||
showFeedback("任务已保存。", "success");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [formState, saving, selectedTaskId]);
|
||||
}, [formState, saving, selectedTaskId, showFeedback]);
|
||||
|
||||
const handleDeleteTask = useCallback(async (): Promise<void> => {
|
||||
if (!selectedTaskId || deleting) {
|
||||
@@ -290,16 +357,16 @@ export function TodoShellPage({ session }: TodoShellPageProps) {
|
||||
setDeleting(true);
|
||||
const deleted = await deleteLocalTask(selectedTaskId);
|
||||
if (!deleted) {
|
||||
setFeedback("任务已不存在。");
|
||||
showFeedback("任务已不存在。", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
await deleteLocalTaskDraft(selectedTaskId);
|
||||
setFeedback("任务已删除。");
|
||||
showFeedback("任务已删除。", "success");
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
}, [deleting, selectedTaskId]);
|
||||
}, [deleting, selectedTaskId, showFeedback]);
|
||||
|
||||
useEffect(() => {
|
||||
function handleKeydown(event: KeyboardEvent): void {
|
||||
@@ -326,15 +393,20 @@ export function TodoShellPage({ session }: TodoShellPageProps) {
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
<>
|
||||
{renderFeedbackBanner()}
|
||||
<div className="rounded-2xl border border-border bg-card/90 p-6 text-sm text-muted-foreground">
|
||||
当前未建立登录会话,请先完成登录。
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const taskList = tasks ?? [];
|
||||
|
||||
return (
|
||||
<>
|
||||
{renderFeedbackBanner()}
|
||||
<div className="grid gap-4 lg:grid-cols-[320px_minmax(0,1fr)]">
|
||||
<section className="rounded-2xl border border-border bg-card/90 p-4 shadow-[0_24px_70px_-42px_hsl(var(--primary)/0.6)] backdrop-blur">
|
||||
<div className="mb-3 flex items-center justify-between gap-2">
|
||||
@@ -358,7 +430,8 @@ export function TodoShellPage({ session }: TodoShellPageProps) {
|
||||
)}
|
||||
>
|
||||
空间占用(估算):{formatStorageSize(quotaSnapshot.usedBytes)} /{" "}
|
||||
{formatStorageSize(quotaSnapshot.quotaBytes)}({quotaSnapshot.usedPercent.toFixed(1)}%)
|
||||
{formatStorageSize(quotaSnapshot.quotaBytes)}({quotaSnapshot.usedPercent.toFixed(1)}
|
||||
%)
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
@@ -513,9 +586,8 @@ export function TodoShellPage({ session }: TodoShellPageProps) {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{feedback ? <p className="mt-3 text-xs text-primary">{feedback}</p> : null}
|
||||
</section>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user