feat(web-task): animate top feedback banner

This commit is contained in:
2026-04-06 00:11:22 +08:00
parent 73e0f1312c
commit a2d1840e47
+249 -177
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useLiveQuery } from "dexie-react-hooks"; import { useLiveQuery } from "dexie-react-hooks";
import { CheckCircle2, CircleAlert } from "lucide-react";
import { TaskRichEditor } from "@/components/task-rich-editor"; import { TaskRichEditor } from "@/components/task-rich-editor";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -37,6 +38,11 @@ type TaskFormState = {
ddlInput: string; ddlInput: string;
}; };
type FeedbackNotice = {
message: string;
tone: "success" | "error";
};
const DEFAULT_FORM_STATE: TaskFormState = { const DEFAULT_FORM_STATE: TaskFormState = {
title: "", title: "",
contentJson: null, contentJson: null,
@@ -124,7 +130,8 @@ export function TodoShellPage({ session }: TodoShellPageProps) {
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
const [deleting, setDeleting] = 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 [draftReadyTaskId, setDraftReadyTaskId] = useState<string | null>(null);
const savedTaskSnapshotRef = useRef(serializeFormState(DEFAULT_FORM_STATE)); const savedTaskSnapshotRef = useRef(serializeFormState(DEFAULT_FORM_STATE));
@@ -236,6 +243,66 @@ export function TodoShellPage({ session }: TodoShellPageProps) {
void persistDraft(); void persistDraft();
}, [draftReadyTaskId, formState, selectedTask, selectedTaskId, userId]); }, [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> => { const handleCreateTask = useCallback(async (): Promise<void> => {
if (creating || !userId) { if (creating || !userId) {
return; return;
@@ -245,11 +312,11 @@ export function TodoShellPage({ session }: TodoShellPageProps) {
setCreating(true); setCreating(true);
const createdTask = await createLocalTask({ userId }); const createdTask = await createLocalTask({ userId });
setSelectedTaskId(createdTask.id); setSelectedTaskId(createdTask.id);
setFeedback("已创建新任务。"); showFeedback("已创建新任务。", "success");
} finally { } finally {
setCreating(false); setCreating(false);
} }
}, [creating, userId]); }, [creating, showFeedback, userId]);
const handleSaveTask = useCallback(async (): Promise<void> => { const handleSaveTask = useCallback(async (): Promise<void> => {
if (!selectedTaskId || saving) { if (!selectedTaskId || saving) {
@@ -269,17 +336,17 @@ export function TodoShellPage({ session }: TodoShellPageProps) {
}); });
if (!updatedTask) { if (!updatedTask) {
setFeedback("任务不存在或已被删除。"); showFeedback("任务不存在或已被删除。", "error");
return; return;
} }
savedTaskSnapshotRef.current = serializeFormState(createFormStateFromTask(updatedTask)); savedTaskSnapshotRef.current = serializeFormState(createFormStateFromTask(updatedTask));
await deleteLocalTaskDraft(selectedTaskId); await deleteLocalTaskDraft(selectedTaskId);
setFeedback("任务已保存。"); showFeedback("任务已保存。", "success");
} finally { } finally {
setSaving(false); setSaving(false);
} }
}, [formState, saving, selectedTaskId]); }, [formState, saving, selectedTaskId, showFeedback]);
const handleDeleteTask = useCallback(async (): Promise<void> => { const handleDeleteTask = useCallback(async (): Promise<void> => {
if (!selectedTaskId || deleting) { if (!selectedTaskId || deleting) {
@@ -290,16 +357,16 @@ export function TodoShellPage({ session }: TodoShellPageProps) {
setDeleting(true); setDeleting(true);
const deleted = await deleteLocalTask(selectedTaskId); const deleted = await deleteLocalTask(selectedTaskId);
if (!deleted) { if (!deleted) {
setFeedback("任务已不存在。"); showFeedback("任务已不存在。", "error");
return; return;
} }
await deleteLocalTaskDraft(selectedTaskId); await deleteLocalTaskDraft(selectedTaskId);
setFeedback("任务已删除。"); showFeedback("任务已删除。", "success");
} finally { } finally {
setDeleting(false); setDeleting(false);
} }
}, [deleting, selectedTaskId]); }, [deleting, selectedTaskId, showFeedback]);
useEffect(() => { useEffect(() => {
function handleKeydown(event: KeyboardEvent): void { function handleKeydown(event: KeyboardEvent): void {
@@ -326,196 +393,201 @@ export function TodoShellPage({ session }: TodoShellPageProps) {
if (!session) { if (!session) {
return ( return (
<div className="rounded-2xl border border-border bg-card/90 p-6 text-sm text-muted-foreground"> <>
{renderFeedbackBanner()}
</div> <div className="rounded-2xl border border-border bg-card/90 p-6 text-sm text-muted-foreground">
</div>
</>
); );
} }
const taskList = tasks ?? []; const taskList = tasks ?? [];
return ( return (
<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"> {renderFeedbackBanner()}
<div className="mb-3 flex items-center justify-between gap-2"> <div className="grid gap-4 lg:grid-cols-[320px_minmax(0,1fr)]">
<h2 className="text-base font-semibold text-foreground"></h2> <section className="rounded-2xl border border-border bg-card/90 p-4 shadow-[0_24px_70px_-42px_hsl(var(--primary)/0.6)] backdrop-blur">
<Button <div className="mb-3 flex items-center justify-between gap-2">
type="button" <h2 className="text-base font-semibold text-foreground"></h2>
size="sm"
className="bg-primary text-primary-foreground hover:bg-primary/90"
onClick={handleCreateTask}
disabled={creating}
>
{creating ? "创建中..." : "新建任务"}
</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">
</p>
) : (
<div className="space-y-2">
{taskList.map((task) => {
const isActive = task.id === selectedTaskId;
return (
<button
key={task.id}
type="button"
className={cn(
"w-full rounded-xl border px-3 py-2 text-left transition-colors",
isActive
? "border-primary/45 bg-primary/10"
: "border-border bg-background hover:border-primary/25 hover:bg-primary/5"
)}
onClick={() => setSelectedTaskId(task.id)}
>
<p className="truncate text-sm font-medium text-foreground">{task.title}</p>
<p className="mt-1 text-xs text-muted-foreground">
{task.status} · {task.priority} · {formatUpdatedAt(task.updatedAt)}
</p>
</button>
);
})}
</div>
)}
</section>
<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-4 flex flex-wrap items-center justify-between gap-2">
<h2 className="text-base font-semibold text-foreground"></h2>
<div className="flex items-center gap-2">
<Button <Button
type="button" type="button"
variant="outline" size="sm"
onClick={handleSaveTask} className="bg-primary text-primary-foreground hover:bg-primary/90"
disabled={!selectedTaskId || saving} onClick={handleCreateTask}
disabled={creating}
> >
{saving ? "保存中..." : "保存"} {creating ? "创建中..." : "新建任务"}
</Button>
<Button
type="button"
variant="outline"
className="border-destructive/50 text-destructive hover:bg-destructive/10"
onClick={handleDeleteTask}
disabled={!selectedTaskId || deleting}
>
{deleting ? "删除中..." : "删除"}
</Button> </Button>
</div> </div>
</div>
{!selectedTaskId || !selectedTask ? ( {quotaSnapshot ? (
<p className="rounded-xl border border-dashed border-border bg-muted/40 p-4 text-sm text-muted-foreground"> <p
className={cn(
</p> "mb-3 text-xs",
) : ( quotaSnapshot.usedPercent >= 85 ? "text-destructive" : "text-muted-foreground"
<div className="space-y-3"> )}
<label className="block text-sm text-muted-foreground"> >
{formatStorageSize(quotaSnapshot.usedBytes)} /{" "}
<input {formatStorageSize(quotaSnapshot.quotaBytes)}{quotaSnapshot.usedPercent.toFixed(1)}
className="mt-1 w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground outline-none focus:border-primary focus:ring-2 focus:ring-ring/30" %
value={formState.title} </p>
onChange={(event) => ) : null}
setFormState((previous) => ({
...previous,
title: event.target.value
}))
}
placeholder="请输入任务标题"
/>
</label>
<div className="grid gap-3 sm:grid-cols-2"> {taskList.length === 0 ? (
<label className="block text-sm text-muted-foreground"> <p className="rounded-xl border border-dashed border-border bg-muted/40 p-4 text-sm text-muted-foreground">
<select </p>
className="mt-1 w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground outline-none focus:border-primary focus:ring-2 focus:ring-ring/30" ) : (
value={formState.status} <div className="space-y-2">
onChange={(event) => {taskList.map((task) => {
setFormState((previous) => ({ const isActive = task.id === selectedTaskId;
...previous, return (
status: event.target.value as LocalTaskStatus <button
})) key={task.id}
} type="button"
> className={cn(
{STATUS_OPTIONS.map((option) => ( "w-full rounded-xl border px-3 py-2 text-left transition-colors",
<option key={option.value} value={option.value}> isActive
{option.label} ? "border-primary/45 bg-primary/10"
</option> : "border-border bg-background hover:border-primary/25 hover:bg-primary/5"
))} )}
</select> onClick={() => setSelectedTaskId(task.id)}
</label> >
<p className="truncate text-sm font-medium text-foreground">{task.title}</p>
<label className="block text-sm text-muted-foreground"> <p className="mt-1 text-xs text-muted-foreground">
{task.status} · {task.priority} · {formatUpdatedAt(task.updatedAt)}
<select </p>
className="mt-1 w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground outline-none focus:border-primary focus:ring-2 focus:ring-ring/30" </button>
value={formState.priority} );
onChange={(event) => })}
setFormState((previous) => ({
...previous,
priority: event.target.value as LocalTaskPriority
}))
}
>
{PRIORITY_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
</div> </div>
)}
</section>
<label className="block text-sm text-muted-foreground"> <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-4 flex flex-wrap items-center justify-between gap-2">
<input <h2 className="text-base font-semibold text-foreground"></h2>
type="datetime-local" <div className="flex items-center gap-2">
className="mt-1 w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground outline-none focus:border-primary focus:ring-2 focus:ring-ring/30" <Button
value={formState.ddlInput} type="button"
onChange={(event) => variant="outline"
setFormState((previous) => ({ onClick={handleSaveTask}
...previous, disabled={!selectedTaskId || saving}
ddlInput: event.target.value >
})) {saving ? "保存中..." : "保存"}
} </Button>
/> <Button
</label> type="button"
variant="outline"
className="border-destructive/50 text-destructive hover:bg-destructive/10"
onClick={handleDeleteTask}
disabled={!selectedTaskId || deleting}
>
{deleting ? "删除中..." : "删除"}
</Button>
</div>
</div>
<div className="block text-sm text-muted-foreground"> {!selectedTaskId || !selectedTask ? (
<p></p> <p className="rounded-xl border border-dashed border-border bg-muted/40 p-4 text-sm text-muted-foreground">
<div className="mt-1">
<TaskRichEditor </p>
valueJson={formState.contentJson} ) : (
textFallback={formState.contentText} <div className="space-y-3">
onChange={(payload) => <label className="block text-sm text-muted-foreground">
<input
className="mt-1 w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground outline-none focus:border-primary focus:ring-2 focus:ring-ring/30"
value={formState.title}
onChange={(event) =>
setFormState((previous) => ({ setFormState((previous) => ({
...previous, ...previous,
contentJson: payload.json, title: event.target.value
contentText: payload.text }))
}
placeholder="请输入任务标题"
/>
</label>
<div className="grid gap-3 sm:grid-cols-2">
<label className="block text-sm text-muted-foreground">
<select
className="mt-1 w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground outline-none focus:border-primary focus:ring-2 focus:ring-ring/30"
value={formState.status}
onChange={(event) =>
setFormState((previous) => ({
...previous,
status: event.target.value as LocalTaskStatus
}))
}
>
{STATUS_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<label className="block text-sm text-muted-foreground">
<select
className="mt-1 w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground outline-none focus:border-primary focus:ring-2 focus:ring-ring/30"
value={formState.priority}
onChange={(event) =>
setFormState((previous) => ({
...previous,
priority: event.target.value as LocalTaskPriority
}))
}
>
{PRIORITY_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
</div>
<label className="block text-sm text-muted-foreground">
<input
type="datetime-local"
className="mt-1 w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground outline-none focus:border-primary focus:ring-2 focus:ring-ring/30"
value={formState.ddlInput}
onChange={(event) =>
setFormState((previous) => ({
...previous,
ddlInput: event.target.value
})) }))
} }
/> />
</label>
<div className="block text-sm text-muted-foreground">
<p></p>
<div className="mt-1">
<TaskRichEditor
valueJson={formState.contentJson}
textFallback={formState.contentText}
onChange={(payload) =>
setFormState((previous) => ({
...previous,
contentJson: payload.json,
contentText: payload.text
}))
}
/>
</div>
</div> </div>
</div> </div>
</div> )}
)} </section>
</div>
{feedback ? <p className="mt-3 text-xs text-primary">{feedback}</p> : null} </>
</section>
</div>
); );
} }