f3e80f10ec
PluginShell over the kit router (flat routes, console deep-link bridge), standalone header only outside the iframe. Every subscription renders through a shared Gate (kit ResultGate + colocated PageSkeleton + retry error EmptyState). Overview: first-run hero, tone-coded StatCards, sync button that tracks engine state, warnings accordion, live EventFeed. Library: @container-responsive cover grid with whileInView stagger, include Switches saving gameOverrides immediately, skipped/excluded card with re-include. Sources: PathListEditor over draft.roots with platform Select + excludes globs. Emulators: detected roster with via/ contested/cores badges + re-detect, per-platform launch overrides. Settings: artwork/sync/files groups over resolved defaults, sticky SaveBar (motion slide-up on dirty; entry variants neutralized inside the object-form motion wrapper so the button isn't stuck at opacity 0). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
48 lines
1.7 KiB
TypeScript
48 lines
1.7 KiB
TypeScript
// The sticky save bar every draft-editing page shares: slides up when the draft goes
|
|
// dirty, pins to the viewport bottom, offers Save + Discard.
|
|
import { AnimatedButton, Button } from "@unom/ui/button";
|
|
import { Spinner } from "@unom/ui/spinner";
|
|
import { AnimatePresence, motion } from "motion/react";
|
|
|
|
export type SaveBarProps = {
|
|
readonly dirty: boolean;
|
|
readonly saving: boolean;
|
|
readonly onSave: () => void;
|
|
readonly onDiscard: () => void;
|
|
};
|
|
|
|
export const SaveBar = ({ dirty, saving, onSave, onDiscard }: SaveBarProps) => (
|
|
<AnimatePresence>
|
|
{dirty ? (
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 24 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
exit={{ opacity: 0, y: 24 }}
|
|
transition={{ duration: 0.3, ease: [0.165, 0.84, 0.44, 1] }}
|
|
className="sticky bottom-4 z-20 mt-6"
|
|
>
|
|
<div className="flex items-center justify-between gap-4 rounded-card border border-border bg-popover/90 px-5 py-3 shadow-lg backdrop-blur">
|
|
<span className="text-sm text-muted-foreground">Unsaved changes</span>
|
|
<div className="flex items-center gap-2">
|
|
<Button variant="ghost" onClick={onDiscard} disabled={saving}>
|
|
Discard
|
|
</Button>
|
|
{/* variants={{}} neutralizes the button's Section-stagger entry variants —
|
|
inside this object-form motion wrapper the `enter` label never
|
|
propagates, which would leave the button stuck at opacity 0. */}
|
|
<AnimatedButton
|
|
onClick={onSave}
|
|
disabled={saving}
|
|
variants={{}}
|
|
whileTap={{ scale: 0.97 }}
|
|
>
|
|
{saving ? <Spinner className="size-4" /> : null}
|
|
Save changes
|
|
</AnimatedButton>
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
) : null}
|
|
</AnimatePresence>
|
|
);
|