feat(frontend): content micro-animations — stagger, button press, toast polish
- animate-stagger utility + staggerDelay() helper; lists now rise in sequence (recordings grid, moderation rows, message list, media queue, dashboard tiles). - Button gets a subtle active:scale-[0.97] press feedback. - Toaster: toast-in slide-up, tone-accent border, rounded hover-close target. - All motion is reduced-motion aware (killed under prefers-reduced-motion).
This commit is contained in:
@@ -29,6 +29,7 @@ import {
|
||||
} from "@/hooks";
|
||||
import { formatNumber } from "@/lib/format";
|
||||
import type { DashboardStats } from "@/lib/types";
|
||||
import { staggerDelay } from "@/lib/utils";
|
||||
|
||||
function deriveSignal(stats?: DashboardStats) {
|
||||
if (!stats) return { tone: "signal" as const, label: "nominal" };
|
||||
@@ -113,23 +114,31 @@ export function DashboardView({
|
||||
value={formatNumber(s.total_messages)}
|
||||
tone="signal"
|
||||
icon={<MessageSquare className="size-3.5" />}
|
||||
className="animate-stagger"
|
||||
style={staggerDelay(0)}
|
||||
/>
|
||||
<MetricTile
|
||||
label="Flagged"
|
||||
value={formatNumber(s.total_flagged)}
|
||||
tone={s.total_flagged > 0 ? "vermilion" : "neutral"}
|
||||
hint={`${s.today_flagged} today`}
|
||||
className="animate-stagger"
|
||||
style={staggerDelay(1)}
|
||||
/>
|
||||
<MetricTile
|
||||
label="Active 24h"
|
||||
value={formatNumber(s.active_users_24h)}
|
||||
tone="signal"
|
||||
icon={<Users className="size-3.5" />}
|
||||
className="animate-stagger"
|
||||
style={staggerDelay(2)}
|
||||
/>
|
||||
<MetricTile
|
||||
label="Voice clips"
|
||||
value={formatNumber(s.total_voice_recordings)}
|
||||
icon={<Mic className="size-3.5" />}
|
||||
className="animate-stagger"
|
||||
style={staggerDelay(3)}
|
||||
/>
|
||||
</div>
|
||||
</GlassPanel>
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
} from "@/hooks";
|
||||
import { formatDuration } from "@/lib/format";
|
||||
import type { MediaState } from "@/lib/types";
|
||||
import { staggerDelay } from "@/lib/utils";
|
||||
import { useWebSocket } from "@/lib/ws/context";
|
||||
|
||||
export function MediaView({ initialStatus }: { initialStatus?: MediaState }) {
|
||||
@@ -205,7 +206,8 @@ export function MediaView({ initialStatus }: { initialStatus?: MediaState }) {
|
||||
{queueList.map((item, i) => (
|
||||
<div
|
||||
key={`${item.source}-${i}`}
|
||||
className="flex items-center gap-3 rounded-[10px] border border-hairline bg-white/5 px-3 py-2.5"
|
||||
className="animate-stagger flex items-center gap-3 rounded-[10px] border border-hairline bg-white/5 px-3 py-2.5"
|
||||
style={staggerDelay(i)}
|
||||
>
|
||||
<span className="mono w-5 text-ink-faint">{i + 1}</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
safeParseJsonArray,
|
||||
} from "@/lib/format";
|
||||
import type { AiStatus, Guild, MessageRecord } from "@/lib/types";
|
||||
import { staggerDelay } from "@/lib/utils";
|
||||
import { useWebSocket } from "@/lib/ws/context";
|
||||
|
||||
export function MessagesView({
|
||||
@@ -254,16 +255,17 @@ export function MessagesView({
|
||||
}
|
||||
}}
|
||||
>
|
||||
{display.map((m) => (
|
||||
{display.map((m, i) => (
|
||||
<button
|
||||
key={m.id}
|
||||
type="button"
|
||||
onClick={() => setSelected(m.id)}
|
||||
className={`flex w-full items-start gap-3 rounded-[12px] border p-3 text-left transition-colors ${
|
||||
className={`animate-stagger flex w-full items-start gap-3 rounded-[12px] border p-3 text-left transition-colors ${
|
||||
selected === m.id
|
||||
? "border-signal/40 bg-signal/8"
|
||||
: "border-hairline bg-white/[0.03] hover:bg-white/[0.06]"
|
||||
}`}
|
||||
style={staggerDelay(i)}
|
||||
>
|
||||
<Avatar src={m.avatar_url} name={m.username} size={34} />
|
||||
<div className="min-w-0 flex-1">
|
||||
|
||||
@@ -37,6 +37,7 @@ import type {
|
||||
ModerationActionType,
|
||||
ModerationStats,
|
||||
} from "@/lib/types";
|
||||
import { staggerDelay } from "@/lib/utils";
|
||||
|
||||
const ACTION_ICON: Record<ModerationActionType, React.ReactNode> = {
|
||||
delete_message: <Trash2 className="size-3.5" />,
|
||||
@@ -217,8 +218,8 @@ export function ModerationView({
|
||||
}
|
||||
/>
|
||||
<div className="max-h-[60vh] space-y-1.5 overflow-y-auto pr-1">
|
||||
{(actions ?? []).map((a) => (
|
||||
<ActionRow key={a.id} a={a} />
|
||||
{(actions ?? []).map((a, i) => (
|
||||
<ActionRow key={a.id} a={a} index={i} />
|
||||
))}
|
||||
{(actions ?? []).length === 0 && (
|
||||
<div className="py-10 text-center text-xs text-ink-faint">
|
||||
@@ -232,7 +233,7 @@ export function ModerationView({
|
||||
);
|
||||
}
|
||||
|
||||
function ActionRow({ a }: { a: ModerationAction }) {
|
||||
function ActionRow({ a, index = 0 }: { a: ModerationAction; index?: number }) {
|
||||
const tone =
|
||||
a.status === "executed"
|
||||
? "signal"
|
||||
@@ -243,7 +244,10 @@ function ActionRow({ a }: { a: ModerationAction }) {
|
||||
<AlertTriangle className="size-3.5" />
|
||||
);
|
||||
return (
|
||||
<div className="flex items-start gap-3 rounded-[10px] border border-hairline bg-white/[0.03] p-3">
|
||||
<div
|
||||
className="animate-stagger flex items-start gap-3 rounded-[10px] border border-hairline bg-white/[0.03] p-3"
|
||||
style={staggerDelay(index)}
|
||||
>
|
||||
<span
|
||||
className={`mt-0.5 ${tone === "vermilion" ? "text-vermilion" : tone === "amber" ? "text-amber" : "text-signal"}`}
|
||||
>
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from "@/hooks";
|
||||
import { formatBytes, formatRelativeTime } from "@/lib/format";
|
||||
import type { VoiceRecording } from "@/lib/types";
|
||||
import { staggerDelay } from "@/lib/utils";
|
||||
import { useWebSocket } from "@/lib/ws/context";
|
||||
|
||||
export function RecordingsView({
|
||||
@@ -94,12 +95,13 @@ export function RecordingsView({
|
||||
/>
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{(items ?? []).map((r) => {
|
||||
{(items ?? []).map((r, i) => {
|
||||
const up = uploadStatus(r);
|
||||
return (
|
||||
<GlassCard
|
||||
key={r.id}
|
||||
className="flex flex-col gap-3 transition-colors hover:bg-white/[0.06]"
|
||||
className="animate-stagger flex flex-col gap-3 transition-colors hover:bg-white/[0.06]"
|
||||
style={staggerDelay(i)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar src={r.avatar_url} name={r.username} size={38} />
|
||||
|
||||
Reference in New Issue
Block a user