mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-10-03 18:23:47 +10:00
Audit every animation in the app and shared UI primitives against a frequency-first motion bar: keyboard and high-frequency actions no longer animate, remaining motion uses interruptible CSS transitions with shared easing tokens, and redundant animation code is removed. UI primitives (@reactive-resume/ui) - Dialog, alert dialog, popover and tooltip move from tw-animate keyframes to Base UI data-starting/ending-style transitions; menus, popovers and tooltips skip motion when opened from the keyboard (data-instant). - Dialog gains an `instant` prop; the command palette uses it. - Accordion animates its real panel height; caret rotates instead of swapping icons. - Menu backdrop blur moves onto the popup so it no longer snaps in after the fade; context menus and comboboxes fade only. - Sidebar collapse uses the strong ease-out curve and snaps on Cmd+B. - Toast, sheet, checkbox, tabs, toggle, inputs and message scroller get tokenised easing, correct transition properties and press feedback. - Tabs no longer squeeze a trigger narrower than its label. - Spinners keep spinning under prefers-reduced-motion. Web app - Remove the default route view transition and page-entrance slides. - Add EASE_OUT_STRONG for Motion; replace built-in "easeOut" everywhere. - Switch LazyMotion to domMax so layout and Reorder animations run. - Builder: consolidate 12 section list files into one ItemsSection, opacity-only list rows with popLayout, instant Cmd+0, faster dock zoom, crossfade that no longer dips, transform-based progress bars. - Dashboard: keep previous results while sorting/filtering, no empty-state flash, uncontrolled sidebar (no network round trip on collapse), calmer resume card tilt, no stacked hover wrappers. - Settings: drop entrance/stagger wrappers and ActionButton. - Agent: CSS marquee paused on hover; thread switches keep the layout. - Homepage: fix invalid transition declarations, CSS spotlight drift, scroll-hiding header without a JS spring, tokenised curves. - Theme switches change every color at once. - Remove SSR-only useIsClient guards from the SPA. Docs: rewrite the DESIGN.md animation section around the new tokens.
168 lines
5.8 KiB
TypeScript
168 lines
5.8 KiB
TypeScript
import type { DragEndEvent, DragStartEvent } from "@dnd-kit/core";
|
|
import type { ApplicationStatus } from "@reactive-resume/schema/applications/data";
|
|
import type { Application } from "../types";
|
|
import {
|
|
DndContext,
|
|
DragOverlay,
|
|
PointerSensor,
|
|
pointerWithin,
|
|
useDraggable,
|
|
useDroppable,
|
|
useSensor,
|
|
useSensors,
|
|
} from "@dnd-kit/core";
|
|
import { t } from "@lingui/core/macro";
|
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
import { useMemo, useState } from "react";
|
|
import { STAGES } from "@reactive-resume/schema/applications/data";
|
|
import { toast } from "@reactive-resume/ui/components/toast";
|
|
import { cn } from "@reactive-resume/utils/style";
|
|
import { orpc } from "@/libs/orpc/client";
|
|
import { applicationsListQueryKey } from "../queries";
|
|
import { ApplicationCard } from "./application-card";
|
|
|
|
type Props = {
|
|
applications: Application[];
|
|
onOpen: (application: Application) => void;
|
|
onEdit: (application: Application) => void;
|
|
};
|
|
|
|
export function ApplicationBoard({ applications, onOpen, onEdit }: Props) {
|
|
const queryClient = useQueryClient();
|
|
const [activeId, setActiveId] = useState<string | null>(null);
|
|
|
|
// A small activation distance so a click still opens the detail panel instead of starting a drag.
|
|
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } }));
|
|
|
|
const listKey = applicationsListQueryKey();
|
|
|
|
const move = useMutation(
|
|
orpc.applications.update.mutationOptions({
|
|
onMutate: async ({ id, status }) => {
|
|
await queryClient.cancelQueries({ queryKey: listKey });
|
|
const previous = queryClient.getQueryData<Application[]>(listKey);
|
|
queryClient.setQueryData<Application[]>(listKey, (rows) =>
|
|
(rows ?? []).map((row) => (row.id === id && status ? { ...row, status } : row)),
|
|
);
|
|
return { previous };
|
|
},
|
|
onError: (_error, _vars, context) => {
|
|
if (context?.previous) queryClient.setQueryData(listKey, context.previous);
|
|
toast.add({ type: "error", description: t`Couldn't move the application. Please try again.` });
|
|
},
|
|
onSettled: () => void queryClient.invalidateQueries({ queryKey: listKey }),
|
|
}),
|
|
);
|
|
|
|
const byStage = useMemo(() => {
|
|
const map = new Map<ApplicationStatus, Application[]>(STAGES.map((s) => [s.value, []]));
|
|
for (const app of applications) map.get(app.status)?.push(app);
|
|
return map;
|
|
}, [applications]);
|
|
|
|
const activeApp = activeId ? applications.find((a) => a.id === activeId) : null;
|
|
|
|
const onDragStart = (event: DragStartEvent) => setActiveId(String(event.active.id));
|
|
|
|
const onDragEnd = (event: DragEndEvent) => {
|
|
setActiveId(null);
|
|
const { active, over } = event;
|
|
if (!over) return;
|
|
const target = over.id as ApplicationStatus;
|
|
const app = applications.find((a) => a.id === active.id);
|
|
if (!app || app.status === target) return;
|
|
move.mutate({ id: app.id, status: target });
|
|
};
|
|
|
|
return (
|
|
<DndContext sensors={sensors} collisionDetection={pointerWithin} onDragStart={onDragStart} onDragEnd={onDragEnd}>
|
|
<div className="flex h-full min-h-0 gap-4 overflow-x-auto pb-4">
|
|
{STAGES.map((stage) => (
|
|
<Column
|
|
key={stage.value}
|
|
stage={stage}
|
|
applications={byStage.get(stage.value) ?? []}
|
|
onOpen={onOpen}
|
|
onEdit={onEdit}
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
{/* No drop animation: the optimistic move lands after an await, so the default would fly the card back. */}
|
|
<DragOverlay dropAnimation={null}>
|
|
{activeApp ? <ApplicationCard application={activeApp} dragging /> : null}
|
|
</DragOverlay>
|
|
</DndContext>
|
|
);
|
|
}
|
|
|
|
type ColumnProps = {
|
|
stage: (typeof STAGES)[number];
|
|
applications: Application[];
|
|
onOpen: (application: Application) => void;
|
|
onEdit: (application: Application) => void;
|
|
};
|
|
|
|
// Cap the cards rendered per column so a stage with hundreds of applications doesn't mount
|
|
// hundreds of draggable nodes at once. Users reveal the rest in batches. Server-side paging
|
|
// isn't needed here — the list payload is small; the cost is DOM/drag nodes.
|
|
const COLUMN_PAGE_SIZE = 50;
|
|
|
|
function Column({ stage, applications, onOpen, onEdit }: ColumnProps) {
|
|
const { setNodeRef, isOver } = useDroppable({ id: stage.value });
|
|
const [visible, setVisible] = useState(COLUMN_PAGE_SIZE);
|
|
|
|
const shown = applications.slice(0, visible);
|
|
const remaining = applications.length - shown.length;
|
|
|
|
return (
|
|
<div className="flex w-72 shrink-0 flex-col rounded-2xl border border-border bg-muted/30">
|
|
<div className="flex items-center gap-2 px-3.5 py-3">
|
|
<span className="size-2.5 rounded-sm" style={{ background: stage.color }} />
|
|
<span className="font-semibold text-sm tracking-tight">{stage.label}</span>
|
|
<span className="rounded-full bg-muted px-2 py-0.5 font-semibold text-muted-foreground text-xs">
|
|
{applications.length}
|
|
</span>
|
|
</div>
|
|
<div
|
|
ref={setNodeRef}
|
|
className={cn(
|
|
"flex min-h-24 flex-1 flex-col gap-2.5 overflow-y-auto px-2.5 pb-3 transition-colors",
|
|
isOver && "bg-muted/60",
|
|
)}
|
|
>
|
|
{shown.map((app) => (
|
|
<DraggableCard key={app.id} application={app} onOpen={() => onOpen(app)} onEdit={onEdit} />
|
|
))}
|
|
{remaining > 0 && (
|
|
<button
|
|
type="button"
|
|
onClick={() => setVisible((v) => v + COLUMN_PAGE_SIZE)}
|
|
className="rounded-lg border border-border border-dashed py-2 text-muted-foreground text-xs hover:bg-muted/60"
|
|
>
|
|
{t`Show ${Math.min(remaining, COLUMN_PAGE_SIZE)} more`}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function DraggableCard({
|
|
application,
|
|
onOpen,
|
|
onEdit,
|
|
}: {
|
|
application: Application;
|
|
onOpen: () => void;
|
|
onEdit: (application: Application) => void;
|
|
}) {
|
|
const { setNodeRef, attributes, listeners, isDragging } = useDraggable({ id: application.id });
|
|
|
|
return (
|
|
<div ref={setNodeRef} {...attributes} {...listeners} className={cn(isDragging && "opacity-30")}>
|
|
<ApplicationCard application={application} onClick={onOpen} onEdit={onEdit} />
|
|
</div>
|
|
);
|
|
}
|