mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-09-29 08:14:25 +10:00
feat(web): refine motion system and simplify animated UI (#3546)
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.
This commit is contained in:
@@ -226,21 +226,24 @@ A custom Tailwind token `--aspect-page: 210 / 297` enforces A4 paper proportions
|
||||
|
||||
## Animation
|
||||
|
||||
Animations use the Motion library (formerly Framer Motion) and follow a consistent choreography pattern:
|
||||
Motion exists to explain a change, confirm an action, or soften a jump. This is a tool people use for hours, so it stays crisp: short, precise, rarely decorative.
|
||||
|
||||
**Entrance animations** use a fade-up reveal: elements start at `opacity: 0, y: 20-100` and animate to `opacity: 1, y: 0`. The hero section uses a larger y-offset (100px) for dramatic effect; subsequent sections use 20px for subtlety.
|
||||
**Frequency decides first.** Keyboard-initiated actions (the command palette, ⌘B sidebar toggle, zoom shortcuts, keyboard-opened menus via Base UI's `data-instant`) do not animate. Things hit tens of times a day (list rows, tooltips after the first, context menus) get opacity-only or no motion. Dialogs, sheets and toasts get a standard transition. Only rare moments (marketing pages, first load) get more.
|
||||
|
||||
**Timing principles:**
|
||||
- **Base duration:** 0.35s–0.6s for standard section reveals, 0.45s for hero elements, up to 1.1s for the hero video entrance.
|
||||
- **Stagger pattern:** Sequential delays within a group, typically 0.1s–0.15s apart (hero: 0.55s, 0.7s, 0.82s, 0.95s). For grids, use `index * 0.03`–`0.1` for per-item stagger.
|
||||
- **Easing:** `easeOut` for entrances (elements decelerate into position). `easeInOut` for looping/ambient animations.
|
||||
- **Performance:** Apply `will-change-[transform,opacity]` on animated elements and `will-change-transform` on continuously animated elements.
|
||||
**Tokens.** Never hand-type a curve.
|
||||
- CSS: `ease-out-strong` / `var(--ease-out-strong)` (`cubic-bezier(0.23, 1, 0.32, 1)`) for anything entering, exiting or responding; `ease-in-out-strong` for on-screen movement nobody is waiting on (ambient loops, carousels); `ease-drawer` for sheets. Never `ease-in`.
|
||||
- Motion (JS): `EASE_OUT_STRONG` from `apps/web/src/libs/motion.ts`.
|
||||
|
||||
**Hover/interaction animations** are quick (0.2s) and subtle — small scale bumps (`scale: 1.01`), slight y-offsets (`y: -2`), and `active:translate-y-px` for button press.
|
||||
**Durations.** Press feedback 100–160ms, tooltips/popovers/menus 150ms in and 100ms out, dialogs 200ms in and 150ms out, sheets 300ms. App UI stays under 300ms; marketing reveals may run 0.5–0.9s. Exits are faster than entrances.
|
||||
|
||||
**Ambient animations** loop infinitely with `easeInOut` — the scroll indicator bounces gently (`y: [0, 5, 0]` over 1.5s).
|
||||
**Mechanics.**
|
||||
- Popups use interruptible CSS transitions on Base UI's `data-starting-style` / `data-ending-style`, scale from `0.95` (never `0`) and grow from `origin-(--transform-origin)`. Modals stay centred.
|
||||
- Animate `transform`/`translate`/`scale` and `opacity` only. No `transition-all`, no permanent `will-change` (Motion promotes layers while it animates).
|
||||
- Presses use `active:scale-[0.97]`. `Button` already has it; don't wrap it in Motion hover/tap wrappers.
|
||||
- Lists use `AnimatePresence initial={false}` so items animate when added or removed, not every time the list mounts.
|
||||
- Continuous loops (marquees, drifting spotlights) are CSS keyframes, so they run off the main thread.
|
||||
|
||||
**Reduced motion:** All CSS transitions and animations collapse to `0.01ms` duration and single iteration when `prefers-reduced-motion: reduce` is active. Motion library animations should also respect this preference.
|
||||
**Reduced motion:** `MotionConfig reducedMotion="user"` disables Motion transforms, and CSS transitions and animations collapse to `0.01ms` — except `animate-spin`, which keeps spinning so loading never looks frozen. Values driven by `useSpring`/`useMotionValue` bypass `MotionConfig`, so check `useReducedMotion()` there.
|
||||
|
||||
## Elevation & Depth
|
||||
|
||||
|
||||
@@ -37,20 +37,20 @@ describe("CometCard", () => {
|
||||
expect(glare?.className).toContain("pointer-events-none");
|
||||
});
|
||||
|
||||
it("does not throw when mouse enters / moves over / leaves the card", () => {
|
||||
it("does not throw when pointer moves over / leaves the card", () => {
|
||||
const { container } = render(
|
||||
<CometCard>
|
||||
<span>x</span>
|
||||
</CometCard>,
|
||||
);
|
||||
|
||||
const tiltable = container.querySelector("[class*='will-change-transform']") as HTMLElement;
|
||||
const tiltable = container.querySelector("[class*='rounded-md']") as HTMLElement;
|
||||
expect(tiltable).toBeTruthy();
|
||||
|
||||
expect(() => {
|
||||
fireEvent.mouseMove(tiltable, { clientX: 100, clientY: 50 });
|
||||
fireEvent.mouseMove(tiltable, { clientX: 0, clientY: 0 });
|
||||
fireEvent.mouseLeave(tiltable);
|
||||
fireEvent.pointerMove(tiltable, { clientX: 100, clientY: 50, pointerType: "mouse" });
|
||||
fireEvent.pointerMove(tiltable, { clientX: 0, clientY: 0, pointerType: "mouse" });
|
||||
fireEvent.pointerLeave(tiltable);
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,63 +1,41 @@
|
||||
import type React from "react";
|
||||
import { m, useMotionTemplate, useMotionValue, useSpring, useTransform } from "motion/react";
|
||||
import { useRef } from "react";
|
||||
import { m, useMotionTemplate, useMotionValue, useReducedMotion, useSpring, useTransform } from "motion/react";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { EASE_OUT_STRONG } from "@/libs/motion";
|
||||
|
||||
type Props = {
|
||||
rotateDepth?: number;
|
||||
translateDepth?: number;
|
||||
glareOpacity?: number;
|
||||
scaleFactor?: number;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export const CometCard = ({
|
||||
rotateDepth = 17.5,
|
||||
translateDepth = 20,
|
||||
glareOpacity = 0.4,
|
||||
scaleFactor = 1.05,
|
||||
className,
|
||||
children,
|
||||
}: Props) => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
// Critically damped: the tilt follows the pointer without wobbling past it.
|
||||
const tiltSpring = { stiffness: 300, damping: 30 };
|
||||
|
||||
const x = useMotionValue(0);
|
||||
const y = useMotionValue(0);
|
||||
export const CometCard = ({ glareOpacity = 0.4, className, children }: Props) => {
|
||||
const reduceMotion = useReducedMotion();
|
||||
|
||||
const mouseXSpring = useSpring(x);
|
||||
const mouseYSpring = useSpring(y);
|
||||
const x = useSpring(useMotionValue(0), tiltSpring);
|
||||
const y = useSpring(useMotionValue(0), tiltSpring);
|
||||
|
||||
const rotateX = useTransform(mouseYSpring, [-0.5, 0.5], [`-${rotateDepth}deg`, `${rotateDepth}deg`]);
|
||||
const rotateY = useTransform(mouseXSpring, [-0.5, 0.5], [`${rotateDepth}deg`, `-${rotateDepth}deg`]);
|
||||
|
||||
const translateX = useTransform(mouseXSpring, [-0.5, 0.5], [`-${translateDepth}px`, `${translateDepth}px`]);
|
||||
const translateY = useTransform(mouseYSpring, [-0.5, 0.5], [`${translateDepth}px`, `-${translateDepth}px`]);
|
||||
|
||||
const glareX = useTransform(mouseXSpring, [-0.5, 0.5], [0, 100]);
|
||||
const glareY = useTransform(mouseYSpring, [-0.5, 0.5], [0, 100]);
|
||||
const rotateX = useTransform(y, [-0.5, 0.5], ["-6deg", "6deg"]);
|
||||
const rotateY = useTransform(x, [-0.5, 0.5], ["6deg", "-6deg"]);
|
||||
const translateX = useTransform(x, [-0.5, 0.5], ["-3px", "3px"]);
|
||||
const translateY = useTransform(y, [-0.5, 0.5], ["3px", "-3px"]);
|
||||
|
||||
const glareX = useTransform(x, [-0.5, 0.5], [0, 100]);
|
||||
const glareY = useTransform(y, [-0.5, 0.5], [0, 100]);
|
||||
const glareBackground = useMotionTemplate`radial-gradient(circle at ${glareX}% ${glareY}%, rgba(255, 255, 255, 0.9) 10%, rgba(255, 255, 255, 0.75) 20%, rgba(255, 255, 255, 0) 80%)`;
|
||||
|
||||
const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!ref.current) return;
|
||||
|
||||
const rect = ref.current.getBoundingClientRect();
|
||||
|
||||
const width = rect.width;
|
||||
const height = rect.height;
|
||||
|
||||
const mouseX = e.clientX - rect.left;
|
||||
const mouseY = e.clientY - rect.top;
|
||||
|
||||
const xPct = mouseX / width - 0.5;
|
||||
const yPct = mouseY / height - 0.5;
|
||||
|
||||
x.set(xPct);
|
||||
y.set(yPct);
|
||||
// Touch taps emit synthetic pointer moves that would leave the card stuck mid-tilt, so only follow a real mouse.
|
||||
const handlePointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (e.pointerType !== "mouse" || reduceMotion) return;
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
x.set((e.clientX - rect.left) / rect.width - 0.5);
|
||||
y.set((e.clientY - rect.top) / rect.height - 0.5);
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
const handlePointerLeave = () => {
|
||||
x.set(0);
|
||||
y.set(0);
|
||||
};
|
||||
@@ -65,21 +43,20 @@ export const CometCard = ({
|
||||
return (
|
||||
<div className={cn("perspective-distant transform-3d", className)}>
|
||||
<m.div
|
||||
ref={ref}
|
||||
initial={{ scale: 1, z: 0 }}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
className="relative rounded-md will-change-transform"
|
||||
whileHover={{ z: 50, scale: scaleFactor, transition: { duration: 0.2 } }}
|
||||
style={{ rotateX: rotateX, rotateY: rotateY, translateX: translateX, translateY: translateY }}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerLeave={handlePointerLeave}
|
||||
className="relative rounded-md"
|
||||
whileHover={{ z: 20, scale: 1.02, transition: { duration: 0.2, ease: EASE_OUT_STRONG } }}
|
||||
style={{ rotateX, rotateY, translateX, translateY }}
|
||||
>
|
||||
{children}
|
||||
|
||||
<m.div
|
||||
transition={{ duration: 0.2 }}
|
||||
style={{ background: glareBackground, opacity: glareOpacity }}
|
||||
className="pointer-events-none absolute inset-0 z-50 h-full w-full rounded-md mix-blend-overlay will-change-[opacity]"
|
||||
/>
|
||||
{glareOpacity > 0 && (
|
||||
<m.div
|
||||
style={{ background: glareBackground, opacity: glareOpacity }}
|
||||
className="pointer-events-none absolute inset-0 z-50 size-full rounded-md mix-blend-overlay"
|
||||
/>
|
||||
)}
|
||||
</m.div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,46 +1,40 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { render } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { CountUp } from "./count-up";
|
||||
import { render, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { domAnimation, LazyMotion } from "motion/react";
|
||||
|
||||
const reducedMotion = vi.hoisted(() => ({ value: false }));
|
||||
|
||||
vi.mock("motion/react", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("motion/react")>()),
|
||||
useReducedMotion: () => reducedMotion.value,
|
||||
}));
|
||||
|
||||
const { CountUp } = await import("./count-up");
|
||||
|
||||
describe("CountUp", () => {
|
||||
it("renders an aria-live=polite span by default (announced to screen readers)", () => {
|
||||
const { container } = render(<CountUp to={1000} />);
|
||||
const span = container.querySelector("span") as HTMLSpanElement;
|
||||
expect(span.getAttribute("aria-live")).toBe("polite");
|
||||
expect(span.getAttribute("aria-atomic")).toBe("true");
|
||||
it("starts at 0 before animating", () => {
|
||||
reducedMotion.value = false;
|
||||
const { container } = render(<CountUp to={1234} />);
|
||||
expect(container.querySelector("span")?.textContent).toBe("0");
|
||||
});
|
||||
|
||||
it("seeds textContent to 0 on initial render", () => {
|
||||
const { container } = render(<CountUp to={100} />);
|
||||
const span = container.querySelector("span") as HTMLSpanElement;
|
||||
expect(span.textContent).toBe("0");
|
||||
it("jumps to the final grouped value under reduced motion", async () => {
|
||||
reducedMotion.value = true;
|
||||
const { container } = render(
|
||||
<LazyMotion features={domAnimation}>
|
||||
<CountUp to={1234} />
|
||||
</LazyMotion>,
|
||||
);
|
||||
await waitFor(() => expect(container.querySelector("span")?.textContent).toBe("1,234"));
|
||||
});
|
||||
|
||||
it("formats with the separator when one is supplied", () => {
|
||||
const { container } = render(<CountUp to={1234} separator="," />);
|
||||
const span = container.querySelector("span") as HTMLSpanElement;
|
||||
expect(span.textContent).toBe("0");
|
||||
});
|
||||
|
||||
it("preserves decimal places when to is fractional", () => {
|
||||
const { container } = render(<CountUp to={3.75} />);
|
||||
const span = container.querySelector("span") as HTMLSpanElement;
|
||||
expect(span.textContent).toBe("0.00");
|
||||
});
|
||||
|
||||
it("strips aria-live and aria-atomic when aria-hidden is set", () => {
|
||||
const { container } = render(<CountUp to={100} aria-hidden="true" />);
|
||||
const span = container.querySelector("span") as HTMLSpanElement;
|
||||
expect(span.getAttribute("aria-hidden")).toBe("true");
|
||||
expect(span.getAttribute("aria-live")).toBeNull();
|
||||
expect(span.getAttribute("aria-atomic")).toBeNull();
|
||||
});
|
||||
|
||||
it("accepts a custom className", () => {
|
||||
const { container } = render(<CountUp to={100} className="custom-class" />);
|
||||
it("forwards className and aria-hidden", () => {
|
||||
reducedMotion.value = false;
|
||||
const { container } = render(<CountUp to={100} className="custom-class" aria-hidden="true" />);
|
||||
const span = container.querySelector("span") as HTMLSpanElement;
|
||||
expect(span.className).toContain("custom-class");
|
||||
expect(span.getAttribute("aria-hidden")).toBe("true");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,84 +1,28 @@
|
||||
import { useInView, useMotionValue, useSpring } from "motion/react";
|
||||
import { useCallback, useEffect, useEffectEvent, useRef } from "react";
|
||||
import { m, useInView, useReducedMotion, useSpring, useTransform } from "motion/react";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
type CountUpProps = {
|
||||
to: number;
|
||||
duration?: number;
|
||||
className?: string;
|
||||
separator?: string;
|
||||
"aria-hidden"?: boolean | "true" | "false";
|
||||
"aria-live"?: "off" | "polite" | "assertive";
|
||||
"aria-atomic"?: boolean | "true" | "false";
|
||||
};
|
||||
|
||||
const getDecimalPlaces = (num: number): number => {
|
||||
const str = num.toString();
|
||||
if (str.includes(".")) {
|
||||
const decimals = str.split(".")[1];
|
||||
if (Number.parseInt(decimals, 10) !== 0) return decimals.length;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
// ponytail: from/direction/delay/startWhen/onStart/onEnd removed — no production caller passes them
|
||||
export function CountUp({
|
||||
to,
|
||||
duration = 2,
|
||||
className = "",
|
||||
separator = "",
|
||||
"aria-hidden": ariaHidden,
|
||||
"aria-live": ariaLive = "polite",
|
||||
"aria-atomic": ariaAtomic = "true",
|
||||
}: CountUpProps) {
|
||||
// Integer count-up with en-US grouping. Starts once when scrolled into view; reduced motion shows the final value.
|
||||
export function CountUp({ to, className, "aria-hidden": ariaHidden }: CountUpProps) {
|
||||
const ref = useRef<HTMLSpanElement>(null);
|
||||
const motionValue = useMotionValue(0);
|
||||
|
||||
const damping = 20 + 40 * (1 / duration);
|
||||
const stiffness = 100 * (1 / duration);
|
||||
|
||||
const springValue = useSpring(motionValue, { damping, stiffness });
|
||||
|
||||
const isInView = useInView(ref, { once: true, margin: "0px" });
|
||||
|
||||
const maxDecimals = getDecimalPlaces(to);
|
||||
|
||||
const formatValue = useCallback(
|
||||
(latest: number) => {
|
||||
const options: Intl.NumberFormatOptions = {
|
||||
useGrouping: !!separator,
|
||||
minimumFractionDigits: maxDecimals,
|
||||
maximumFractionDigits: maxDecimals,
|
||||
};
|
||||
const formattedNumber = Intl.NumberFormat("en-US", options).format(latest);
|
||||
return separator ? formattedNumber.replace(/,/g, separator) : formattedNumber;
|
||||
},
|
||||
[maxDecimals, separator],
|
||||
);
|
||||
|
||||
const formatCurrentValue = useEffectEvent((latest: number) => formatValue(latest));
|
||||
const isInView = useInView(ref, { once: true });
|
||||
const reducedMotion = useReducedMotion();
|
||||
const spring = useSpring(0, { visualDuration: 0.8, bounce: 0 });
|
||||
const text = useTransform(spring, (value) => Math.round(value).toLocaleString("en-US"));
|
||||
|
||||
useEffect(() => {
|
||||
if (ref.current) ref.current.textContent = formatCurrentValue(0);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isInView) motionValue.set(to);
|
||||
}, [isInView, motionValue, to]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = springValue.on("change", (latest: number) => {
|
||||
if (ref.current) ref.current.textContent = formatCurrentValue(latest);
|
||||
});
|
||||
return () => unsubscribe();
|
||||
}, [springValue]);
|
||||
if (reducedMotion) spring.jump(to);
|
||||
else if (isInView) spring.set(to);
|
||||
}, [isInView, reducedMotion, spring, to]);
|
||||
|
||||
return (
|
||||
<span
|
||||
ref={ref}
|
||||
className={className}
|
||||
aria-hidden={ariaHidden}
|
||||
aria-live={ariaHidden ? undefined : ariaLive}
|
||||
aria-atomic={ariaHidden ? undefined : ariaAtomic}
|
||||
/>
|
||||
<m.span ref={ref} className={className} aria-hidden={ariaHidden}>
|
||||
{text}
|
||||
</m.span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,28 +18,4 @@ describe("Spotlight", () => {
|
||||
const beamGroups = container.firstChild?.childNodes;
|
||||
expect(beamGroups?.length).toBe(2);
|
||||
});
|
||||
|
||||
it("applies the provided width / height / smallWidth to inline styles", () => {
|
||||
const { container } = render(<Spotlight width={500} height={800} smallWidth={120} translateY={-100} />);
|
||||
|
||||
const inlineStyles = Array.from(container.querySelectorAll<HTMLDivElement>("[style]")).map(
|
||||
(el) => el.getAttribute("style") ?? "",
|
||||
);
|
||||
const allStyles = inlineStyles.join("|");
|
||||
|
||||
expect(allStyles).toContain("width: 500px");
|
||||
expect(allStyles).toContain("height: 800px");
|
||||
expect(allStyles).toContain("width: 120px");
|
||||
expect(allStyles).toContain("translateY(-100px)");
|
||||
});
|
||||
|
||||
it("uses the supplied gradient strings as background values", () => {
|
||||
const customFirst = "radial-gradient(red, blue)";
|
||||
const { container } = render(<Spotlight gradientFirst={customFirst} />);
|
||||
|
||||
const matched = Array.from(container.querySelectorAll<HTMLDivElement>("[style]")).filter(
|
||||
(el) => el.style.background.includes("red") && el.style.background.includes("blue"),
|
||||
);
|
||||
expect(matched.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,106 +1,44 @@
|
||||
import { m } from "motion/react";
|
||||
const FIRST =
|
||||
"radial-gradient(68.54% 68.72% at 55.02% 31.46%, hsla(210, 100%, 85%, .08) 0, hsla(210, 100%, 55%, .02) 50%, hsla(210, 100%, 45%, 0) 80%)";
|
||||
const SECOND =
|
||||
"radial-gradient(50% 50% at 50% 50%, hsla(210, 100%, 85%, .06) 0, hsla(210, 100%, 55%, .02) 80%, transparent 100%)";
|
||||
const THIRD =
|
||||
"radial-gradient(50% 50% at 50% 50%, hsla(210, 100%, 85%, .04) 0, hsla(210, 100%, 45%, .02) 80%, transparent 100%)";
|
||||
|
||||
type SpotlightProps = {
|
||||
duration?: number;
|
||||
gradientFirst?: string;
|
||||
gradientSecond?: string;
|
||||
gradientThird?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
smallWidth?: number;
|
||||
translateY?: number;
|
||||
xOffset?: number;
|
||||
};
|
||||
// CSS keyframes run on the compositor, so the infinite drift never competes with the page's main thread.
|
||||
const drift =
|
||||
"pointer-events-none absolute top-0 z-40 h-svh w-svw animate-[spotlight-drift_7s_ease-in-out_infinite_alternate]";
|
||||
|
||||
export const Spotlight = ({
|
||||
duration = 7,
|
||||
gradientFirst = "radial-gradient(68.54% 68.72% at 55.02% 31.46%, hsla(210, 100%, 85%, .08) 0, hsla(210, 100%, 55%, .02) 50%, hsla(210, 100%, 45%, 0) 80%)",
|
||||
gradientSecond = "radial-gradient(50% 50% at 50% 50%, hsla(210, 100%, 85%, .06) 0, hsla(210, 100%, 55%, .02) 80%, transparent 100%)",
|
||||
gradientThird = "radial-gradient(50% 50% at 50% 50%, hsla(210, 100%, 85%, .04) 0, hsla(210, 100%, 45%, .02) 80%, transparent 100%)",
|
||||
width = 560,
|
||||
height = 1380,
|
||||
smallWidth = 240,
|
||||
translateY = -350,
|
||||
xOffset = 100,
|
||||
}: SpotlightProps) => {
|
||||
return (
|
||||
<m.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 1.5 }}
|
||||
className="pointer-events-none absolute inset-0 h-full w-full"
|
||||
>
|
||||
<m.div
|
||||
animate={{ x: [0, xOffset, 0] }}
|
||||
transition={{ duration, repeat: Number.POSITIVE_INFINITY, repeatType: "reverse", ease: "easeInOut" }}
|
||||
className="pointer-events-none absolute inset-s-0 top-0 z-40 h-svh w-svw will-change-transform"
|
||||
>
|
||||
<div
|
||||
className="absolute inset-s-0 top-0"
|
||||
style={{
|
||||
width: `${width}px`,
|
||||
height: `${height}px`,
|
||||
background: gradientFirst,
|
||||
transform: `translateY(${translateY}px) rotate(-45deg)`,
|
||||
}}
|
||||
/>
|
||||
export const Spotlight = () => (
|
||||
<div className="fade-in pointer-events-none absolute inset-0 size-full animate-in duration-1500 ease-out">
|
||||
<div className={`${drift} inset-s-0 [--spotlight-drift:100px]`}>
|
||||
<div
|
||||
className="absolute inset-s-0 top-0 h-[1380px] w-[560px]"
|
||||
style={{ background: FIRST, transform: "translateY(-350px) rotate(-45deg)" }}
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-s-0 top-0 h-[1380px] w-[240px] origin-top-left"
|
||||
style={{ background: SECOND, transform: "rotate(-45deg) translate(5%, -50%)" }}
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-s-0 top-0 h-[1380px] w-[240px] origin-top-left"
|
||||
style={{ background: THIRD, transform: "rotate(-45deg) translate(-180%, -70%)" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="absolute inset-s-0 top-0 origin-top-left"
|
||||
style={{
|
||||
height: `${height}px`,
|
||||
width: `${smallWidth}px`,
|
||||
background: gradientSecond,
|
||||
transform: "rotate(-45deg) translate(5%, -50%)",
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
className="absolute inset-s-0 top-0 origin-top-left"
|
||||
style={{
|
||||
height: `${height}px`,
|
||||
width: `${smallWidth}px`,
|
||||
background: gradientThird,
|
||||
transform: "rotate(-45deg) translate(-180%, -70%)",
|
||||
}}
|
||||
/>
|
||||
</m.div>
|
||||
|
||||
<m.div
|
||||
animate={{ x: [0, -xOffset, 0] }}
|
||||
transition={{ duration, repeat: Number.POSITIVE_INFINITY, ease: "easeInOut", repeatType: "reverse" }}
|
||||
className="pointer-events-none absolute inset-e-0 top-0 z-40 h-svh w-svw will-change-transform"
|
||||
>
|
||||
<div
|
||||
className="absolute inset-e-0 top-0"
|
||||
style={{
|
||||
width: `${width}px`,
|
||||
height: `${height}px`,
|
||||
background: gradientFirst,
|
||||
transform: `translateY(${translateY}px) rotate(45deg)`,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
className="absolute inset-e-0 top-0 origin-top-right"
|
||||
style={{
|
||||
height: `${height}px`,
|
||||
width: `${smallWidth}px`,
|
||||
background: gradientSecond,
|
||||
transform: "rotate(45deg) translate(-5%, -50%)",
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
className="absolute inset-e-0 top-0 origin-top-right"
|
||||
style={{
|
||||
height: `${height}px`,
|
||||
width: `${smallWidth}px`,
|
||||
background: gradientThird,
|
||||
transform: "rotate(45deg) translate(180%, -70%)",
|
||||
}}
|
||||
/>
|
||||
</m.div>
|
||||
</m.div>
|
||||
);
|
||||
};
|
||||
<div className={`${drift} inset-e-0 [--spotlight-drift:-100px]`}>
|
||||
<div
|
||||
className="absolute inset-e-0 top-0 h-[1380px] w-[560px]"
|
||||
style={{ background: FIRST, transform: "translateY(-350px) rotate(45deg)" }}
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-e-0 top-0 h-[1380px] w-[240px] origin-top-right"
|
||||
style={{ background: SECOND, transform: "rotate(45deg) translate(-5%, -50%)" }}
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-e-0 top-0 h-[1380px] w-[240px] origin-top-right"
|
||||
style={{ background: THIRD, transform: "rotate(45deg) translate(180%, -70%)" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -13,7 +13,6 @@ import { CSS } from "@dnd-kit/utilities";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { PencilSimpleIcon, XIcon } from "@phosphor-icons/react";
|
||||
import { AnimatePresence, m } from "motion/react";
|
||||
import * as React from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Badge } from "@reactive-resume/ui/components/badge";
|
||||
@@ -75,15 +74,10 @@ function ChipItem({ id, chip, index, isEditing, onEdit, onRemove }: ChipItemProp
|
||||
};
|
||||
|
||||
return (
|
||||
<m.div
|
||||
layout
|
||||
initial={{ opacity: 0, scale: 0.92, y: -4 }}
|
||||
animate={{ opacity: isDragging ? 0.62 : 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.92, y: -4 }}
|
||||
transition={{ duration: 0.1, ease: "easeOut" }}
|
||||
<div
|
||||
style={style}
|
||||
ref={setNodeRef}
|
||||
className="group/chip relative touch-none"
|
||||
className={cn("group/chip relative touch-none", isDragging && "opacity-60")}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
@@ -96,11 +90,11 @@ function ChipItem({ id, chip, index, isEditing, onEdit, onRemove }: ChipItemProp
|
||||
)}
|
||||
>
|
||||
<span className="max-w-32 truncate sm:max-w-44">{chip}</span>
|
||||
<m.div
|
||||
initial={false}
|
||||
animate={isEditing ? { opacity: 1 } : { opacity: 0.66 }}
|
||||
transition={{ duration: 0.12, ease: "easeOut" }}
|
||||
className="ms-1.5 flex shrink-0 items-center gap-x-0.5 will-change-[opacity] group-focus-within/chip:opacity-100 group-hover/chip:opacity-100"
|
||||
<div
|
||||
className={cn(
|
||||
"ms-1.5 flex shrink-0 items-center gap-x-0.5 transition-opacity duration-150 group-focus-within/chip:opacity-100 group-hover/chip:opacity-100",
|
||||
isEditing ? "opacity-100" : "opacity-65",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
@@ -134,9 +128,9 @@ function ChipItem({ id, chip, index, isEditing, onEdit, onRemove }: ChipItemProp
|
||||
>
|
||||
<XIcon className="size-3.5" />
|
||||
</button>
|
||||
</m.div>
|
||||
</div>
|
||||
</Badge>
|
||||
</m.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -357,21 +351,19 @@ export function ChipInput({
|
||||
className={cn("max-h-24 overflow-y-auto px-2 py-1.5", hasChips ? "border-border/70 border-b" : "hidden")}
|
||||
>
|
||||
<SortableContext items={chips} strategy={rectSortingStrategy}>
|
||||
<m.div layout className="flex flex-wrap gap-1">
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
{chips.map((chip, idx) => (
|
||||
<ChipItem
|
||||
key={chip}
|
||||
id={chip}
|
||||
chip={chip}
|
||||
index={idx}
|
||||
isEditing={editingIndex === idx}
|
||||
onEdit={handleEdit}
|
||||
onRemove={removeChip}
|
||||
/>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</m.div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{chips.map((chip, idx) => (
|
||||
<ChipItem
|
||||
key={chip}
|
||||
id={chip}
|
||||
chip={chip}
|
||||
index={idx}
|
||||
isEditing={editingIndex === idx}
|
||||
onEdit={handleEdit}
|
||||
onRemove={removeChip}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</div>
|
||||
<div className={cn("flex items-center gap-1.5 px-2", hasChips ? "py-1.5" : "py-0")}>
|
||||
@@ -393,28 +385,18 @@ export function ChipInput({
|
||||
onChange={handleInputChange}
|
||||
className="h-9 flex-1 border-none p-0 focus-visible:border-none focus-visible:ring-0 dark:bg-transparent"
|
||||
/>
|
||||
<AnimatePresence>
|
||||
{chips.length > 0 && (
|
||||
<m.span
|
||||
layout
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{
|
||||
opacity: isEditingKeyword ? 1 : 0.8,
|
||||
scale: 1,
|
||||
}}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ duration: 0.12, ease: "easeOut" }}
|
||||
className={cn(
|
||||
"flex h-6 min-w-6 shrink-0 items-center justify-center rounded-md border px-1.5 font-medium text-[0.7rem] tabular-nums",
|
||||
isEditingKeyword
|
||||
? "border-primary/30 bg-primary/10 text-primary"
|
||||
: "border-border bg-muted/50 text-foreground/80",
|
||||
)}
|
||||
>
|
||||
{isEditingKeyword ? <Trans>Edit</Trans> : chips.length}
|
||||
</m.span>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
{chips.length > 0 && (
|
||||
<span
|
||||
className={cn(
|
||||
"flex h-6 min-w-6 shrink-0 items-center justify-center rounded-md border px-1.5 font-medium text-[0.7rem] tabular-nums",
|
||||
isEditingKeyword
|
||||
? "border-primary/30 bg-primary/10 text-primary"
|
||||
: "border-border bg-muted/50 text-foreground/80 opacity-80",
|
||||
)}
|
||||
>
|
||||
{isEditingKeyword ? <Trans>Edit</Trans> : chips.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -65,7 +65,7 @@ export function ColorPicker({
|
||||
{trigger ?? (
|
||||
<PopoverTrigger>
|
||||
<div
|
||||
className="size-6 shrink-0 cursor-pointer rounded-full border border-foreground/60 transition-all hover:scale-105 focus-visible:outline-hidden"
|
||||
className="size-6 shrink-0 cursor-pointer rounded-full border border-foreground/60 transition-transform duration-150 ease-out-strong focus-visible:outline-hidden active:scale-[0.97]"
|
||||
style={{ backgroundColor: currentValue }}
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
@@ -95,7 +95,7 @@ export function ColorPicker({
|
||||
aria-pressed={currentValue === color}
|
||||
onClick={() => setCurrentValue(color)}
|
||||
className={cn(
|
||||
"size-5 shrink-0 cursor-pointer rounded-full transition-all hover:scale-105 focus-visible:outline-hidden",
|
||||
"size-5 shrink-0 cursor-pointer rounded-full transition-transform duration-150 ease-out-strong focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring active:scale-[0.97]",
|
||||
currentValue === color && "border border-foreground/60",
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -25,9 +25,7 @@ export function GithubStarsButton() {
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<GithubLogoIcon aria-hidden="true" />
|
||||
{starCount != null ? (
|
||||
<CountUp to={starCount} duration={0.5} separator="," className="font-bold" aria-hidden="true" />
|
||||
) : null}
|
||||
{starCount != null ? <CountUp to={starCount} className="font-bold" aria-hidden="true" /> : null}
|
||||
<StarIcon aria-hidden="true" />
|
||||
</a>
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ export function FontDisplay({ family, label, type, url }: FontDisplayProps) {
|
||||
<div ref={containerRef} className="inline-flex items-baseline gap-2">
|
||||
<span
|
||||
style={{ fontFamily: isLoaded ? `'${previewName}', sans-serif` : "sans-serif" }}
|
||||
className={cn(isLoaded ? "opacity-100" : "opacity-50", "transition-opacity duration-200 ease-in")}
|
||||
className={cn(isLoaded ? "opacity-100" : "opacity-50", "transition-opacity duration-150 ease-out-strong")}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
|
||||
@@ -15,6 +15,7 @@ import { RichInput } from "@/components/input/rich-input";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { useFormBlocker } from "@/hooks/use-form-blocker";
|
||||
import { EASE_OUT_STRONG } from "@/libs/motion";
|
||||
import { makeSectionItem } from "@/libs/resume/make-section-item";
|
||||
import { createSectionItem, updateSectionItem } from "@/libs/resume/section-actions";
|
||||
import { useAppForm, withForm } from "@/libs/tanstack-form";
|
||||
@@ -189,7 +190,7 @@ const ExperienceForm = withForm({
|
||||
onReorder={handleReorderRoles}
|
||||
className="flex flex-col gap-4 sm:col-span-full"
|
||||
>
|
||||
<AnimatePresence>
|
||||
<AnimatePresence initial={false}>
|
||||
{rolesField.state.value.map((role: RoleItem, index: number) => (
|
||||
<RoleFields
|
||||
key={role.id}
|
||||
@@ -238,10 +239,11 @@ const RoleFields = withForm({
|
||||
value={role}
|
||||
dragListener={false}
|
||||
dragControls={controls}
|
||||
initial={{ opacity: 1, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
className="relative grid rounded-md border sm:col-span-full sm:grid-cols-2"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15, ease: EASE_OUT_STRONG }}
|
||||
className="relative grid rounded-md border bg-popover sm:col-span-full sm:grid-cols-2"
|
||||
>
|
||||
<div className="col-span-full flex items-center justify-between rounded-t bg-border/30 px-2 py-1.5">
|
||||
<Button
|
||||
|
||||
@@ -160,7 +160,7 @@ const InterestForm = withForm({
|
||||
trigger={
|
||||
<PopoverTrigger className="h-9 rounded-e border-input border-y border-e px-2">
|
||||
<div
|
||||
className="size-4 shrink-0 cursor-pointer rounded-full border border-foreground/60 transition-all hover:scale-105 focus-visible:outline-hidden"
|
||||
className="size-4 shrink-0 cursor-pointer rounded-full border border-foreground/60 transition-transform duration-150 ease-out-strong focus-visible:outline-hidden active:scale-[0.97]"
|
||||
style={{ backgroundColor: field.state.value ?? "currentColor" }}
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
|
||||
@@ -168,7 +168,7 @@ const ProfileForm = withForm({
|
||||
trigger={
|
||||
<PopoverTrigger className="h-9 rounded-e border-input border-y border-e px-2">
|
||||
<div
|
||||
className="size-4 shrink-0 cursor-pointer rounded-full border border-foreground/60 transition-all hover:scale-105 focus-visible:outline-hidden"
|
||||
className="size-4 shrink-0 cursor-pointer rounded-full border border-foreground/60 transition-transform duration-150 ease-out-strong focus-visible:outline-hidden active:scale-[0.97]"
|
||||
style={{ backgroundColor: field.state.value ?? "currentColor" }}
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
|
||||
@@ -164,7 +164,7 @@ const SkillForm = withForm({
|
||||
trigger={
|
||||
<PopoverTrigger className="h-9 rounded-e border-input border-y border-e px-2">
|
||||
<div
|
||||
className="size-4 shrink-0 cursor-pointer rounded-full border border-foreground/60 transition-all hover:scale-105 focus-visible:outline-hidden"
|
||||
className="size-4 shrink-0 cursor-pointer rounded-full border border-foreground/60 transition-transform duration-150 ease-out-strong focus-visible:outline-hidden active:scale-[0.97]"
|
||||
style={{ backgroundColor: field.state.value ?? "currentColor" }}
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
|
||||
@@ -84,7 +84,7 @@ type TemplateCardProps = {
|
||||
|
||||
function TemplateCard({ id, metadata, isActive, onSelect }: TemplateCardProps) {
|
||||
return (
|
||||
<CometCard translateDepth={3} rotateDepth={6} glareOpacity={0}>
|
||||
<CometCard glareOpacity={0}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(id)}
|
||||
@@ -104,7 +104,7 @@ function TemplateCard({ id, metadata, isActive, onSelect }: TemplateCardProps) {
|
||||
{metadata.tags.length > 0 && (
|
||||
<div className="flex flex-wrap justify-center gap-1 px-1 pb-1">
|
||||
{metadata.tags
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
.toSorted((a, b) => a.localeCompare(b))
|
||||
.map((tag) => (
|
||||
<Badge key={tag} variant="secondary" className="text-xs">
|
||||
{tag}
|
||||
|
||||
@@ -60,7 +60,7 @@ function FitRing({ score }: { score: number }) {
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={c}
|
||||
strokeDashoffset={c - (Math.max(0, Math.min(100, score)) / 100) * c}
|
||||
className="transition-[stroke-dashoffset] duration-700 ease-out motion-reduce:transition-none"
|
||||
className="transition-[stroke-dashoffset] duration-300 ease-out-strong motion-reduce:transition-none"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
@@ -31,7 +31,7 @@ export function ApplicationCard({ application, onClick, onEdit, className, dragg
|
||||
}}
|
||||
className={cn(
|
||||
"group relative w-full cursor-pointer rounded-xl border border-border bg-card p-3 text-left shadow-sm outline-none transition-colors hover:border-foreground/25 focus-visible:ring-2 focus-visible:ring-ring",
|
||||
dragging && "opacity-60",
|
||||
dragging && "scale-[1.02] shadow-lg",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -88,7 +88,10 @@ export function ApplicationBoard({ applications, onOpen, onEdit }: Props) {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<DragOverlay>{activeApp ? <ApplicationCard application={activeApp} dragging /> : null}</DragOverlay>
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { PdfAtsReport } from "@reactive-resume/resume/ats-pdf";
|
||||
import type { CSSProperties } from "react";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { InfoIcon } from "@phosphor-icons/react";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@reactive-resume/ui/components/tooltip";
|
||||
@@ -30,8 +31,11 @@ export function ScoreHeader({ report }: ScoreHeaderProps) {
|
||||
|
||||
<div className="h-1.5 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className={cn("h-full rounded-full transition-[width] duration-300", tone.bar)}
|
||||
style={{ width: `${report.score}%` }}
|
||||
className={cn(
|
||||
"h-full translate-x-(--fill) rounded-full transition-[translate] duration-300 ease-out-strong rtl:-translate-x-(--fill)",
|
||||
tone.bar,
|
||||
)}
|
||||
style={{ "--fill": `${report.score - 100}%` } as CSSProperties}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -98,6 +98,7 @@ export function CommandPalette() {
|
||||
</DialogHeader>
|
||||
|
||||
<DialogContent
|
||||
instant
|
||||
className="overflow-hidden p-0"
|
||||
aria-label={
|
||||
isFirstPage
|
||||
|
||||
@@ -134,7 +134,7 @@ export function ResumesCommandGroup() {
|
||||
<ReadCvLogoIcon />
|
||||
{resume.name}
|
||||
|
||||
<CommandShortcut className="opacity-0 transition-opacity group-data-[selected=true]/command-item:opacity-100">
|
||||
<CommandShortcut className="opacity-0 group-data-[selected=true]/command-item:opacity-100">
|
||||
<Trans comment="Command palette hint that pressing Enter opens the selected resume">
|
||||
Press <Kbd>Enter</Kbd> to open
|
||||
</Trans>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { keepPreviousData, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import { coverLetterDocumentSchema } from "@reactive-resume/schema/cover-letter/data";
|
||||
import { templateSchema } from "@reactive-resume/schema/templates";
|
||||
@@ -9,6 +9,7 @@ import { Input } from "@reactive-resume/ui/components/input";
|
||||
import { Label } from "@reactive-resume/ui/components/label";
|
||||
import { Spinner } from "@reactive-resume/ui/components/spinner";
|
||||
import { toast } from "@reactive-resume/ui/components/toast";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
import { templates } from "@/dialogs/resume/template/data";
|
||||
import { getReadableErrorMessage } from "@/libs/error-message";
|
||||
@@ -46,7 +47,10 @@ export function CoverLetterLibrary({ initialResumeId, resumeReady = true, onEdit
|
||||
useEffect(() => {
|
||||
onEditingChange?.(selectedId !== null);
|
||||
}, [selectedId, onEditingChange]);
|
||||
const query = useQuery(orpc.coverLetters.list.queryOptions({ input: { search: querySearch, offset, limit: 20 } }));
|
||||
const query = useQuery({
|
||||
...orpc.coverLetters.list.queryOptions({ input: { search: querySearch, offset, limit: 20 } }),
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
const resumes = useQuery(orpc.resume.list.queryOptions({ input: {} }));
|
||||
const source = useQuery(
|
||||
orpc.resume.getById.queryOptions({ input: { id: resumeId ?? "" }, enabled: creating && !!resumeId }),
|
||||
@@ -250,7 +254,12 @@ export function CoverLetterLibrary({ initialResumeId, resumeReady = true, onEdit
|
||||
<Trans>No cover letters found. Create one or import an existing letter.</Trans>
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y rounded-lg border">
|
||||
<ul
|
||||
className={cn(
|
||||
"divide-y rounded-lg border transition-opacity duration-150",
|
||||
query.isPlaceholderData && "opacity-60",
|
||||
)}
|
||||
>
|
||||
{query.data.items.map((letter) => (
|
||||
<li key={letter.id}>
|
||||
<button
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
/* Keyframes cannot be expressed as a utility; referenced via animate-[home-ats-spin_...] in ats-playground.tsx. */
|
||||
@keyframes home-ats-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { ArrowRightIcon, ArrowUpRightIcon, FilePdfIcon, SpinnerGapIcon } from "@phosphor-icons/react";
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import "./ats-playground.css";
|
||||
|
||||
const focusRing = "focus-visible:outline-2 focus-visible:outline-(--home-accent) focus-visible:outline-offset-4";
|
||||
const message = "max-w-[300px] text-[14px] leading-[1.8]";
|
||||
@@ -97,17 +96,13 @@ export default function AtsPlayground() {
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-[15px] max-[900px]:col-[1] max-[600px]:row-auto max-[900px]:row-[2] max-[600px]:flex-row max-[600px]:flex-wrap max-[600px]:justify-center max-[600px]:gap-2.5">
|
||||
<button
|
||||
className={`motion-safe:active:not-disabled:not-focus-visible:transform-[scale(0.97)] flex min-w-[140px] flex-col items-center gap-[14px] rounded border border-(--home-line) bg-(--home-panel) px-4 py-5 text-(--home-ink) text-[13px] transition-[transform_140ms_cubic-bezier(0.23,1,0.32,1)] hover:not-disabled:border-(--home-accent) disabled:cursor-progress max-[900px]:flex-row max-[900px]:gap-2.5 max-[900px]:py-[13px] ${focusRing}`}
|
||||
className={`motion-safe:active:not-disabled:not-focus-visible:transform-[scale(0.97)] flex min-w-[140px] flex-col items-center gap-[14px] rounded border border-(--home-line) bg-(--home-panel) px-4 py-5 text-(--home-ink) text-[13px] [transition:transform_140ms_var(--ease-out-strong),border-color_150ms_ease] hover:not-disabled:border-(--home-accent) disabled:cursor-progress max-[900px]:flex-row max-[900px]:gap-2.5 max-[900px]:py-[13px] ${focusRing}`}
|
||||
type="button"
|
||||
disabled={isReading}
|
||||
onClick={() => void read(file)}
|
||||
>
|
||||
{isReading ? (
|
||||
<SpinnerGapIcon
|
||||
className="animate-[home-ats-spin_900ms_linear_infinite] text-(--home-accent)"
|
||||
size={22}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<SpinnerGapIcon className="animate-spin text-(--home-accent)" size={22} aria-hidden="true" />
|
||||
) : (
|
||||
<ArrowRightIcon className="text-(--home-accent)" size={22} aria-hidden="true" />
|
||||
)}
|
||||
|
||||
@@ -184,7 +184,7 @@ export default function ExportPlayground({ name, accent, typeface, template }: E
|
||||
className="perspective-[1000px] grid justify-items-center px-[30px] pt-[44px] pb-4 max-[700px]:pt-[35px]"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div className="transform-3d transform-[rotate(-7deg)_rotateY(-12deg)] group-data-[format=docx]:transform-[rotate(-3deg)_rotateY(8deg)] group-data-[format=json]:transform-[rotate(7deg)_rotateY(9deg)] group-data-[format=md]:transform-[rotate(4deg)_rotateY(-7deg)] motion-reduce:transform-[rotate(-4deg)] relative aspect-[0.76] w-[min(100%,285px)] transition-[transform_280ms_cubic-bezier(0.23,1,0.32,1)] group-has-focus-visible:transition-none max-[700px]:w-[225px]">
|
||||
<div className="transform-3d transform-[rotate(-7deg)_rotateY(-12deg)] group-data-[format=docx]:transform-[rotate(-3deg)_rotateY(8deg)] group-data-[format=json]:transform-[rotate(7deg)_rotateY(9deg)] group-data-[format=md]:transform-[rotate(4deg)_rotateY(-7deg)] motion-reduce:transform-[rotate(-4deg)] relative aspect-[0.76] w-[min(100%,285px)] [transition:transform_280ms_var(--ease-out-strong)] group-has-focus-visible:transition-none max-[700px]:w-[225px]">
|
||||
<div className="transform-[translate(-26px,21px)_rotate(-10deg)] absolute inset-0 rounded border border-[#535154] bg-[#242427]" />
|
||||
<div className="transform-[translate(-14px,10px)_rotate(-5deg)] absolute inset-0 rounded border border-[#535154] bg-[#383739]" />
|
||||
<div className="absolute inset-0 flex flex-col overflow-hidden rounded bg-(--home-export-color) p-[25px] text-[#29272c] shadow-[1px_1px_0_#ffffff60_inset,0_24px_48px_#00000050] max-[700px]:p-5">
|
||||
@@ -226,7 +226,7 @@ export default function ExportPlayground({ name, accent, typeface, template }: E
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className={`group flex min-h-16 w-full items-center gap-[17px] border-(--home-line) border-b py-3 pr-[15px] pl-2 text-left text-(--home-muted) text-[17px] [transition:color_160ms_ease,background-color_160ms_ease,transform_120ms_cubic-bezier(0.23,1,0.32,1)] hover:not-disabled:bg-[#ffffff08] hover:not-disabled:text-(--home-ink) aria-pressed:bg-[#ffffff04] aria-pressed:text-(--home-ink) aria-pressed:hover:not-disabled:bg-[#ffffff08] ${buttonState}`}
|
||||
className={`group flex min-h-16 w-full items-center gap-[17px] border-(--home-line) border-b py-3 pr-[15px] pl-2 text-left text-(--home-muted) text-[17px] [transition:color_160ms_ease,background-color_160ms_ease,transform_120ms_var(--ease-out-strong)] hover:not-disabled:bg-[#ffffff08] hover:not-disabled:text-(--home-ink) aria-pressed:bg-[#ffffff04] aria-pressed:text-(--home-ink) aria-pressed:hover:not-disabled:bg-[#ffffff08] ${buttonState}`}
|
||||
aria-pressed={format === item.id}
|
||||
disabled={busy}
|
||||
onClick={() => setFormat(item.id)}
|
||||
@@ -253,7 +253,7 @@ export default function ExportPlayground({ name, accent, typeface, template }: E
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className={`flex min-h-14 w-full items-center justify-between gap-5 rounded border border-transparent bg-(--home-export-color) px-5 py-4 font-[550] text-[#252329] text-[15px] [transition:transform_120ms_cubic-bezier(0.23,1,0.32,1),opacity_160ms_ease] hover:not-disabled:opacity-90 ${buttonState}`}
|
||||
className={`flex min-h-14 w-full items-center justify-between gap-5 rounded border border-transparent bg-(--home-export-color) px-5 py-4 font-[550] text-[#252329] text-[15px] [transition:transform_120ms_var(--ease-out-strong),opacity_160ms_ease] hover:not-disabled:opacity-90 ${buttonState}`}
|
||||
disabled={busy}
|
||||
aria-describedby={descriptionId}
|
||||
onClick={() => void download()}
|
||||
|
||||
@@ -19,11 +19,11 @@ import { cn } from "@reactive-resume/utils/style";
|
||||
import { textLink } from "./classes";
|
||||
|
||||
const demoClass =
|
||||
"min-h-[492px] animate-[home-feature-enter_220ms_cubic-bezier(0.23,1,0.32,1)_both] px-[30px] py-6 group-data-[instant=true]/explorer:animate-none max-[1100px]:p-[23px] max-[900px]:p-[27px] max-[540px]:px-4 max-[540px]:py-5";
|
||||
"min-h-[492px] animate-[home-feature-enter_220ms_var(--ease-out-strong)_both] px-[30px] py-6 group-data-[instant=true]/explorer:animate-none max-[1100px]:p-[23px] max-[900px]:p-[27px] max-[540px]:px-4 max-[540px]:py-5";
|
||||
const demoTopClass = "flex items-center justify-between gap-5 text-[12px] text-(--home-muted)";
|
||||
const demoCaptionClass = "mt-[25px] text-[12px] leading-[1.7] text-(--home-muted) max-[540px]:text-[11px]";
|
||||
const pressTransition =
|
||||
"[transition:transform_140ms_cubic-bezier(0.23,1,0.32,1),background-color_180ms_ease,color_180ms_ease] active:transform-[scale(0.97)]";
|
||||
"[transition:transform_140ms_var(--ease-out-strong),background-color_180ms_ease,color_180ms_ease] active:transform-[scale(0.97)]";
|
||||
const jobCardClass =
|
||||
"min-h-[191px] rounded-[4px] border px-3 py-[14px] max-[540px]:min-h-[183px] max-[540px]:px-2 max-[540px]:py-[13px]";
|
||||
const toolBoxClass =
|
||||
@@ -282,7 +282,7 @@ export function FeatureExplorer() {
|
||||
/>
|
||||
))}
|
||||
<div
|
||||
className={`${jobCardClass} transform-[translateX(calc(var(--stage)_*_(100%_+_var(--home-board-gap))))_rotate(calc(var(--stage)_*_3deg_-_2deg))] absolute top-0 left-0 w-[calc((100%_-_2_*_var(--home-board-gap))_/_3)] border-[#4b4b51] bg-[#2a2a2e] text-(--home-ink) shadow-[0_5px_12px_#0002] [transition:transform_460ms_cubic-bezier(0.23,1,0.32,1)] group-data-[instant=true]/explorer:transition-none`}
|
||||
className={`${jobCardClass} transform-[translateX(calc(var(--stage)_*_(100%_+_var(--home-board-gap))))_rotate(calc(var(--stage)_*_3deg_-_2deg))] absolute top-0 left-0 w-[calc((100%_-_2_*_var(--home-board-gap))_/_3)] border-[#4b4b51] bg-[#2a2a2e] text-(--home-ink) shadow-[0_5px_12px_#0002] [transition:transform_460ms_var(--ease-out-strong)] group-data-[instant=true]/explorer:transition-none`}
|
||||
>
|
||||
<div className="mb-4 grid size-[30px] place-items-center rounded-[4px] bg-[#d8c4ac] font-[Georgia,serif] font-semibold text-[#34302a] text-[19px] leading-none">
|
||||
n.
|
||||
|
||||
@@ -30,7 +30,7 @@ const headingClass = `${wordClass} mb-[15px] font-semibold text-[13px] leading-[
|
||||
const paperClass =
|
||||
"language-paper relative mx-auto w-full max-w-[440px] rotate-2 rounded border border-[#e0dcd5] bg-[#efede7] font-[Arial,Noto_Sans,sans-serif] text-[#29272b] text-start shadow-[4px_5px_0_#d5d0c8,5px_6px_0_#77716b,11px_16px_0_-4px_#b9b1a7,10px_28px_42px_#0005]";
|
||||
const optionClass =
|
||||
"group/option flex min-h-[62px] items-center justify-between gap-2 rounded border border-[#454345] bg-[#222224] px-[17px] py-[14px] text-[#e3dfda] text-[clamp(16px,1.6vw,21px)] shadow-[0_3px_0_#101011] transition-[background,color,transform,box-shadow] duration-[160ms] ease-[ease] hover:not-aria-pressed:-translate-y-[2px] hover:not-aria-pressed:bg-[#343031] hover:not-aria-pressed:shadow-[0_5px_0_#101011] focus-visible:outline-2 focus-visible:outline-offset-[5px] focus-visible:outline-(--home-accent) active:translate-y-[2px] active:shadow-[0_1px_0_#101011] aria-pressed:translate-y-[2px] aria-pressed:border-[#d0b9a5] aria-pressed:bg-[#d0b9a5] aria-pressed:text-[#24201d] aria-pressed:shadow-[0_1px_0_#101011] group-data-[instant=true]/languages:transition-none max-[420px]:min-h-[56px] max-[420px]:px-[11px] max-[420px]:py-[10px] max-[420px]:text-[16px]";
|
||||
"group/option flex min-h-[62px] items-center justify-between gap-2 rounded border border-[#454345] bg-[#222224] px-[17px] py-[14px] text-[#e3dfda] text-[clamp(16px,1.6vw,21px)] shadow-[0_3px_0_#101011] transition-[background-color,color,translate,box-shadow] duration-[160ms] ease-[ease] hover:not-aria-pressed:-translate-y-[2px] hover:not-aria-pressed:bg-[#343031] hover:not-aria-pressed:shadow-[0_5px_0_#101011] focus-visible:outline-2 focus-visible:outline-offset-[5px] focus-visible:outline-(--home-accent) active:translate-y-[2px] active:shadow-[0_1px_0_#101011] aria-pressed:translate-y-[2px] aria-pressed:border-[#d0b9a5] aria-pressed:bg-[#d0b9a5] aria-pressed:text-[#24201d] aria-pressed:shadow-[0_1px_0_#101011] group-data-[instant=true]/languages:transition-none max-[420px]:min-h-[56px] max-[420px]:px-[11px] max-[420px]:py-[10px] max-[420px]:text-[16px]";
|
||||
|
||||
type LinesProps = { count: 2 | 3; className?: string };
|
||||
|
||||
|
||||
@@ -23,12 +23,12 @@ const ExportPlayground = lazy(() => import("./export-playground"));
|
||||
const AtsPlayground = lazy(() => import("./ats-playground"));
|
||||
const githubUrl = "https://github.com/reactive-resume/reactive-resume";
|
||||
const buttonClass =
|
||||
"inline-flex min-h-[52px] items-center justify-center gap-3 rounded-[4px] border border-[#f1f0eb] bg-[#f1f0eb] px-[19px] py-[14px] text-[14px] font-[550] text-[#151516] [transition:background-color_150ms_ease,transform_150ms_cubic-bezier(0.23,1,0.32,1)] hover:bg-[#d9d8d2] active:transform-[scale(0.97)] max-[540px]:min-h-[49px] max-[540px]:gap-[18px] max-[540px]:px-[15px] max-[540px]:py-3 max-[540px]:text-[13px]";
|
||||
"inline-flex min-h-[52px] items-center justify-center gap-3 rounded-[4px] border border-[#f1f0eb] bg-[#f1f0eb] px-[19px] py-[14px] text-[14px] font-[550] text-[#151516] [transition:background-color_150ms_ease,transform_150ms_var(--ease-out-strong)] hover:bg-[#d9d8d2] active:transform-[scale(0.97)] max-[540px]:min-h-[49px] max-[540px]:gap-[18px] max-[540px]:px-[15px] max-[540px]:py-3 max-[540px]:text-[13px]";
|
||||
const brandClass = "inline-flex shrink-0 items-center gap-[11px] font-[550] tracking-[-0.04em] max-[540px]:gap-2";
|
||||
const paperLinkClass =
|
||||
"group/link flex min-h-[65px] items-center gap-[14px] border-t border-[#2d2e3038] text-[14px] [transition:background-color_160ms_ease] focus-visible:outline-[#37383a]";
|
||||
const paperArrowClass =
|
||||
"ml-auto [transition:transform_180ms_cubic-bezier(0.23,1,0.32,1)] group-hover/link:transform-[translate(2px,-2px)]";
|
||||
"ml-auto [transition:transform_180ms_var(--ease-out-strong)] group-hover/link:transform-[translate(2px,-2px)]";
|
||||
const contributeLinkClass =
|
||||
"inline-flex min-h-11 items-center gap-2 text-(--home-ink) underline-offset-4 hover:underline";
|
||||
type DeferredDemoProps = { children: ReactNode };
|
||||
@@ -91,7 +91,7 @@ export function Homepage() {
|
||||
>
|
||||
<Trans>Docs</Trans>
|
||||
</a>
|
||||
<div className="[&>a]:hover:[&_svg:last-child]:transform-[rotate(20deg)_scale(1.15)] [&>a>span]:min-w-[6ch] [&>a>span]:border-s [&>a>span]:border-s-[#3d444d] [&>a>span]:ps-2 [&>a>span]:text-center [&>a>span]:font-semibold max-[540px]:[&>a>span]:min-w-0 max-[540px]:[&>a>span]:ps-[6px] [&>a]:inline-flex [&>a]:min-h-[38px] [&>a]:min-w-[128px] [&>a]:items-center [&>a]:gap-2 [&>a]:rounded-[4px] [&>a]:border [&>a]:border-[#3d444d] [&>a]:bg-[#161b22] [&>a]:px-[11px] [&>a]:py-0 [&>a]:text-[#f0f6fc] [&>a]:text-[13px] [&>a]:tabular-nums [&>a]:shadow-[inset_0_1px_0_#ffffff0d,0_3px_0_#09090966] [&>a]:[transition:background-color_180ms_ease,border-color_180ms_ease,box-shadow_180ms_ease] [&>a]:hover:border-[#6e7681] [&>a]:hover:bg-[#21262d] [&>a]:hover:text-(--home-ink) [&>a]:hover:shadow-[inset_0_1px_0_#ffffff14,0_3px_0_#09090966,0_0_18px_#ffffff08] [&>a]:focus-visible:border-[#3d444d] [&>a]:focus-visible:ring-0 max-[540px]:[&>a]:min-w-24 max-[540px]:[&>a]:gap-[5px] max-[540px]:[&>a]:px-[6px] max-[540px]:[&>a]:text-[11px] [&_svg:last-child]:text-[#f0f6fc] [&_svg:last-child]:[transition:transform_220ms_cubic-bezier(0.23,1,0.32,1)] max-[540px]:[&_svg:not([class*='size-'])]:size-[14px]">
|
||||
<div className="[&>a]:hover:[&_svg:last-child]:transform-[rotate(20deg)_scale(1.15)] [&>a>span]:min-w-[6ch] [&>a>span]:border-s [&>a>span]:border-s-[#3d444d] [&>a>span]:ps-2 [&>a>span]:text-center [&>a>span]:font-semibold max-[540px]:[&>a>span]:min-w-0 max-[540px]:[&>a>span]:ps-[6px] [&>a]:inline-flex [&>a]:min-h-[38px] [&>a]:min-w-[128px] [&>a]:items-center [&>a]:gap-2 [&>a]:rounded-[4px] [&>a]:border [&>a]:border-[#3d444d] [&>a]:bg-[#161b22] [&>a]:px-[11px] [&>a]:py-0 [&>a]:text-[#f0f6fc] [&>a]:text-[13px] [&>a]:tabular-nums [&>a]:shadow-[inset_0_1px_0_#ffffff0d,0_3px_0_#09090966] [&>a]:[transition:background-color_180ms_ease,border-color_180ms_ease,box-shadow_180ms_ease] [&>a]:hover:border-[#6e7681] [&>a]:hover:bg-[#21262d] [&>a]:hover:text-(--home-ink) [&>a]:hover:shadow-[inset_0_1px_0_#ffffff14,0_3px_0_#09090966,0_0_18px_#ffffff08] [&>a]:focus-visible:border-[#3d444d] [&>a]:focus-visible:ring-0 max-[540px]:[&>a]:min-w-24 max-[540px]:[&>a]:gap-[5px] max-[540px]:[&>a]:px-[6px] max-[540px]:[&>a]:text-[11px] [&_svg:last-child]:text-[#f0f6fc] [&_svg:last-child]:[transition:transform_220ms_var(--ease-out-strong)] max-[540px]:[&_svg:not([class*='size-'])]:size-[14px]">
|
||||
<GithubStarsButton />
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { CSSProperties, PointerEvent } from "react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { motion, useMotionTemplate, useReducedMotion, useSpring } from "motion/react";
|
||||
import { m, useMotionTemplate, useReducedMotion, useSpring } from "motion/react";
|
||||
import { useId, useRef, useState } from "react";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import "./resume-sculpture.css";
|
||||
@@ -16,7 +16,7 @@ const focusRing =
|
||||
const dockLabel = "mb-[7px] block text-[11px] leading-[1.4] text-[#a5a5ab]";
|
||||
|
||||
const dockButton =
|
||||
"relative grid min-h-[44px] min-w-[44px] cursor-pointer place-items-center bg-transparent text-[12px] text-[#a5a5ab] [transition:transform_140ms_cubic-bezier(0.23,1,0.32,1),color_150ms_ease]";
|
||||
"relative grid min-h-[44px] min-w-[44px] cursor-pointer place-items-center bg-transparent text-[12px] text-[#a5a5ab] [transition:transform_140ms_var(--ease-out-strong),color_150ms_ease]";
|
||||
|
||||
const templateIcon: Record<SculptureTemplate, string> = {
|
||||
ditgar: "bg-[linear-gradient(to_right,#aa8ebe_34%,transparent_34%)]",
|
||||
@@ -110,7 +110,7 @@ export function ResumeSculpture({
|
||||
onPointerCancel={endDrag}
|
||||
onLostPointerCapture={endDrag}
|
||||
>
|
||||
<motion.div
|
||||
<m.div
|
||||
className="sculpture-rig group/rig transform-3d absolute inset-0"
|
||||
data-spread={spread}
|
||||
data-instant={keyboardAction}
|
||||
@@ -129,7 +129,7 @@ export function ResumeSculpture({
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</motion.div>
|
||||
</m.div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between @max-[450px]:gap-1 gap-3 @max-[450px]:px-0 px-2 pt-[14px] pb-[18px] text-(--home-muted)">
|
||||
@@ -351,7 +351,7 @@ function ResumePaper({ name, accent, typeface, template, position }: ResumePaper
|
||||
return (
|
||||
<article
|
||||
className={cn(
|
||||
"sculpture-paper transform-3d absolute top-[2%] left-[24%] aspect-[210/297] w-[56%] origin-[50%_80%] animate-[home-sculpture-assemble_900ms_cubic-bezier(0.23,1,0.32,1)_both] transition-[transform] duration-[650ms] ease-[cubic-bezier(0.23,1,0.32,1)] group-data-[instant=true]/rig:transition-none",
|
||||
"sculpture-paper transform-3d absolute top-[2%] left-[24%] aspect-[210/297] w-[56%] origin-[50%_80%] animate-[home-sculpture-assemble_900ms_var(--ease-out-strong)_both] transition-[transform] duration-[650ms] ease-out-strong group-data-[instant=true]/rig:transition-none",
|
||||
paperTransform[position],
|
||||
)}
|
||||
data-template={template}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { m } from "motion/react";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { EASE_OUT_STRONG } from "@/libs/motion";
|
||||
import { wrap } from "./classes";
|
||||
|
||||
const githubUrl = "https://github.com/reactive-resume/reactive-resume";
|
||||
@@ -73,7 +74,7 @@ const getSocialLinks = (): SocialLink[] => [
|
||||
const linkClass =
|
||||
"group/link inline-flex min-h-9 items-center gap-1 text-(--home-muted) text-[14px] [transition:color_150ms_ease] hover:text-(--home-ink)";
|
||||
const arrowClass =
|
||||
"opacity-0 [transition:opacity_180ms_ease,transform_180ms_cubic-bezier(0.23,1,0.32,1)] group-hover/link:transform-[translate(1px,-1px)] group-hover/link:opacity-60";
|
||||
"opacity-0 [transition:opacity_180ms_ease,transform_180ms_var(--ease-out-strong)] group-hover/link:transform-[translate(1px,-1px)] group-hover/link:opacity-60";
|
||||
const socialClass =
|
||||
"inline-grid size-10 place-items-center text-(--home-muted) [transition:color_160ms_ease] hover:text-(--home-ink)";
|
||||
const metaLinkClass = "text-(--home-ink) underline-offset-[3px] hover:underline";
|
||||
@@ -201,7 +202,7 @@ export function SiteFooter() {
|
||||
initial={{ opacity: 0, y: 24 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, amount: 0.3 }}
|
||||
transition={{ duration: 0.7, ease: [0.23, 1, 0.32, 1] }}
|
||||
transition={{ duration: 0.7, ease: EASE_OUT_STRONG }}
|
||||
>
|
||||
Reactive Resume
|
||||
</m.p>
|
||||
|
||||
@@ -49,13 +49,3 @@
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.homepage *,
|
||||
.homepage *::before,
|
||||
.homepage *::after {
|
||||
animation: none;
|
||||
transition: none;
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ type TemplateShelfProps = { template: Template; onChange: (template: Template) =
|
||||
const templates = templateSchema.options;
|
||||
const displayName = (name: string) => name[0].toUpperCase() + name.slice(1);
|
||||
const roundLinkClass =
|
||||
"inline-flex size-[46px] shrink-0 items-center justify-center rounded-full border border-[#444447] bg-transparent text-(--home-ink) [transition:background-color_150ms_ease,transform_150ms_cubic-bezier(0.23,1,0.32,1)] hover:bg-[#2c2c2f] active:transform-[scale(0.97)] max-[540px]:size-11";
|
||||
"inline-flex size-[46px] shrink-0 items-center justify-center rounded-full border border-[#444447] bg-transparent text-(--home-ink) [transition:background-color_150ms_ease,transform_150ms_var(--ease-out-strong)] hover:bg-[#2c2c2f] active:transform-[scale(0.97)] max-[540px]:size-11";
|
||||
|
||||
export function TemplateShelf({ template, onChange }: TemplateShelfProps) {
|
||||
const index = templates.indexOf(template);
|
||||
@@ -35,7 +35,7 @@ export function TemplateShelf({ template, onChange }: TemplateShelfProps) {
|
||||
<button
|
||||
key={item}
|
||||
type="button"
|
||||
className="transform-[translateX(calc(var(--position)_*_var(--home-paper-step)))_translateY(calc(var(--distance)_*_15px))_translateZ(calc(var(--distance)_*_-80px))_rotateY(calc(var(--position)_*_-12deg))_rotateZ(calc(var(--position)_*_2deg))] absolute top-5 left-[calc(50%_-_var(--home-paper-width)_/_2)] z-[calc(10_-_var(--distance))] aspect-[510/720] w-(--home-paper-width) rounded-[2px] border border-[#dedbd3] bg-[#eee] shadow-[0_25px_28px_#0004,0_2px_1px_#0006] brightness-[calc(1_-_var(--distance)_*_0.18)] [transition:transform_550ms_cubic-bezier(0.23,1,0.32,1),filter_350ms_ease,opacity_220ms_ease] not-aria-pressed:hover:brightness-95 aria-hidden:invisible aria-hidden:opacity-0 group-data-[instant=true]/shelf:transition-none"
|
||||
className="transform-[translateX(calc(var(--position)_*_var(--home-paper-step)))_translateY(calc(var(--distance)_*_15px))_translateZ(calc(var(--distance)_*_-80px))_rotateY(calc(var(--position)_*_-12deg))_rotateZ(calc(var(--position)_*_2deg))] absolute top-5 left-[calc(50%_-_var(--home-paper-width)_/_2)] z-[calc(10_-_var(--distance))] aspect-[510/720] w-(--home-paper-width) rounded-[2px] border border-[#dedbd3] bg-[#eee] shadow-[0_25px_28px_#0004,0_2px_1px_#0006] brightness-[calc(1_-_var(--distance)_*_0.18)] [transition:transform_550ms_var(--ease-out-strong),filter_350ms_ease,opacity_220ms_ease,visibility_220ms] not-aria-pressed:hover:brightness-95 aria-hidden:invisible aria-hidden:opacity-0 group-data-[instant=true]/shelf:transition-none"
|
||||
aria-label={t`Choose ${displayName(item)}`}
|
||||
aria-pressed={item === template}
|
||||
aria-hidden={Math.abs(offset) > 2}
|
||||
|
||||
@@ -26,10 +26,15 @@ export function getVisibleLeftSidebarSections(data: ResumeData): LeftSidebarSect
|
||||
);
|
||||
}
|
||||
|
||||
/** Smooth scrolling unless the user asked for reduced motion. */
|
||||
export function getScrollBehavior(): ScrollBehavior {
|
||||
return window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "instant" : "smooth";
|
||||
}
|
||||
|
||||
function focusSidebarSection(sectionId: string): void {
|
||||
const editorTarget = document.getElementById(`sidebar-${sectionId}`);
|
||||
if (editorTarget) {
|
||||
editorTarget.scrollIntoView({ block: "start", inline: "nearest", behavior: "smooth" });
|
||||
editorTarget.scrollIntoView({ block: "start", inline: "nearest", behavior: getScrollBehavior() });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -39,7 +44,7 @@ function focusSidebarSection(sectionId: string): void {
|
||||
if (!recoveryTarget) return;
|
||||
|
||||
recoveryTarget.focus({ preventScroll: true });
|
||||
recoveryTarget.scrollIntoView({ block: "start", inline: "nearest", behavior: "smooth" });
|
||||
recoveryTarget.scrollIntoView({ block: "start", inline: "nearest", behavior: getScrollBehavior() });
|
||||
};
|
||||
|
||||
const trigger = document.getElementById("sidebar-hidden-sections-trigger");
|
||||
|
||||
@@ -9,6 +9,7 @@ import { toast } from "@reactive-resume/ui/components/toast";
|
||||
import { isRTL } from "@reactive-resume/utils/locale";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { createResumePdfBlob } from "@/features/resume/export/pdf-document";
|
||||
import { EASE_OUT_STRONG } from "@/libs/motion";
|
||||
import { usePreviewPausedStore, useResumeData } from "../builder/draft";
|
||||
import { PdfCanvasDocument, PdfCanvasPage } from "./pdf-canvas";
|
||||
import { ResumePreviewLoader } from "./preview.shared";
|
||||
@@ -26,7 +27,10 @@ type PreviewPdf = {
|
||||
};
|
||||
|
||||
const UPDATE_DEBOUNCE_MS = 100;
|
||||
const CROSSFADE_DURATION_MS = 180;
|
||||
// Incoming layer fades in over the old one; the old layer holds at full opacity until the incoming one is opaque,
|
||||
// then drops out. Fading both at once dips the page towards the background mid-swap.
|
||||
const INCOMING_TRANSITION = { duration: 0.15, ease: EASE_OUT_STRONG };
|
||||
const EXITING_TRANSITION = { duration: 0.1, delay: 0.18 };
|
||||
|
||||
const createPreviewPdf = (file: Blob, id: number, hasExistingPreview: boolean, template: Template): PreviewPdf => ({
|
||||
file,
|
||||
@@ -189,7 +193,7 @@ export function ResumePreviewClient({
|
||||
initial={{ opacity: visiblePdf.phase === "active" ? 1 : 0 }}
|
||||
animate={{ opacity: visiblePdf.phase === "active" ? 1 : 0 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: CROSSFADE_DURATION_MS / 1000, ease: "easeOut" }}
|
||||
transition={visiblePdf.phase === "exiting" ? EXITING_TRANSITION : INCOMING_TRANSITION}
|
||||
onAnimationComplete={() => {
|
||||
if (visiblePdf.phase !== "exiting") return;
|
||||
setPreviewLayers((current) => removePreviewLayer(current, visiblePdf.id));
|
||||
|
||||
@@ -65,7 +65,10 @@ export function ResumePreviewLoader({
|
||||
role="img"
|
||||
aria-label={`Loading resume page ${pageNumber} of ${pageCount}`}
|
||||
style={pageSize}
|
||||
className={cn("aspect-page overflow-hidden rounded-md bg-white", pageClassName)}
|
||||
className={cn(
|
||||
"flex aspect-page items-center justify-center overflow-hidden rounded-md bg-white text-neutral-400",
|
||||
pageClassName,
|
||||
)}
|
||||
>
|
||||
<Spinner className="size-10" />
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { ResumePreviewProps } from "./preview.shared";
|
||||
import { lazy, Suspense } from "react";
|
||||
import { useIsClient } from "usehooks-ts";
|
||||
import { useResumeData } from "../builder/draft";
|
||||
import { ResumePreviewLoader } from "./preview.shared";
|
||||
import { getResumePreviewPageCount } from "./preview.shared.utils";
|
||||
@@ -19,13 +18,10 @@ export function ResumePreview({
|
||||
showPageNumbers = false,
|
||||
...rest
|
||||
}: ResumePreviewProps) {
|
||||
const isClient = useIsClient();
|
||||
const builderResumeData = useResumeData();
|
||||
const resumeData = rest.data ?? builderResumeData;
|
||||
const pageCount = getResumePreviewPageCount(resumeData);
|
||||
|
||||
if (!isClient) return null;
|
||||
|
||||
const resolvedProps = { ...rest, pageGap, pageLayout, pageScale, showPageNumbers };
|
||||
|
||||
return (
|
||||
|
||||
@@ -89,7 +89,7 @@ export function PublicResumePage({ resume, username, slug, flags, isRoot = false
|
||||
onClick={() => void onDownloadPDF()}
|
||||
aria-label={t`Download PDF`}
|
||||
title={t`Download PDF`}
|
||||
className="fixed right-6 bottom-6 z-50 rounded-full bg-background/95 opacity-70 shadow-lg backdrop-blur transition-opacity hover:opacity-100 print:hidden"
|
||||
className="fixed right-6 bottom-6 z-50 rounded-full bg-background/95 opacity-70 shadow-lg backdrop-blur transition-[opacity,scale] duration-150 ease-out-strong hover:opacity-100 print:hidden"
|
||||
>
|
||||
{isExporting ? (
|
||||
<CircleNotchIcon className="size-5 animate-spin" />
|
||||
|
||||
@@ -28,9 +28,8 @@ const builder = vi.hoisted(() => ({
|
||||
redo: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("usehooks-ts", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("usehooks-ts")>()),
|
||||
useMediaQuery: () => media.mobile,
|
||||
vi.mock("@reactive-resume/ui/hooks/use-mobile", () => ({
|
||||
useIsMobile: () => media.mobile,
|
||||
}));
|
||||
|
||||
vi.mock("@/features/theme/provider", () => ({
|
||||
|
||||
@@ -20,7 +20,6 @@ import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { BookOpenIcon } from "@phosphor-icons/react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useMediaQuery } from "usehooks-ts";
|
||||
import { convertLegacyStyleRules } from "@reactive-resume/pdf/semantic-legacy";
|
||||
import {
|
||||
buildSemanticTree,
|
||||
@@ -31,6 +30,7 @@ import {
|
||||
import { isFatalStylesheetDiagnostic } from "@reactive-resume/resume/stylesheet";
|
||||
import { PopoverTrigger } from "@reactive-resume/ui/components/popover";
|
||||
import { Sheet, SheetContent, SheetTitle } from "@reactive-resume/ui/components/sheet";
|
||||
import { useIsMobile } from "@reactive-resume/ui/hooks/use-mobile";
|
||||
import { ColorPicker } from "@/components/input/color-picker";
|
||||
import { useIsResumeLocked, useResumeData, useResumeStore, useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { useTheme } from "@/features/theme/provider";
|
||||
@@ -382,7 +382,7 @@ const createEditorMetadata = (data: ResumeData): SemanticCssEditorMetadata => {
|
||||
|
||||
function StylesheetEditorShell({ readOnly = false }: StylesheetEditorShellProps) {
|
||||
const { theme } = useTheme();
|
||||
const isMobile = useMediaQuery("(max-width: 767px)", { initializeWithValue: false });
|
||||
const isMobile = useIsMobile();
|
||||
const [focusOpen, setFocusOpen] = useState(false);
|
||||
const [diagnostics, setDiagnostics] = useState<readonly SemanticCssDiagnostic[]>([]);
|
||||
const [colorTokens, setColorTokens] = useState<readonly SemanticCssColorToken[]>([]);
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { m } from "motion/react";
|
||||
|
||||
type ActionButtonProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export function ActionButton({ children }: ActionButtonProps) {
|
||||
return (
|
||||
<m.div
|
||||
className="will-change-transform"
|
||||
whileHover={{ y: -1, scale: 1.01 }}
|
||||
whileTap={{ scale: 0.99 }}
|
||||
transition={{ duration: 0.14, ease: "easeOut" }}
|
||||
>
|
||||
{children}
|
||||
</m.div>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { KeyIcon, PlusIcon, TrashIcon } from "@phosphor-icons/react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { m } from "motion/react";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { Separator } from "@reactive-resume/ui/components/separator";
|
||||
import { toast } from "@reactive-resume/ui/components/toast";
|
||||
@@ -112,12 +111,7 @@ export function PasskeysSection() {
|
||||
};
|
||||
|
||||
return (
|
||||
<m.div
|
||||
initial={{ y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.2, delay: 0.3, ease: "easeOut" }}
|
||||
className="will-change-[transform,opacity]"
|
||||
>
|
||||
<div>
|
||||
<Separator />
|
||||
|
||||
<div className="mt-4 grid gap-3">
|
||||
@@ -166,6 +160,6 @@ export function PasskeysSection() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</m.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { PasswordIcon, PencilSimpleLineIcon } from "@phosphor-icons/react";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { m } from "motion/react";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { ActionButton } from "./action-button";
|
||||
import { useAuthAccounts } from "./hooks";
|
||||
|
||||
export function PasswordSection() {
|
||||
@@ -14,35 +12,28 @@ export function PasswordSection() {
|
||||
const hasPassword = hasAccount("credential");
|
||||
|
||||
return (
|
||||
<m.div
|
||||
initial={{ y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.2, delay: 0.1, ease: "easeOut" }}
|
||||
className="flex items-center justify-between gap-x-4 will-change-[transform,opacity]"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-x-4">
|
||||
<h2 className="flex items-center gap-x-3 font-medium text-base">
|
||||
<PasswordIcon />
|
||||
<Trans>Password</Trans>
|
||||
</h2>
|
||||
|
||||
<ActionButton>
|
||||
{hasPassword ? (
|
||||
<Button variant="outline" onClick={() => openDialog("auth.change-password", undefined)}>
|
||||
<PencilSimpleLineIcon />
|
||||
<Trans>Update Password</Trans>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
nativeButton={false}
|
||||
render={
|
||||
<Link to="/auth/forgot-password">
|
||||
<Trans>Set Password</Trans>
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</ActionButton>
|
||||
</m.div>
|
||||
{hasPassword ? (
|
||||
<Button variant="outline" onClick={() => openDialog("auth.change-password", undefined)}>
|
||||
<PencilSimpleLineIcon />
|
||||
<Trans>Update Password</Trans>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
nativeButton={false}
|
||||
render={
|
||||
<Link to="/auth/forgot-password">
|
||||
<Trans>Set Password</Trans>
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
import type { AuthProvider } from "@reactive-resume/auth/types";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { LinkBreakIcon, LinkIcon } from "@phosphor-icons/react";
|
||||
import { m } from "motion/react";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { Separator } from "@reactive-resume/ui/components/separator";
|
||||
import { ActionButton } from "./action-button";
|
||||
import { getProviderIcon, getProviderName, useAuthAccounts, useAuthProviderActions } from "./hooks";
|
||||
|
||||
type SocialProviderSectionProps = {
|
||||
provider: AuthProvider;
|
||||
name?: string;
|
||||
animationDelay?: number;
|
||||
};
|
||||
|
||||
export function SocialProviderSection({ provider, name, animationDelay = 0 }: SocialProviderSectionProps) {
|
||||
export function SocialProviderSection({ provider, name }: SocialProviderSectionProps) {
|
||||
const { link, unlink } = useAuthProviderActions();
|
||||
const { hasAccount, getAccountByProviderId } = useAuthAccounts();
|
||||
|
||||
@@ -24,12 +21,7 @@ export function SocialProviderSection({ provider, name, animationDelay = 0 }: So
|
||||
const isConnected = hasAccount(provider);
|
||||
|
||||
return (
|
||||
<m.div
|
||||
className="will-change-[transform,opacity]"
|
||||
initial={{ y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.2, delay: animationDelay, ease: "easeOut" }}
|
||||
>
|
||||
<div>
|
||||
<Separator />
|
||||
|
||||
<div className="mt-4 flex items-center justify-between gap-x-4">
|
||||
@@ -38,27 +30,25 @@ export function SocialProviderSection({ provider, name, animationDelay = 0 }: So
|
||||
{providerName}
|
||||
</h2>
|
||||
|
||||
<ActionButton>
|
||||
{isConnected ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
if (account?.accountId) void unlink(provider, account.accountId);
|
||||
}}
|
||||
>
|
||||
<LinkBreakIcon />
|
||||
<Trans comment="Authentication settings action to unlink a connected social login provider">
|
||||
Disconnect
|
||||
</Trans>
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="outline" onClick={() => void link(provider)}>
|
||||
<LinkIcon />
|
||||
<Trans comment="Authentication settings action to link a social login provider">Connect</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</ActionButton>
|
||||
{isConnected ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
if (account?.accountId) void unlink(provider, account.accountId);
|
||||
}}
|
||||
>
|
||||
<LinkBreakIcon />
|
||||
<Trans comment="Authentication settings action to unlink a connected social login provider">
|
||||
Disconnect
|
||||
</Trans>
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="outline" onClick={() => void link(provider)}>
|
||||
<LinkIcon />
|
||||
<Trans comment="Authentication settings action to link a social login provider">Connect</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</m.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { KeyIcon, LockOpenIcon, ToggleLeftIcon, ToggleRightIcon } from "@phosphor-icons/react";
|
||||
import { m } from "motion/react";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { Separator } from "@reactive-resume/ui/components/separator";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { authClient } from "@/libs/auth/client";
|
||||
import { ActionButton } from "./action-button";
|
||||
import { useAuthAccounts } from "./hooks";
|
||||
|
||||
export function TwoFactorSection() {
|
||||
@@ -19,12 +17,7 @@ export function TwoFactorSection() {
|
||||
if (!hasPassword) return null;
|
||||
|
||||
return (
|
||||
<m.div
|
||||
className="will-change-[transform,opacity]"
|
||||
initial={{ y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.2, delay: 0.2, ease: "easeOut" }}
|
||||
>
|
||||
<div>
|
||||
<Separator />
|
||||
|
||||
<div className="mt-4 flex items-center justify-between gap-x-4">
|
||||
@@ -33,25 +26,23 @@ export function TwoFactorSection() {
|
||||
<Trans>Two-Factor Authentication</Trans>
|
||||
</h2>
|
||||
|
||||
<ActionButton>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => openDialog(hasTwoFactor ? "auth.two-factor.disable" : "auth.two-factor.enable", undefined)}
|
||||
>
|
||||
{hasTwoFactor ? (
|
||||
<>
|
||||
<ToggleLeftIcon />
|
||||
<Trans>Disable 2FA</Trans>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ToggleRightIcon />
|
||||
<Trans>Enable 2FA</Trans>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</ActionButton>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => openDialog(hasTwoFactor ? "auth.two-factor.disable" : "auth.two-factor.enable", undefined)}
|
||||
>
|
||||
{hasTwoFactor ? (
|
||||
<>
|
||||
<ToggleLeftIcon />
|
||||
<Trans>Disable 2FA</Trans>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ToggleRightIcon />
|
||||
<Trans>Enable 2FA</Trans>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</m.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { m } from "motion/react";
|
||||
import { useEnabledProviders } from "./components/hooks";
|
||||
import { PasskeysSection } from "./components/passkeys";
|
||||
import { PasswordSection } from "./components/password";
|
||||
@@ -9,27 +8,20 @@ export function AuthenticationSettingsPage() {
|
||||
const { enabledProviders } = useEnabledProviders();
|
||||
|
||||
return (
|
||||
<m.div
|
||||
initial={{ y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, ease: "easeOut" }}
|
||||
className="grid max-w-xl gap-4 will-change-[transform,opacity]"
|
||||
>
|
||||
<div className="grid max-w-xl gap-4">
|
||||
<PasswordSection />
|
||||
|
||||
<TwoFactorSection />
|
||||
|
||||
<PasskeysSection />
|
||||
|
||||
{"google" in enabledProviders && <SocialProviderSection provider="google" animationDelay={0.04} />}
|
||||
{"google" in enabledProviders && <SocialProviderSection provider="google" />}
|
||||
|
||||
{"github" in enabledProviders && <SocialProviderSection provider="github" animationDelay={0.08} />}
|
||||
{"github" in enabledProviders && <SocialProviderSection provider="github" />}
|
||||
|
||||
{"linkedin" in enabledProviders && <SocialProviderSection provider="linkedin" animationDelay={0.12} />}
|
||||
{"linkedin" in enabledProviders && <SocialProviderSection provider="linkedin" />}
|
||||
|
||||
{"custom" in enabledProviders && (
|
||||
<SocialProviderSection provider="custom" animationDelay={0.16} name={enabledProviders.custom} />
|
||||
)}
|
||||
</m.div>
|
||||
{"custom" in enabledProviders && <SocialProviderSection provider="custom" name={enabledProviders.custom} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,9 @@
|
||||
import { m } from "motion/react";
|
||||
import { useIsClient } from "usehooks-ts";
|
||||
import { AISettingsSection } from "./components/ai-section";
|
||||
|
||||
export function IntegrationsSettingsPage() {
|
||||
const isClient = useIsClient();
|
||||
|
||||
if (!isClient) return null;
|
||||
|
||||
return (
|
||||
<m.div
|
||||
initial={{ y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, ease: "easeOut" }}
|
||||
className="grid max-w-4xl gap-8 will-change-[transform,opacity]"
|
||||
>
|
||||
<div className="grid max-w-4xl gap-8">
|
||||
<AISettingsSection />
|
||||
</m.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { Trans } from "@lingui/react/macro";
|
||||
import { DownloadSimpleIcon, TrashSimpleIcon } from "@phosphor-icons/react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { m } from "motion/react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { Input } from "@reactive-resume/ui/components/input";
|
||||
@@ -20,7 +19,6 @@ export function AccountSettingsPage() {
|
||||
const confirm = useConfirm();
|
||||
const navigate = useNavigate();
|
||||
const [confirmationText, setConfirmationText] = useState("");
|
||||
const isConfirmationValid = confirmationText === CONFIRMATION_TEXT;
|
||||
|
||||
const { mutate: deleteAccount } = useMutation(orpc.auth.deleteAccount.mutationOptions());
|
||||
|
||||
@@ -86,28 +84,21 @@ export function AccountSettingsPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<m.div
|
||||
initial={{ y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, ease: "easeOut" }}
|
||||
className="grid max-w-xl gap-6 will-change-[transform,opacity]"
|
||||
>
|
||||
<div className="grid max-w-xl gap-6">
|
||||
<div className="grid gap-3">
|
||||
<p className="leading-relaxed">
|
||||
<Trans>Download a copy of all your data, including your profile and every resume, as a JSON file.</Trans>
|
||||
</p>
|
||||
|
||||
<m.div
|
||||
className="justify-self-start will-change-transform"
|
||||
whileHover={{ y: -1, scale: 1.01 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
transition={{ duration: 0.14, ease: "easeOut" }}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="justify-self-start"
|
||||
onClick={() => exportData(undefined)}
|
||||
disabled={isExporting}
|
||||
>
|
||||
<Button variant="outline" onClick={() => exportData(undefined)} disabled={isExporting}>
|
||||
<DownloadSimpleIcon />
|
||||
<Trans>Export my data</Trans>
|
||||
</Button>
|
||||
</m.div>
|
||||
<DownloadSimpleIcon />
|
||||
<Trans>Export my data</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<hr className="border-border" />
|
||||
@@ -123,17 +114,15 @@ export function AccountSettingsPage() {
|
||||
placeholder={t`Type "${CONFIRMATION_TEXT}" to confirm`}
|
||||
/>
|
||||
|
||||
<m.div
|
||||
className="justify-self-end will-change-transform"
|
||||
whileHover={!isConfirmationValid ? undefined : { y: -1, scale: 1.01 }}
|
||||
whileTap={!isConfirmationValid ? undefined : { scale: 0.98 }}
|
||||
transition={{ duration: 0.14, ease: "easeOut" }}
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="justify-self-end"
|
||||
onClick={handleDeleteAccount}
|
||||
disabled={confirmationText !== CONFIRMATION_TEXT}
|
||||
>
|
||||
<Button variant="destructive" onClick={handleDeleteAccount} disabled={!isConfirmationValid}>
|
||||
<TrashSimpleIcon />
|
||||
<Trans>Delete Account</Trans>
|
||||
</Button>
|
||||
</m.div>
|
||||
</m.div>
|
||||
<TrashSimpleIcon />
|
||||
<Trans>Delete Account</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { BookOpenIcon, KeyIcon, LinkSimpleIcon, PlusIcon, TrashSimpleIcon } from "@phosphor-icons/react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { AnimatePresence, m } from "motion/react";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { Separator } from "@reactive-resume/ui/components/separator";
|
||||
import { toast } from "@reactive-resume/ui/components/toast";
|
||||
@@ -67,12 +66,7 @@ export function ApiKeysSettingsPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<m.div
|
||||
initial={{ y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, ease: "easeOut" }}
|
||||
className="grid max-w-xl gap-6 will-change-[transform,opacity]"
|
||||
>
|
||||
<div className="grid max-w-xl gap-6">
|
||||
<div className="flex items-start gap-4 rounded-md border bg-popover p-6">
|
||||
<div className="rounded-md bg-primary/10 p-2.5">
|
||||
<BookOpenIcon className="text-primary" size={24} />
|
||||
@@ -115,43 +109,27 @@ export function ApiKeysSettingsPage() {
|
||||
<Trans>Create a new API key</Trans>
|
||||
</Button>
|
||||
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
{apiKeys.map((key, index) => (
|
||||
<m.div
|
||||
key={key.id}
|
||||
className="flex items-center gap-x-4 py-4 will-change-[transform,opacity]"
|
||||
initial={{ opacity: 0, y: -16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -16 }}
|
||||
transition={{ duration: 0.16, delay: Math.min(0.12, index * 0.04) }}
|
||||
>
|
||||
<KeyIcon />
|
||||
{apiKeys.map((key) => (
|
||||
<div key={key.id} className="flex items-center gap-x-4 py-4">
|
||||
<KeyIcon />
|
||||
|
||||
<div className="flex-1 space-y-1">
|
||||
<p className="font-mono text-xs">{key.start}...</p>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{key.expiresAt ? (
|
||||
<Trans>Expires on {key.expiresAt.toLocaleDateString()}</Trans>
|
||||
) : (
|
||||
<Trans>Never expires</Trans>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 space-y-1">
|
||||
<p className="font-mono text-xs">{key.start}...</p>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{key.expiresAt ? (
|
||||
<Trans>Expires on {key.expiresAt.toLocaleDateString()}</Trans>
|
||||
) : (
|
||||
<Trans>Never expires</Trans>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<m.div
|
||||
className="will-change-transform"
|
||||
whileHover={{ y: -1, scale: 1.03 }}
|
||||
whileTap={{ scale: 0.96 }}
|
||||
transition={{ duration: 0.14, ease: "easeOut" }}
|
||||
>
|
||||
<Button size="icon" variant="ghost" onClick={() => onDelete(key.id)}>
|
||||
<TrashSimpleIcon />
|
||||
</Button>
|
||||
</m.div>
|
||||
</m.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
<Button size="icon" variant="ghost" onClick={() => onDelete(key.id)}>
|
||||
<TrashSimpleIcon />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</m.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { ArrowRightIcon } from "@phosphor-icons/react";
|
||||
import { m } from "motion/react";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { Label } from "@reactive-resume/ui/components/label";
|
||||
import { LocaleCombobox } from "@/features/locale/combobox";
|
||||
@@ -8,12 +7,7 @@ import { ThemeCombobox } from "@/features/theme/combobox";
|
||||
|
||||
export function PreferencesSettingsPage() {
|
||||
return (
|
||||
<m.div
|
||||
initial={{ y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, ease: "easeOut" }}
|
||||
className="grid max-w-xl gap-6 will-change-[transform,opacity]"
|
||||
>
|
||||
<div className="grid max-w-xl gap-6">
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="mb-0.5">
|
||||
<Trans>Theme</Trans>
|
||||
@@ -39,6 +33,6 @@ export function PreferencesSettingsPage() {
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</m.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Input } from "@reactive-resume/ui/components/input";
|
||||
import { toast } from "@reactive-resume/ui/components/toast";
|
||||
import { authClient } from "@/libs/auth/client";
|
||||
import { getReadableErrorMessage } from "@/libs/error-message";
|
||||
import { EASE_OUT_STRONG } from "@/libs/motion";
|
||||
import { useAppForm } from "@/libs/tanstack-form";
|
||||
|
||||
const formSchema = z.object({
|
||||
@@ -136,11 +137,8 @@ export function ProfileSettingsPage({ session }: Props) {
|
||||
};
|
||||
|
||||
return (
|
||||
<m.form
|
||||
initial={{ y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, ease: "easeOut" }}
|
||||
className="grid max-w-xl gap-6 will-change-[transform,opacity]"
|
||||
<form
|
||||
className="grid max-w-xl gap-6"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
@@ -258,8 +256,8 @@ export function ProfileSettingsPage({ session }: Props) {
|
||||
initial={{ opacity: 0, y: -8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -8 }}
|
||||
transition={{ duration: 0.16, ease: "easeOut" }}
|
||||
className="flex items-center gap-x-4 justify-self-end will-change-[transform,opacity]"
|
||||
transition={{ duration: 0.16, ease: EASE_OUT_STRONG }}
|
||||
className="flex items-center gap-x-4 justify-self-end"
|
||||
>
|
||||
<Button type="reset" variant="ghost" onClick={onCancel}>
|
||||
<Trans comment="Profile settings form action to discard unsaved edits">Cancel</Trans>
|
||||
@@ -271,6 +269,6 @@ export function ProfileSettingsPage({ session }: Props) {
|
||||
</m.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</m.form>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,19 +12,35 @@ type ThemeContextValue = {
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue | null>(null);
|
||||
|
||||
// Swap the theme class with transitions suppressed for one frame, so every color changes at once
|
||||
// instead of each element easing between palettes on its own schedule.
|
||||
function applyThemeClass(theme: Theme) {
|
||||
const root = document.documentElement;
|
||||
const isDark = theme === "dark";
|
||||
if (root.classList.contains("dark") === isDark) return;
|
||||
|
||||
const style = document.createElement("style");
|
||||
style.textContent = "*,*::before,*::after{transition:none!important}";
|
||||
document.head.append(style);
|
||||
|
||||
root.classList.toggle("dark", isDark);
|
||||
void window.getComputedStyle(root).color; // force a style flush while transitions are off
|
||||
setTimeout(() => style.remove(), 1); // rAF would never fire in a hidden tab, leaving transitions off
|
||||
}
|
||||
|
||||
type Props = PropsWithChildren<{ theme: Theme }>;
|
||||
|
||||
export function ThemeProvider({ children, theme }: Props) {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.classList.toggle("dark", theme === "dark");
|
||||
applyThemeClass(theme);
|
||||
}, [theme]);
|
||||
|
||||
async function setTheme(value: Theme, options: { playSound?: boolean } = {}) {
|
||||
const { playSound = true } = options;
|
||||
|
||||
document.documentElement.classList.toggle("dark", value === "dark");
|
||||
applyThemeClass(value);
|
||||
setThemeCookie(value);
|
||||
void router.invalidate();
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useLingui } from "@lingui/react";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { PaletteIcon, SignOutIcon, TranslateIcon } from "@phosphor-icons/react";
|
||||
import { useRouter } from "@tanstack/react-router";
|
||||
import { useIsClient } from "usehooks-ts";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -30,7 +29,6 @@ type Props = {
|
||||
};
|
||||
|
||||
export function UserDropdownMenu({ children }: Props) {
|
||||
const isClient = useIsClient();
|
||||
const router = useRouter();
|
||||
const { i18n } = useLingui();
|
||||
const { theme, setTheme } = useTheme();
|
||||
@@ -67,7 +65,6 @@ export function UserDropdownMenu({ children }: Props) {
|
||||
});
|
||||
};
|
||||
|
||||
if (!isClient) return null;
|
||||
if (!session?.user) return null;
|
||||
|
||||
return (
|
||||
|
||||
@@ -1 +1,13 @@
|
||||
@import "@reactive-resume/ui/globals.css";
|
||||
|
||||
@keyframes spotlight-drift {
|
||||
to {
|
||||
translate: var(--spotlight-drift);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-prompt-marquee {
|
||||
to {
|
||||
transform: translateX(-33.333%);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
// Mirrors `--ease-out-strong` in packages/ui/src/styles/globals.css for Motion (JS) animations.
|
||||
export const EASE_OUT_STRONG = [0.23, 1, 0.32, 1] as const;
|
||||
@@ -15,7 +15,6 @@ export const getRouter = async () => {
|
||||
const router = createRouter({
|
||||
routeTree,
|
||||
scrollRestoration: true,
|
||||
defaultViewTransition: true,
|
||||
defaultStructuralSharing: true,
|
||||
defaultErrorComponent: ErrorScreen,
|
||||
defaultPendingComponent: LoadingScreen,
|
||||
|
||||
@@ -15,7 +15,7 @@ import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ReactQueryDevtoolsPanel } from "@tanstack/react-query-devtools";
|
||||
import { createRootRouteWithContext, HeadContent, Outlet, useRouterState } from "@tanstack/react-router";
|
||||
import { TanStackRouterDevtoolsPanel } from "@tanstack/react-router-devtools";
|
||||
import { domAnimation, LazyMotion, MotionConfig } from "motion/react";
|
||||
import { domMax, LazyMotion, MotionConfig } from "motion/react";
|
||||
import { useEffect } from "react";
|
||||
import { Toaster } from "@reactive-resume/ui/components/toast";
|
||||
import { TooltipProvider } from "@reactive-resume/ui/components/tooltip";
|
||||
@@ -110,7 +110,7 @@ function RootComponent() {
|
||||
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MotionConfig reducedMotion="user">
|
||||
<LazyMotion features={domAnimation}>
|
||||
<LazyMotion features={domMax}>
|
||||
<I18nProvider i18n={i18n}>
|
||||
<IconContext.Provider value={iconContextValue}>
|
||||
<ThemeProvider theme={theme}>
|
||||
|
||||
@@ -3,10 +3,10 @@ import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { GithubLogoIcon, LinkedinLogoIcon, XLogoIcon } from "@phosphor-icons/react";
|
||||
import { m } from "motion/react";
|
||||
import { useState } from "react";
|
||||
import { BrandIcon } from "@reactive-resume/ui/components/brand-icon";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { Copyright } from "@/components/ui/copyright";
|
||||
import { EASE_OUT_STRONG } from "@/libs/motion";
|
||||
|
||||
type FooterLinkItem = {
|
||||
url: string;
|
||||
@@ -48,11 +48,11 @@ export function Footer() {
|
||||
return (
|
||||
<m.footer
|
||||
id="footer"
|
||||
className="p-4 pb-8 will-change-[opacity] md:p-8 md:pb-12"
|
||||
className="p-4 pb-8 md:p-8 md:pb-12"
|
||||
initial={{ opacity: 0 }}
|
||||
whileInView={{ opacity: 1 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.45 }}
|
||||
transition={{ duration: 0.45, ease: EASE_OUT_STRONG }}
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-8 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{/* Brand Column */}
|
||||
@@ -121,31 +121,19 @@ function FooterLinkGroup({ title, links }: FooterLinkGroupProps) {
|
||||
}
|
||||
|
||||
function FooterLink({ url, label }: FooterLinkItem) {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
return (
|
||||
<li className="relative">
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="relative inline-block text-sm transition-colors hover:text-foreground"
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
className="relative inline-block text-sm transition-colors after:pointer-events-none after:absolute after:inset-x-0 after:-bottom-0.5 after:h-px after:origin-left after:scale-x-0 after:rounded-md after:bg-primary after:content-[''] hover:text-foreground hover:after:scale-x-100 rtl:after:origin-right after:[transition:scale_200ms_var(--ease-out-strong)]"
|
||||
>
|
||||
{label}
|
||||
|
||||
<span className="sr-only">
|
||||
<Trans>(opens in new tab)</Trans>
|
||||
</span>
|
||||
|
||||
<m.div
|
||||
aria-hidden="true"
|
||||
initial={{ width: 0, opacity: 0 }}
|
||||
animate={isHovered ? { width: "100%", opacity: 1 } : { width: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
className="pointer-events-none absolute inset-s-0 -bottom-0.5 h-px rounded-md bg-primary will-change-[width,opacity]"
|
||||
/>
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { ArrowRightIcon, TranslateIcon } from "@phosphor-icons/react";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { m, useMotionValue, useSpring } from "motion/react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useMotionValueEvent, useScroll } from "motion/react";
|
||||
import { useState } from "react";
|
||||
import { BrandIcon } from "@reactive-resume/ui/components/brand-icon";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { GithubStarsButton } from "@/components/input/github-stars-button";
|
||||
@@ -10,43 +10,17 @@ import { LocaleCombobox } from "@/features/locale/combobox";
|
||||
import { ThemeToggleButton } from "@/features/theme/toggle-button";
|
||||
|
||||
export function Header() {
|
||||
const y = useMotionValue(0);
|
||||
const lastScroll = useRef(0);
|
||||
const ticking = useRef(false);
|
||||
const springY = useSpring(y, { stiffness: 300, damping: 40 });
|
||||
const [hidden, setHidden] = useState(false);
|
||||
const { scrollY } = useScroll();
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
function onScroll() {
|
||||
const current = window.scrollY ?? 0;
|
||||
if (!ticking.current) {
|
||||
window.requestAnimationFrame(() => {
|
||||
if (current > 32 && current > lastScroll.current) {
|
||||
// Scrolling down, hide
|
||||
y.set(-100);
|
||||
} else {
|
||||
// Scrolling up, show
|
||||
y.set(0);
|
||||
}
|
||||
lastScroll.current = current;
|
||||
ticking.current = false;
|
||||
});
|
||||
ticking.current = true;
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => window.removeEventListener("scroll", onScroll);
|
||||
}, [y]);
|
||||
useMotionValueEvent(scrollY, "change", (current) => {
|
||||
setHidden(current > 32 && current > (scrollY.getPrevious() ?? 0));
|
||||
});
|
||||
|
||||
return (
|
||||
<m.header
|
||||
style={{ y: springY }}
|
||||
className="fixed inset-x-0 top-0 z-50 border-transparent border-b bg-background/80 backdrop-blur-lg transition-colors"
|
||||
initial={{ y: -100, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ duration: 0.35, ease: "easeOut" }}
|
||||
<header
|
||||
data-hidden={hidden}
|
||||
className="fade-in animation-duration-300 fixed inset-x-0 top-0 z-50 animate-in border-transparent border-b bg-background/80 backdrop-blur-lg ease-out-strong [transition:translate_250ms_var(--ease-out-strong)] data-[hidden=true]:-translate-y-full"
|
||||
>
|
||||
<nav aria-label={t`Main navigation`} className="container mx-auto flex items-center gap-x-4 p-3 lg:px-12">
|
||||
<Link to="/" className="transition-opacity hover:opacity-80" aria-label={t`Reactive Resume - Go to homepage`}>
|
||||
@@ -80,6 +54,6 @@ export function Header() {
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</m.header>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Spotlight } from "@/components/animation/spotlight";
|
||||
import { AiReviewCard } from "@/features/ats-checker/ai-review/ai-review-card";
|
||||
import { LockedAiCard } from "@/features/ats-checker/ai-review/locked-card";
|
||||
import { ParsePreview } from "@/features/ats-checker/parse-preview";
|
||||
import { EASE_OUT_STRONG } from "@/libs/motion";
|
||||
import { Footer } from "./-sections/footer";
|
||||
|
||||
const PAGE_TITLE = "ATS Checker - Reactive Resume";
|
||||
@@ -91,10 +92,10 @@ function Header() {
|
||||
|
||||
<div className="relative grid items-center gap-8 p-4 md:p-8 lg:grid-cols-[minmax(0,1fr)_minmax(0,32rem)] lg:gap-12 xl:py-12">
|
||||
<m.div
|
||||
className="space-y-5 will-change-[transform,opacity]"
|
||||
className="space-y-5"
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, ease: "easeOut" }}
|
||||
transition={{ duration: 0.5, ease: EASE_OUT_STRONG }}
|
||||
>
|
||||
<h1 className="font-semibold text-4xl tracking-tight md:text-5xl">
|
||||
<Trans>ATS Checker</Trans>
|
||||
@@ -109,10 +110,9 @@ function Header() {
|
||||
</m.div>
|
||||
|
||||
<m.div
|
||||
className="will-change-[transform,opacity]"
|
||||
initial={{ opacity: 0, y: 24 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.55, delay: 0.12, ease: "easeOut" }}
|
||||
transition={{ duration: 0.55, delay: 0.12, ease: EASE_OUT_STRONG }}
|
||||
>
|
||||
<ParsePreview />
|
||||
</m.div>
|
||||
|
||||
@@ -7,9 +7,9 @@ import { useCallback, useRef, useState } from "react";
|
||||
import { useMediaQuery } from "usehooks-ts";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { ResizableGroup, ResizablePanel, ResizableSeparator } from "@reactive-resume/ui/components/resizable";
|
||||
import { Spinner } from "@reactive-resume/ui/components/spinner";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@reactive-resume/ui/components/tabs";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { LoadingScreen } from "@/components/layout/loading-screen";
|
||||
import { orpc } from "@/libs/orpc/client";
|
||||
import { AgentChat } from "./-components/agent-chat";
|
||||
import { ResumePane } from "./-components/resume-pane";
|
||||
@@ -62,9 +62,8 @@ function RouteComponent() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (isLoading) return <LoadingScreen />;
|
||||
|
||||
if (error || !data) {
|
||||
// Loading stays local to the chat/resume panes so the thread sidebar remains mounted while switching threads.
|
||||
if (!isLoading && (error || !data)) {
|
||||
return (
|
||||
<div className="grid h-svh place-items-center bg-background p-6 text-center">
|
||||
<div className="space-y-4">
|
||||
@@ -79,12 +78,18 @@ function RouteComponent() {
|
||||
);
|
||||
}
|
||||
|
||||
const readOnlyReason: "archived" | "missing" | null = data.isReadOnly
|
||||
const readOnlyReason: "archived" | "missing" | null = data?.isReadOnly
|
||||
? data.thread.status === "archived"
|
||||
? "archived"
|
||||
: "missing"
|
||||
: null;
|
||||
|
||||
const loadingPane = (
|
||||
<div className="grid h-full place-items-center">
|
||||
<Spinner className="size-6 text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="h-svh min-w-0 overflow-hidden bg-background">
|
||||
{isDesktopLayout ? (
|
||||
@@ -104,18 +109,22 @@ function RouteComponent() {
|
||||
</ResizablePanel>
|
||||
<ResizableSeparator withHandle />
|
||||
<ResizablePanel id="chat" defaultSize="52%" minSize="280px">
|
||||
<AgentChat
|
||||
threadId={threadId}
|
||||
initialMessages={data.messages}
|
||||
isReadOnly={data.isReadOnly}
|
||||
readOnlyReason={readOnlyReason}
|
||||
threadStatus={data.thread.status}
|
||||
reviewPatches={data.thread.reviewPatches}
|
||||
activeRunId={data.thread.activeRunId}
|
||||
actions={data.actions}
|
||||
onToggleThreads={toggleThreadsPanel}
|
||||
onToggleResume={toggleResumePanel}
|
||||
/>
|
||||
{data ? (
|
||||
<AgentChat
|
||||
threadId={threadId}
|
||||
initialMessages={data.messages}
|
||||
isReadOnly={data.isReadOnly}
|
||||
readOnlyReason={readOnlyReason}
|
||||
threadStatus={data.thread.status}
|
||||
reviewPatches={data.thread.reviewPatches}
|
||||
activeRunId={data.thread.activeRunId}
|
||||
actions={data.actions}
|
||||
onToggleThreads={toggleThreadsPanel}
|
||||
onToggleResume={toggleResumePanel}
|
||||
/>
|
||||
) : (
|
||||
loadingPane
|
||||
)}
|
||||
</ResizablePanel>
|
||||
<ResizableSeparator withHandle />
|
||||
<ResizablePanel
|
||||
@@ -129,7 +138,7 @@ function RouteComponent() {
|
||||
onResize={(size) => setIsResumeCollapsed(size.inPixels < 24)}
|
||||
>
|
||||
<div className={cn("h-full", isResumeCollapsed && "invisible")}>
|
||||
<ResumePane resume={data.resume} />
|
||||
{data && <ResumePane resume={data.resume} />}
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizableGroup>
|
||||
@@ -159,19 +168,23 @@ function RouteComponent() {
|
||||
<AgentThreadSidebar activeThreadId={threadId} className="border-e-0" />
|
||||
</div>
|
||||
<div className={cn("h-full min-w-0", mobileTab !== "chat" && "hidden")}>
|
||||
<AgentChat
|
||||
threadId={threadId}
|
||||
initialMessages={data.messages}
|
||||
isReadOnly={data.isReadOnly}
|
||||
readOnlyReason={readOnlyReason}
|
||||
threadStatus={data.thread.status}
|
||||
reviewPatches={data.thread.reviewPatches}
|
||||
activeRunId={data.thread.activeRunId}
|
||||
actions={data.actions}
|
||||
/>
|
||||
{data ? (
|
||||
<AgentChat
|
||||
threadId={threadId}
|
||||
initialMessages={data.messages}
|
||||
isReadOnly={data.isReadOnly}
|
||||
readOnlyReason={readOnlyReason}
|
||||
threadStatus={data.thread.status}
|
||||
reviewPatches={data.thread.reviewPatches}
|
||||
activeRunId={data.thread.activeRunId}
|
||||
actions={data.actions}
|
||||
/>
|
||||
) : (
|
||||
loadingPane
|
||||
)}
|
||||
</div>
|
||||
<div className={cn("h-full min-w-0", mobileTab !== "resume" && "hidden")}>
|
||||
<ResumePane resume={data.resume} />
|
||||
{data && <ResumePane resume={data.resume} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -31,7 +31,6 @@ import {
|
||||
parseJsonEventStream,
|
||||
uiMessageChunkSchema,
|
||||
} from "ai";
|
||||
import { m } from "motion/react";
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
@@ -412,14 +411,12 @@ function StarterPromptMarquee({ onSelect }: StarterPromptMarqueeProps) {
|
||||
{ id: `${prompt}-repeat-b`, prompt },
|
||||
]);
|
||||
const duration = 135 + rowIndex * 22;
|
||||
const animate = rowIndex % 2 === 0 ? { x: ["0%", "-33.333%"] } : { x: ["-33.333%", "0%"] };
|
||||
|
||||
return (
|
||||
<m.div
|
||||
<div
|
||||
key={`prompt-row-${row.join("|")}`}
|
||||
className="flex w-max gap-3"
|
||||
animate={animate}
|
||||
transition={{ duration, ease: "linear", repeat: Number.POSITIVE_INFINITY }}
|
||||
className="flex w-max animate-[agent-prompt-marquee_135s_linear_infinite] gap-3 focus-within:[animation-play-state:paused] hover:[animation-play-state:paused]"
|
||||
style={{ animationDuration: `${duration}s`, animationDirection: rowIndex % 2 === 0 ? "normal" : "reverse" }}
|
||||
>
|
||||
{marqueePrompts.map(({ id, prompt }) => (
|
||||
<Button
|
||||
@@ -432,7 +429,7 @@ function StarterPromptMarquee({ onSelect }: StarterPromptMarqueeProps) {
|
||||
{promptPreview(prompt)}
|
||||
</Button>
|
||||
))}
|
||||
</m.div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,6 @@ import { ArrowRightIcon, ChatCircleDotsIcon, FilePlusIcon, GearSixIcon } from "@
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { Link, useNavigate } from "@tanstack/react-router";
|
||||
import { useState } from "react";
|
||||
import { useIsClient } from "usehooks-ts";
|
||||
import { Badge } from "@reactive-resume/ui/components/badge";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { Label } from "@reactive-resume/ui/components/label";
|
||||
@@ -29,7 +28,6 @@ function isAgentConfigError(error: unknown) {
|
||||
}
|
||||
|
||||
export function NewThreadSetup({ resumeId }: NewThreadSetupProps) {
|
||||
const isClient = useIsClient();
|
||||
const navigate = useNavigate();
|
||||
const { usableProviders, isLoading: isLoadingProviders, error: providersError } = useHasUsableAiProvider();
|
||||
const { data: resumes, isLoading: isLoadingResumes } = useQuery(
|
||||
@@ -54,8 +52,6 @@ export function NewThreadSetup({ resumeId }: NewThreadSetupProps) {
|
||||
const selectedResumeValue = sourceResumeId ?? "__scratch__";
|
||||
const canCreate = !!aiProviderId && usableProviders.length > 0;
|
||||
|
||||
if (!isClient) return null;
|
||||
|
||||
return (
|
||||
<div className="mx-auto grid w-full max-w-4xl gap-6 self-center p-4 lg:p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
} from "@phosphor-icons/react";
|
||||
import { useHotkey } from "@tanstack/react-hotkeys";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { m } from "motion/react";
|
||||
import { m, useReducedMotion } from "motion/react";
|
||||
import { useControls, useTransformComponent } from "react-zoom-pan-pinch";
|
||||
import { useCopyToClipboard } from "usehooks-ts";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
@@ -26,13 +26,13 @@ import {
|
||||
} from "@reactive-resume/ui/components/dropdown-menu";
|
||||
import { toast } from "@reactive-resume/ui/components/toast";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@reactive-resume/ui/components/tooltip";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import {
|
||||
isEditableElementFocused,
|
||||
useCurrentBuilderResumeSelector,
|
||||
useResumeStore,
|
||||
} from "@/features/resume/builder/draft";
|
||||
import { authClient } from "@/libs/auth/client";
|
||||
import { EASE_OUT_STRONG } from "@/libs/motion";
|
||||
|
||||
type BuilderDockProps = {
|
||||
pageLayout: BuilderPreviewPageLayout;
|
||||
@@ -48,13 +48,15 @@ export function BuilderDock({ pageLayout, onTogglePageLayout }: BuilderDockProps
|
||||
|
||||
const [_, copyToClipboard] = useCopyToClipboard();
|
||||
const { zoomIn, zoomOut, resetTransform } = useControls();
|
||||
const zoomDuration = useReducedMotion() ? 0 : 150;
|
||||
|
||||
const canUndo = useResumeStore((state) => state.canUndo);
|
||||
const canRedo = useResumeStore((state) => state.canRedo);
|
||||
const undo = useResumeStore((state) => state.undo);
|
||||
const redo = useResumeStore((state) => state.redo);
|
||||
|
||||
useHotkey("Mod+0", () => resetTransform());
|
||||
// Keyboard actions snap instantly; animating a shortcut makes it feel sluggish.
|
||||
useHotkey("Mod+0", () => resetTransform(0));
|
||||
// App-level undo/redo of resume state, scoped to the builder. Mod maps to Cmd (mac) / Ctrl (win/linux).
|
||||
// Inside a focused text field, defer to the browser's native input undo; the dock buttons remain
|
||||
// available for resume-level history while editing a field.
|
||||
@@ -77,18 +79,25 @@ export function BuilderDock({ pageLayout, onTogglePageLayout }: BuilderDockProps
|
||||
return (
|
||||
<div className="fixed inset-x-0 bottom-20 flex items-center justify-center md:bottom-4">
|
||||
<m.div
|
||||
initial={{ opacity: 0, y: -18 }}
|
||||
animate={{ opacity: 0.6, y: 0 }}
|
||||
whileHover={{ opacity: 1, y: -2, scale: 1.01 }}
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
className="flex items-center rounded-r-full rounded-l-full bg-popover px-2 shadow-xl will-change-[transform,opacity]"
|
||||
initial={{ opacity: 0, transform: "translateY(8px)" }}
|
||||
animate={{ opacity: 1, transform: "translateY(0px)" }}
|
||||
transition={{ duration: 0.2, ease: EASE_OUT_STRONG }}
|
||||
className="flex items-center rounded-r-full rounded-l-full bg-popover px-2 shadow-xl"
|
||||
>
|
||||
<DockIcon icon={ArrowUUpLeftIcon} title={t`Undo`} disabled={!canUndo} onClick={() => undo()} />
|
||||
<DockIcon icon={ArrowUUpRightIcon} title={t`Redo`} disabled={!canRedo} onClick={() => redo()} />
|
||||
<div className="mx-1 h-8 w-px bg-border" />
|
||||
<DockIcon icon={MagnifyingGlassMinusIcon} title={t`Zoom out`} onClick={() => zoomOut(0.15)} />
|
||||
<DockIcon
|
||||
icon={MagnifyingGlassMinusIcon}
|
||||
title={t`Zoom out`}
|
||||
onClick={() => zoomOut(0.15, zoomDuration, "easeOutCubic")}
|
||||
/>
|
||||
<ZoomMenu />
|
||||
<DockIcon icon={MagnifyingGlassPlusIcon} title={t`Zoom in`} onClick={() => zoomIn(0.15)} />
|
||||
<DockIcon
|
||||
icon={MagnifyingGlassPlusIcon}
|
||||
title={t`Zoom in`}
|
||||
onClick={() => zoomIn(0.15, zoomDuration, "easeOutCubic")}
|
||||
/>
|
||||
<DockIcon
|
||||
icon={pageLayout === "horizontal" ? AlignTopIcon : AlignCenterHorizontalIcon}
|
||||
title={t`Toggle page stacking`}
|
||||
@@ -119,6 +128,7 @@ export function BuilderDock({ pageLayout, onTogglePageLayout }: BuilderDockProps
|
||||
function ZoomMenu() {
|
||||
const scale = useTransformComponent((ctx) => ctx.state.scale);
|
||||
const { centerView, resetTransform } = useControls();
|
||||
const zoomDuration = useReducedMotion() ? 0 : 200;
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
@@ -136,10 +146,10 @@ function ZoomMenu() {
|
||||
/>
|
||||
|
||||
<DropdownMenuContent side="top" align="center">
|
||||
<DropdownMenuItem onClick={() => centerView(1)}>
|
||||
<DropdownMenuItem onClick={() => centerView(1, zoomDuration, "easeOutCubic")}>
|
||||
<Trans>Actual size (100%)</Trans>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => resetTransform()}>
|
||||
<DropdownMenuItem onClick={() => resetTransform(zoomDuration, "easeOutCubic")}>
|
||||
<Trans>Fit to view</Trans>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
@@ -152,34 +162,17 @@ type DockIconProps = {
|
||||
icon: Icon;
|
||||
disabled?: boolean;
|
||||
onClick: () => void;
|
||||
iconClassName?: string;
|
||||
active?: boolean;
|
||||
};
|
||||
|
||||
function DockIcon({ icon: Icon, title, disabled, onClick, iconClassName, active }: DockIconProps) {
|
||||
function DockIcon({ icon: Icon, title, disabled, onClick }: DockIconProps) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<m.div
|
||||
className="will-change-transform"
|
||||
whileHover={disabled ? undefined : { y: -1, scale: 1.04 }}
|
||||
whileTap={disabled ? undefined : { scale: 0.97 }}
|
||||
transition={{ duration: 0.15, ease: "easeOut" }}
|
||||
>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
disabled={disabled}
|
||||
className={cn(active && "bg-primary/10 text-primary hover:bg-primary/15 hover:text-primary")}
|
||||
onClick={onClick}
|
||||
aria-label={title}
|
||||
>
|
||||
<Icon className={cn("size-4", iconClassName)} />
|
||||
</Button>
|
||||
</m.div>
|
||||
}
|
||||
/>
|
||||
{/* A disabled button ignores the pointer, so a wrapper keeps the tooltip working for Undo/Redo. */}
|
||||
<TooltipTrigger render={<span className="inline-flex" />}>
|
||||
<Button size="icon" variant="ghost" disabled={disabled} onClick={onClick} aria-label={title}>
|
||||
<Icon className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
|
||||
<TooltipContent side="top" align="center" className="font-medium">
|
||||
{title}
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@reactive-resume/ui/components/dropdown-menu";
|
||||
import { toast } from "@reactive-resume/ui/components/toast";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import {
|
||||
useCurrentBuilderResumeSelector,
|
||||
@@ -137,9 +138,9 @@ function ResumeDownloadButton() {
|
||||
|
||||
function SaveStatusIndicator() {
|
||||
const status = useResumeStore((state) => state.saveStatus);
|
||||
if (status === "idle") return null;
|
||||
|
||||
const { icon, label } = match(status)
|
||||
.with("idle", () => ({ icon: null, label: "" }))
|
||||
.with("saving", () => ({
|
||||
icon: <CircleNotchIcon className="animate-spin" />,
|
||||
label: t`Saving…`,
|
||||
@@ -153,7 +154,11 @@ function SaveStatusIndicator() {
|
||||
|
||||
return (
|
||||
<span
|
||||
className="ms-1 flex shrink-0 items-center gap-x-1 text-muted-foreground text-xs"
|
||||
// Always reserve the slot so the header buttons don't shift when the status appears or clears.
|
||||
className={cn(
|
||||
"ms-1 flex min-w-3 shrink-0 items-center gap-x-1 text-muted-foreground text-xs md:min-w-18",
|
||||
status === "idle" && "invisible",
|
||||
)}
|
||||
aria-live="polite"
|
||||
role="status"
|
||||
>
|
||||
|
||||
@@ -53,7 +53,7 @@ function MobileBuilderTabBar({ activeTab, onTabChange }: MobileBuilderTabBarProp
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
onClick={() => onTabChange(value)}
|
||||
className={cn(
|
||||
"flex min-h-16 flex-1 flex-col items-center justify-center gap-1 text-xs transition-colors",
|
||||
"flex min-h-16 flex-1 flex-col items-center justify-center gap-1 text-xs transition-[color,scale] duration-150 ease-out-strong active:scale-[0.97]",
|
||||
isActive ? "text-primary" : "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useHotkey } from "@tanstack/react-hotkeys";
|
||||
import { useReducedMotion } from "motion/react";
|
||||
import { Suspense, useState } from "react";
|
||||
import { TransformComponent, TransformWrapper } from "react-zoom-pan-pinch";
|
||||
import { toast } from "@reactive-resume/ui/components/toast";
|
||||
@@ -11,6 +12,7 @@ import { blurFocusedElementOnPan } from "./pan-focus";
|
||||
|
||||
export function PreviewPage() {
|
||||
const [pageLayout, setPageLayout] = useState(DEFAULT_BUILDER_PREVIEW_PAGE_LAYOUT);
|
||||
const reduceMotion = useReducedMotion();
|
||||
|
||||
useHotkey("Mod+S", () => {
|
||||
toast.add({
|
||||
@@ -30,6 +32,7 @@ export function PreviewPage() {
|
||||
initialScale={0.75}
|
||||
limitToBounds={false}
|
||||
wheel={{ step: 0.001 }}
|
||||
doubleClick={{ animationType: "easeOutCubic", animationTime: reduceMotion ? 0 : 200 }}
|
||||
onPanningStart={blurFocusedElementOnPan}
|
||||
>
|
||||
{/* Zoom transforms use left-origin coordinates, regardless of the interface language. */}
|
||||
|
||||
@@ -28,40 +28,32 @@ import { orpc } from "@/libs/orpc/client";
|
||||
import { getSectionIcon, getSectionTitle, leftSidebarSections } from "@/libs/resume/section";
|
||||
import { BuilderSidebarEdge } from "../../-components/edge";
|
||||
import { useBuilderSidebar } from "../../-store/sidebar";
|
||||
import { AwardsSectionBuilder } from "./sections/awards";
|
||||
import { BasicsSectionBuilder } from "./sections/basics";
|
||||
import { CertificationsSectionBuilder } from "./sections/certifications";
|
||||
import { CustomSectionBuilder } from "./sections/custom";
|
||||
import { EducationSectionBuilder } from "./sections/education";
|
||||
import { ExperienceSectionBuilder } from "./sections/experience";
|
||||
import { InterestsSectionBuilder } from "./sections/interests";
|
||||
import { LanguagesSectionBuilder } from "./sections/languages";
|
||||
import { PictureSectionBuilder } from "./sections/picture";
|
||||
import { ProfilesSectionBuilder } from "./sections/profiles";
|
||||
import { ProjectsSectionBuilder } from "./sections/projects";
|
||||
import { PublicationsSectionBuilder } from "./sections/publications";
|
||||
import { ReferencesSectionBuilder } from "./sections/references";
|
||||
import { SkillsSectionBuilder } from "./sections/skills";
|
||||
import { SummarySectionBuilder } from "./sections/summary";
|
||||
import { VolunteerSectionBuilder } from "./sections/volunteer";
|
||||
import { ItemsSection } from "./shared/items-section";
|
||||
|
||||
function getSectionComponent(type: LeftSidebarSection) {
|
||||
return match(type)
|
||||
.with("picture", () => <PictureSectionBuilder />)
|
||||
.with("basics", () => <BasicsSectionBuilder />)
|
||||
.with("summary", () => <SummarySectionBuilder />)
|
||||
.with("profiles", () => <ProfilesSectionBuilder />)
|
||||
.with("experience", () => <ExperienceSectionBuilder />)
|
||||
.with("education", () => <EducationSectionBuilder />)
|
||||
.with("projects", () => <ProjectsSectionBuilder />)
|
||||
.with("skills", () => <SkillsSectionBuilder />)
|
||||
.with("languages", () => <LanguagesSectionBuilder />)
|
||||
.with("interests", () => <InterestsSectionBuilder />)
|
||||
.with("awards", () => <AwardsSectionBuilder />)
|
||||
.with("certifications", () => <CertificationsSectionBuilder />)
|
||||
.with("publications", () => <PublicationsSectionBuilder />)
|
||||
.with("volunteer", () => <VolunteerSectionBuilder />)
|
||||
.with("references", () => <ReferencesSectionBuilder />)
|
||||
.with(
|
||||
"profiles",
|
||||
"experience",
|
||||
"education",
|
||||
"projects",
|
||||
"skills",
|
||||
"languages",
|
||||
"interests",
|
||||
"awards",
|
||||
"certifications",
|
||||
"publications",
|
||||
"volunteer",
|
||||
"references",
|
||||
(type) => <ItemsSection type={type} />,
|
||||
)
|
||||
.with("custom", () => <CustomSectionBuilder />)
|
||||
.exhaustive();
|
||||
}
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import type { awardItemSchema } from "@reactive-resume/schema/resume/data";
|
||||
import type z from "zod";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { AnimatePresence, Reorder } from "motion/react";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { useCurrentBuilderResumeSelector, useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
|
||||
|
||||
export function AwardsSectionBuilder() {
|
||||
const section = useCurrentBuilderResumeSelector((resume) => resume.data.sections.awards);
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
const handleReorder = (items: z.infer<typeof awardItemSchema>[]) => {
|
||||
updateResumeData((draft) => {
|
||||
draft.sections.awards.items = items;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionBase type="awards" className={cn("rounded-md border", section.items.length === 0 && "border-dashed")}>
|
||||
<Reorder.Group axis="y" values={section.items} onReorder={handleReorder}>
|
||||
<AnimatePresence>
|
||||
{section.items.map((item) => (
|
||||
<SectionItem key={item.id} type="awards" item={item} title={item.title} subtitle={item.awarder} />
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</Reorder.Group>
|
||||
|
||||
<SectionAddItemButton type="awards">
|
||||
<Trans>Add a new award</Trans>
|
||||
</SectionAddItemButton>
|
||||
</SectionBase>
|
||||
);
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import type { certificationItemSchema } from "@reactive-resume/schema/resume/data";
|
||||
import type z from "zod";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { AnimatePresence, Reorder } from "motion/react";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { useCurrentBuilderResumeSelector, useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
|
||||
|
||||
export function CertificationsSectionBuilder() {
|
||||
const section = useCurrentBuilderResumeSelector((resume) => resume.data.sections.certifications);
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
const handleReorder = (items: z.infer<typeof certificationItemSchema>[]) => {
|
||||
updateResumeData((draft) => {
|
||||
draft.sections.certifications.items = items;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionBase
|
||||
type="certifications"
|
||||
className={cn("rounded-md border", section.items.length === 0 && "border-dashed")}
|
||||
>
|
||||
<Reorder.Group axis="y" values={section.items} onReorder={handleReorder}>
|
||||
<AnimatePresence>
|
||||
{section.items.map((item) => (
|
||||
<SectionItem
|
||||
key={item.id}
|
||||
type="certifications"
|
||||
item={item}
|
||||
title={item.title}
|
||||
subtitle={[item.issuer, item.date].filter(Boolean).join(" • ") || undefined}
|
||||
/>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</Reorder.Group>
|
||||
|
||||
<SectionAddItemButton type="certifications">
|
||||
<Trans>Add a new certification</Trans>
|
||||
</SectionAddItemButton>
|
||||
</SectionBase>
|
||||
);
|
||||
}
|
||||
@@ -33,7 +33,7 @@ export const CustomFieldsSection = withForm({
|
||||
<form.Field name="customFields" mode="array">
|
||||
{(customFieldsField) => (
|
||||
<Reorder.Group
|
||||
className="touch-none space-y-4"
|
||||
className="space-y-4"
|
||||
values={customFieldsField.state.value}
|
||||
onReorder={(fields) => {
|
||||
customFieldsField.setValue(fields);
|
||||
@@ -152,15 +152,7 @@ function CustomFieldItem({ field, children }: CustomFieldItemProps) {
|
||||
const controls = useDragControls();
|
||||
|
||||
return (
|
||||
<Reorder.Item
|
||||
key={field.id}
|
||||
value={field}
|
||||
dragListener={false}
|
||||
dragControls={controls}
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="flex touch-none items-center"
|
||||
>
|
||||
<Reorder.Item value={field} dragListener={false} dragControls={controls} className="flex items-center">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
PencilSimpleLineIcon,
|
||||
TrashSimpleIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { AnimatePresence, Reorder } from "motion/react";
|
||||
import { Badge } from "@reactive-resume/ui/components/badge";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -35,39 +34,11 @@ import { useDialogStore } from "@/dialogs/store";
|
||||
import { useCurrentBuilderResumeSelector, useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { useConfirm } from "@/hooks/use-confirm";
|
||||
import { getSectionTitle } from "@/libs/resume/section";
|
||||
import { SECTIONS, SectionItemList } from "../shared/items-section";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
|
||||
import { SectionAddItemButton } from "../shared/section-item";
|
||||
import { SkillKeywordLayoutMenu } from "../shared/skill-keyword-layout-menu";
|
||||
|
||||
// ponytail: data maps replace ts-pattern exhaustive matchers — same coverage, no runtime dependency
|
||||
const TITLE_FIELD: Partial<Record<CustomSectionType, string>> = {
|
||||
profiles: "network",
|
||||
experience: "company",
|
||||
education: "school",
|
||||
projects: "name",
|
||||
skills: "name",
|
||||
languages: "language",
|
||||
interests: "name",
|
||||
awards: "title",
|
||||
certifications: "title",
|
||||
publications: "title",
|
||||
volunteer: "organization",
|
||||
references: "name",
|
||||
};
|
||||
|
||||
const SUBTITLE_FIELD: Partial<Record<CustomSectionType, string>> = {
|
||||
profiles: "username",
|
||||
experience: "position",
|
||||
education: "degree",
|
||||
projects: "period",
|
||||
skills: "proficiency",
|
||||
languages: "fluency",
|
||||
awards: "awarder",
|
||||
certifications: "issuer",
|
||||
publications: "publisher",
|
||||
volunteer: "period",
|
||||
};
|
||||
|
||||
function truncateHtml(html: string, max = 50): string {
|
||||
const stripped = stripHtml(html);
|
||||
return stripped.length > max ? `${stripped.slice(0, max)}...` : stripped;
|
||||
@@ -104,8 +75,7 @@ function getItemTitle(type: CustomSectionType, item: CustomSectionItemType): str
|
||||
message: "Cover Letter",
|
||||
});
|
||||
}
|
||||
const field = TITLE_FIELD[type];
|
||||
return field && field in item ? String((item as Record<string, unknown>)[field]) : "";
|
||||
return SECTIONS[type].title(item as never);
|
||||
}
|
||||
|
||||
function getItemSubtitle(type: CustomSectionType, item: CustomSectionItemType): string | undefined {
|
||||
@@ -116,10 +86,8 @@ function getItemSubtitle(type: CustomSectionType, item: CustomSectionItemType):
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
const field = SUBTITLE_FIELD[type];
|
||||
if (!field || !(field in item)) return undefined;
|
||||
const value = (item as Record<string, unknown>)[field];
|
||||
return typeof value === "string" ? value || undefined : undefined;
|
||||
if (type === "summary") return undefined;
|
||||
return SECTIONS[type].subtitle?.(item as never) || undefined;
|
||||
}
|
||||
|
||||
export function CustomSectionBuilder() {
|
||||
@@ -127,13 +95,11 @@ export function CustomSectionBuilder() {
|
||||
|
||||
return (
|
||||
<SectionBase type="custom" className={cn("space-y-4", customSections.length === 0 && "border-dashed")}>
|
||||
<AnimatePresence>
|
||||
{customSections
|
||||
.filter((section) => !section.hidden)
|
||||
.map((section) => (
|
||||
<CustomSectionContainer key={section.id} section={section} />
|
||||
))}
|
||||
</AnimatePresence>
|
||||
{customSections
|
||||
.filter((section) => !section.hidden)
|
||||
.map((section) => (
|
||||
<CustomSectionContainer key={section.id} section={section} />
|
||||
))}
|
||||
|
||||
{/* Add Custom Section Button */}
|
||||
<SectionAddItemButton type="custom" variant="outline" className="rounded-md">
|
||||
@@ -190,20 +156,14 @@ function CustomSectionContainer({ section }: CustomSectionContainerProps) {
|
||||
{/* Section Items */}
|
||||
{section.items.length > 0 && (
|
||||
<div className={cn("border-t", section.hidden && "opacity-50")}>
|
||||
<Reorder.Group axis="y" values={section.items} onReorder={handleReorder}>
|
||||
<AnimatePresence>
|
||||
{section.items.map((item) => (
|
||||
<SectionItem
|
||||
key={item.id}
|
||||
type={section.type}
|
||||
item={item}
|
||||
customSectionId={section.id}
|
||||
title={getItemTitle(section.type, item)}
|
||||
subtitle={getItemSubtitle(section.type, item)}
|
||||
/>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</Reorder.Group>
|
||||
<SectionItemList
|
||||
type={section.type}
|
||||
items={section.items}
|
||||
onReorder={handleReorder}
|
||||
customSectionId={section.id}
|
||||
getTitle={(item) => getItemTitle(section.type, item)}
|
||||
getSubtitle={(item) => getItemSubtitle(section.type, item)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
+3
-2
@@ -100,8 +100,9 @@ vi.mock("../shared/section-item", () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
const { EducationSectionBuilder } = await import("./education");
|
||||
const { ExperienceSectionBuilder } = await import("./experience");
|
||||
const { ItemsSection } = await import("../shared/items-section");
|
||||
const EducationSectionBuilder = () => <ItemsSection type="education" />;
|
||||
const ExperienceSectionBuilder = () => <ItemsSection type="experience" />;
|
||||
|
||||
beforeAll(() => {
|
||||
i18n.loadAndActivate({ locale: "en", messages: {} });
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import type { educationItemSchema } from "@reactive-resume/schema/resume/data";
|
||||
import type z from "zod";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { AnimatePresence, Reorder } from "motion/react";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { useCurrentBuilderResumeSelector, useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
|
||||
|
||||
export function EducationSectionBuilder() {
|
||||
const section = useCurrentBuilderResumeSelector((resume) => resume.data.sections.education);
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
const handleReorder = (items: z.infer<typeof educationItemSchema>[]) => {
|
||||
updateResumeData((draft) => {
|
||||
draft.sections.education.items = items;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionBase type="education" className={cn("rounded-md border", section.items.length === 0 && "border-dashed")}>
|
||||
<Reorder.Group axis="y" values={section.items} onReorder={handleReorder}>
|
||||
<AnimatePresence>
|
||||
{section.items.map((item) => (
|
||||
<SectionItem key={item.id} type="education" item={item} title={item.school} subtitle={item.degree} />
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</Reorder.Group>
|
||||
|
||||
<SectionAddItemButton type="education">
|
||||
<Trans>Add a new education</Trans>
|
||||
</SectionAddItemButton>
|
||||
</SectionBase>
|
||||
);
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import type { experienceItemSchema } from "@reactive-resume/schema/resume/data";
|
||||
import type z from "zod";
|
||||
import { plural } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { AnimatePresence, Reorder } from "motion/react";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { useCurrentBuilderResumeSelector, useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
|
||||
|
||||
export function ExperienceSectionBuilder() {
|
||||
const section = useCurrentBuilderResumeSelector((resume) => resume.data.sections.experience);
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
const handleReorder = (items: z.infer<typeof experienceItemSchema>[]) => {
|
||||
updateResumeData((draft) => {
|
||||
draft.sections.experience.items = items;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionBase type="experience" className={cn("rounded-md border", section.items.length === 0 && "border-dashed")}>
|
||||
<Reorder.Group axis="y" values={section.items} onReorder={handleReorder}>
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
{section.items.map((item) => {
|
||||
return (
|
||||
<SectionItem
|
||||
key={item.id}
|
||||
type="experience"
|
||||
item={item}
|
||||
title={item.company}
|
||||
subtitle={item.position || plural(item.roles.length, { one: "# role", other: "# roles" })}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</AnimatePresence>
|
||||
</Reorder.Group>
|
||||
|
||||
<SectionAddItemButton type="experience">
|
||||
<Trans>Add a new experience</Trans>
|
||||
</SectionAddItemButton>
|
||||
</SectionBase>
|
||||
);
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import type { interestItemSchema } from "@reactive-resume/schema/resume/data";
|
||||
import type z from "zod";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { AnimatePresence, Reorder } from "motion/react";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { useCurrentBuilderResumeSelector, useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
|
||||
|
||||
export function InterestsSectionBuilder() {
|
||||
const section = useCurrentBuilderResumeSelector((resume) => resume.data.sections.interests);
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
const handleReorder = (items: z.infer<typeof interestItemSchema>[]) => {
|
||||
updateResumeData((draft) => {
|
||||
draft.sections.interests.items = items;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionBase type="interests" className={cn("rounded-md border", section.items.length === 0 && "border-dashed")}>
|
||||
<Reorder.Group axis="y" values={section.items} onReorder={handleReorder}>
|
||||
<AnimatePresence>
|
||||
{section.items.map((item) => (
|
||||
<SectionItem key={item.id} type="interests" item={item} title={item.name} />
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</Reorder.Group>
|
||||
|
||||
<SectionAddItemButton type="interests">
|
||||
<Trans>Add a new interest</Trans>
|
||||
</SectionAddItemButton>
|
||||
</SectionBase>
|
||||
);
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import type { languageItemSchema } from "@reactive-resume/schema/resume/data";
|
||||
import type z from "zod";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { AnimatePresence, Reorder } from "motion/react";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { useCurrentBuilderResumeSelector, useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
|
||||
|
||||
export function LanguagesSectionBuilder() {
|
||||
const section = useCurrentBuilderResumeSelector((resume) => resume.data.sections.languages);
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
const handleReorder = (items: z.infer<typeof languageItemSchema>[]) => {
|
||||
updateResumeData((draft) => {
|
||||
draft.sections.languages.items = items;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionBase type="languages" className={cn("rounded-md border", section.items.length === 0 && "border-dashed")}>
|
||||
<Reorder.Group axis="y" values={section.items} onReorder={handleReorder}>
|
||||
<AnimatePresence>
|
||||
{section.items.map((item) => (
|
||||
<SectionItem key={item.id} type="languages" item={item} title={item.language} subtitle={item.fluency} />
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</Reorder.Group>
|
||||
|
||||
<SectionAddItemButton type="languages">
|
||||
<Trans>Add a new language</Trans>
|
||||
</SectionAddItemButton>
|
||||
</SectionBase>
|
||||
);
|
||||
}
|
||||
@@ -125,13 +125,14 @@ vi.mock("../shared/section-item", () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
const { AwardsSectionBuilder } = await import("./awards");
|
||||
const { CertificationsSectionBuilder } = await import("./certifications");
|
||||
const { InterestsSectionBuilder } = await import("./interests");
|
||||
const { LanguagesSectionBuilder } = await import("./languages");
|
||||
const { PublicationsSectionBuilder } = await import("./publications");
|
||||
const { ReferencesSectionBuilder } = await import("./references");
|
||||
const { VolunteerSectionBuilder } = await import("./volunteer");
|
||||
const { ItemsSection } = await import("../shared/items-section");
|
||||
const AwardsSectionBuilder = () => <ItemsSection type="awards" />;
|
||||
const CertificationsSectionBuilder = () => <ItemsSection type="certifications" />;
|
||||
const InterestsSectionBuilder = () => <ItemsSection type="interests" />;
|
||||
const LanguagesSectionBuilder = () => <ItemsSection type="languages" />;
|
||||
const PublicationsSectionBuilder = () => <ItemsSection type="publications" />;
|
||||
const ReferencesSectionBuilder = () => <ItemsSection type="references" />;
|
||||
const VolunteerSectionBuilder = () => <ItemsSection type="volunteer" />;
|
||||
|
||||
beforeAll(() => {
|
||||
i18n.loadAndActivate({ locale: "en", messages: {} });
|
||||
|
||||
@@ -95,7 +95,7 @@ function PicturePreviewControls({
|
||||
alt=""
|
||||
src={normalizedPictureUrl}
|
||||
style={{ objectFit: picture.fit }}
|
||||
className="fade-in relative z-10 size-full animate-in rounded-md transition-opacity group-hover/picture:opacity-20"
|
||||
className="relative z-10 size-full rounded-md transition-opacity group-hover/picture:opacity-20"
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -67,7 +67,8 @@ vi.mock("../shared/section-item", () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
const { ProfilesSectionBuilder } = await import("./profiles");
|
||||
const { ItemsSection } = await import("../shared/items-section");
|
||||
const ProfilesSectionBuilder = () => <ItemsSection type="profiles" />;
|
||||
|
||||
beforeAll(() => {
|
||||
i18n.loadAndActivate({ locale: "en", messages: {} });
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import type { profileItemSchema } from "@reactive-resume/schema/resume/data";
|
||||
import type z from "zod";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { AnimatePresence, Reorder } from "motion/react";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { useCurrentBuilderResumeSelector, useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
|
||||
|
||||
export function ProfilesSectionBuilder() {
|
||||
const section = useCurrentBuilderResumeSelector((resume) => resume.data.sections.profiles);
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
const handleReorder = (items: z.infer<typeof profileItemSchema>[]) => {
|
||||
updateResumeData((draft) => {
|
||||
draft.sections.profiles.items = items;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionBase type="profiles" className={cn("rounded-md border", section.items.length === 0 && "border-dashed")}>
|
||||
<Reorder.Group axis="y" values={section.items} onReorder={handleReorder}>
|
||||
<AnimatePresence>
|
||||
{section.items.map((item) => (
|
||||
<SectionItem key={item.id} type="profiles" item={item} title={item.network} subtitle={item.username} />
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</Reorder.Group>
|
||||
|
||||
<SectionAddItemButton type="profiles">
|
||||
<Trans>Add a new profile</Trans>
|
||||
</SectionAddItemButton>
|
||||
</SectionBase>
|
||||
);
|
||||
}
|
||||
@@ -69,7 +69,8 @@ vi.mock("../shared/section-item", () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
const { ProjectsSectionBuilder } = await import("./projects");
|
||||
const { ItemsSection } = await import("../shared/items-section");
|
||||
const ProjectsSectionBuilder = () => <ItemsSection type="projects" />;
|
||||
|
||||
beforeAll(() => {
|
||||
i18n.loadAndActivate({ locale: "en", messages: {} });
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import type { projectItemSchema } from "@reactive-resume/schema/resume/data";
|
||||
import type z from "zod";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { AnimatePresence, Reorder } from "motion/react";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { useCurrentBuilderResumeSelector, useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
|
||||
|
||||
const buildSubtitle = (item: z.infer<typeof projectItemSchema>) => {
|
||||
const parts = [item.period, item.website.label].filter((part) => part && part.trim().length > 0);
|
||||
return parts.length > 0 ? parts.join(" • ") : undefined;
|
||||
};
|
||||
|
||||
export function ProjectsSectionBuilder() {
|
||||
const section = useCurrentBuilderResumeSelector((resume) => resume.data.sections.projects);
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
const handleReorder = (items: z.infer<typeof projectItemSchema>[]) => {
|
||||
updateResumeData((draft) => {
|
||||
draft.sections.projects.items = items;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionBase type="projects" className={cn("rounded-md border", section.items.length === 0 && "border-dashed")}>
|
||||
<Reorder.Group axis="y" values={section.items} onReorder={handleReorder}>
|
||||
<AnimatePresence>
|
||||
{section.items.map((item) => (
|
||||
<SectionItem key={item.id} type="projects" item={item} title={item.name} subtitle={buildSubtitle(item)} />
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</Reorder.Group>
|
||||
|
||||
<SectionAddItemButton type="projects">
|
||||
<Trans>Add a new project</Trans>
|
||||
</SectionAddItemButton>
|
||||
</SectionBase>
|
||||
);
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import type { publicationItemSchema } from "@reactive-resume/schema/resume/data";
|
||||
import type z from "zod";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { AnimatePresence, Reorder } from "motion/react";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { useCurrentBuilderResumeSelector, useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
|
||||
|
||||
export function PublicationsSectionBuilder() {
|
||||
const section = useCurrentBuilderResumeSelector((resume) => resume.data.sections.publications);
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
const handleReorder = (items: z.infer<typeof publicationItemSchema>[]) => {
|
||||
updateResumeData((draft) => {
|
||||
draft.sections.publications.items = items;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionBase type="publications" className={cn("rounded-md border", section.items.length === 0 && "border-dashed")}>
|
||||
<Reorder.Group axis="y" values={section.items} onReorder={handleReorder}>
|
||||
<AnimatePresence>
|
||||
{section.items.map((item) => (
|
||||
<SectionItem key={item.id} type="publications" item={item} title={item.title} subtitle={item.publisher} />
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</Reorder.Group>
|
||||
|
||||
<SectionAddItemButton type="publications">
|
||||
<Trans>Add a new publication</Trans>
|
||||
</SectionAddItemButton>
|
||||
</SectionBase>
|
||||
);
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import type { referenceItemSchema } from "@reactive-resume/schema/resume/data";
|
||||
import type z from "zod";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { AnimatePresence, Reorder } from "motion/react";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { useCurrentBuilderResumeSelector, useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
|
||||
|
||||
export function ReferencesSectionBuilder() {
|
||||
const section = useCurrentBuilderResumeSelector((resume) => resume.data.sections.references);
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
const handleReorder = (items: z.infer<typeof referenceItemSchema>[]) => {
|
||||
updateResumeData((draft) => {
|
||||
draft.sections.references.items = items;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionBase type="references" className={cn("rounded-md border", section.items.length === 0 && "border-dashed")}>
|
||||
<Reorder.Group axis="y" values={section.items} onReorder={handleReorder}>
|
||||
<AnimatePresence>
|
||||
{section.items.map((item) => (
|
||||
<SectionItem key={item.id} type="references" item={item} title={item.name} />
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</Reorder.Group>
|
||||
|
||||
<SectionAddItemButton type="references">
|
||||
<Trans>Add a new reference</Trans>
|
||||
</SectionAddItemButton>
|
||||
</SectionBase>
|
||||
);
|
||||
}
|
||||
@@ -49,7 +49,8 @@ vi.mock("../shared/section-item", () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
const { SkillsSectionBuilder } = await import("./skills");
|
||||
const { ItemsSection } = await import("../shared/items-section");
|
||||
const SkillsSectionBuilder = () => <ItemsSection type="skills" />;
|
||||
|
||||
beforeAll(() => {
|
||||
i18n.loadAndActivate({ locale: "en", messages: {} });
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import type { skillItemSchema } from "@reactive-resume/schema/resume/data";
|
||||
import type z from "zod";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { AnimatePresence, Reorder } from "motion/react";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { useCurrentBuilderResumeSelector, useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
|
||||
|
||||
export function SkillsSectionBuilder() {
|
||||
const section = useCurrentBuilderResumeSelector((resume) => resume.data.sections.skills);
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
const handleReorder = (items: z.infer<typeof skillItemSchema>[]) => {
|
||||
updateResumeData((draft) => {
|
||||
draft.sections.skills.items = items;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionBase type="skills" className={cn("rounded-md border", section.items.length === 0 && "border-dashed")}>
|
||||
<Reorder.Group axis="y" values={section.items} onReorder={handleReorder}>
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
{section.items.map((item) => (
|
||||
<SectionItem key={item.id} type="skills" item={item} title={item.name} subtitle={item.proficiency} />
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</Reorder.Group>
|
||||
|
||||
<SectionAddItemButton type="skills">
|
||||
<Trans>Add a new skill</Trans>
|
||||
</SectionAddItemButton>
|
||||
</SectionBase>
|
||||
);
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import type { volunteerItemSchema } from "@reactive-resume/schema/resume/data";
|
||||
import type z from "zod";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { AnimatePresence, Reorder } from "motion/react";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { useCurrentBuilderResumeSelector, useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
|
||||
|
||||
export function VolunteerSectionBuilder() {
|
||||
const section = useCurrentBuilderResumeSelector((resume) => resume.data.sections.volunteer);
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
const handleReorder = (items: z.infer<typeof volunteerItemSchema>[]) => {
|
||||
updateResumeData((draft) => {
|
||||
draft.sections.volunteer.items = items;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionBase type="volunteer" className={cn("rounded-md border", section.items.length === 0 && "border-dashed")}>
|
||||
<Reorder.Group axis="y" values={section.items} onReorder={handleReorder}>
|
||||
<AnimatePresence>
|
||||
{section.items.map((item) => (
|
||||
<SectionItem
|
||||
key={item.id}
|
||||
type="volunteer"
|
||||
item={item}
|
||||
title={item.organization}
|
||||
subtitle={item.location}
|
||||
/>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</Reorder.Group>
|
||||
|
||||
<SectionAddItemButton type="volunteer">
|
||||
<Trans>Add a new volunteer experience</Trans>
|
||||
</SectionAddItemButton>
|
||||
</SectionBase>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import type {
|
||||
CustomSectionItem,
|
||||
CustomSectionType,
|
||||
SectionItem as SectionItemType,
|
||||
SectionType,
|
||||
} from "@reactive-resume/schema/resume/data";
|
||||
import type { ReactNode } from "react";
|
||||
import { plural } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { AnimatePresence, Reorder } from "motion/react";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { useCurrentBuilderResumeSelector, useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { SectionBase } from "./section-base";
|
||||
import { SectionAddItemButton, SectionItem } from "./section-item";
|
||||
|
||||
type SectionItemListProps<T extends CustomSectionItem | SectionItemType> = {
|
||||
type: CustomSectionType;
|
||||
items: T[];
|
||||
onReorder: (items: T[]) => void;
|
||||
getTitle: (item: T) => string;
|
||||
getSubtitle?: (item: T) => string | undefined;
|
||||
customSectionId?: string;
|
||||
};
|
||||
|
||||
export function SectionItemList<T extends CustomSectionItem | SectionItemType>({
|
||||
type,
|
||||
items,
|
||||
onReorder,
|
||||
getTitle,
|
||||
getSubtitle,
|
||||
customSectionId,
|
||||
}: SectionItemListProps<T>) {
|
||||
return (
|
||||
// popLayout pops exiting rows out of flow, so the group must be the positioned ancestor.
|
||||
<Reorder.Group axis="y" values={items} onReorder={onReorder} className="relative">
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
{items.map((item) => (
|
||||
<SectionItem
|
||||
key={item.id}
|
||||
type={type}
|
||||
item={item}
|
||||
customSectionId={customSectionId}
|
||||
title={getTitle(item)}
|
||||
subtitle={getSubtitle?.(item)}
|
||||
/>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</Reorder.Group>
|
||||
);
|
||||
}
|
||||
|
||||
type ItemsSectionConfig<T extends SectionType> = {
|
||||
title: (item: SectionItemType<T>) => string;
|
||||
subtitle?: (item: SectionItemType<T>) => string | undefined;
|
||||
addLabel: ReactNode;
|
||||
};
|
||||
|
||||
export const SECTIONS: { [T in SectionType]: ItemsSectionConfig<T> } = {
|
||||
profiles: {
|
||||
title: (item) => item.network,
|
||||
subtitle: (item) => item.username,
|
||||
addLabel: <Trans>Add a new profile</Trans>,
|
||||
},
|
||||
experience: {
|
||||
title: (item) => item.company,
|
||||
subtitle: (item) => item.position || plural(item.roles.length, { one: "# role", other: "# roles" }),
|
||||
addLabel: <Trans>Add a new experience</Trans>,
|
||||
},
|
||||
education: {
|
||||
title: (item) => item.school,
|
||||
subtitle: (item) => item.degree,
|
||||
addLabel: <Trans>Add a new education</Trans>,
|
||||
},
|
||||
projects: {
|
||||
title: (item) => item.name,
|
||||
subtitle: (item) => [item.period, item.website.label].filter((part) => part?.trim()).join(" • ") || undefined,
|
||||
addLabel: <Trans>Add a new project</Trans>,
|
||||
},
|
||||
skills: {
|
||||
title: (item) => item.name,
|
||||
subtitle: (item) => item.proficiency,
|
||||
addLabel: <Trans>Add a new skill</Trans>,
|
||||
},
|
||||
languages: {
|
||||
title: (item) => item.language,
|
||||
subtitle: (item) => item.fluency,
|
||||
addLabel: <Trans>Add a new language</Trans>,
|
||||
},
|
||||
interests: {
|
||||
title: (item) => item.name,
|
||||
addLabel: <Trans>Add a new interest</Trans>,
|
||||
},
|
||||
awards: {
|
||||
title: (item) => item.title,
|
||||
subtitle: (item) => item.awarder,
|
||||
addLabel: <Trans>Add a new award</Trans>,
|
||||
},
|
||||
certifications: {
|
||||
title: (item) => item.title,
|
||||
subtitle: (item) => [item.issuer, item.date].filter(Boolean).join(" • ") || undefined,
|
||||
addLabel: <Trans>Add a new certification</Trans>,
|
||||
},
|
||||
publications: {
|
||||
title: (item) => item.title,
|
||||
subtitle: (item) => item.publisher,
|
||||
addLabel: <Trans>Add a new publication</Trans>,
|
||||
},
|
||||
volunteer: {
|
||||
title: (item) => item.organization,
|
||||
subtitle: (item) => item.location,
|
||||
addLabel: <Trans>Add a new volunteer experience</Trans>,
|
||||
},
|
||||
references: {
|
||||
title: (item) => item.name,
|
||||
addLabel: <Trans>Add a new reference</Trans>,
|
||||
},
|
||||
};
|
||||
|
||||
type ItemsSectionProps<T extends SectionType> = {
|
||||
type: T;
|
||||
};
|
||||
|
||||
export function ItemsSection<T extends SectionType>({ type }: ItemsSectionProps<T>) {
|
||||
const items = useCurrentBuilderResumeSelector((resume) => resume.data.sections[type].items) as SectionItemType<T>[];
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
const config = SECTIONS[type];
|
||||
|
||||
const handleReorder = (nextItems: SectionItemType<T>[]) => {
|
||||
updateResumeData((draft) => {
|
||||
(draft.sections[type] as { items: SectionItemType<T>[] }).items = nextItems;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionBase type={type} className={cn("rounded-md border", items.length === 0 && "border-dashed")}>
|
||||
<SectionItemList
|
||||
type={type}
|
||||
items={items}
|
||||
onReorder={handleReorder}
|
||||
getTitle={config.title}
|
||||
getSubtitle={config.subtitle}
|
||||
/>
|
||||
|
||||
<SectionAddItemButton type={type}>{config.addLabel}</SectionAddItemButton>
|
||||
</SectionBase>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
import type {
|
||||
CustomSectionItem,
|
||||
CustomSectionType,
|
||||
ResumeData,
|
||||
SectionItem as SectionItemType,
|
||||
SectionType,
|
||||
} from "@reactive-resume/schema/resume/data";
|
||||
import type { ButtonProps } from "@reactive-resume/ui/components/button";
|
||||
import type { Ref } from "react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import {
|
||||
@@ -39,6 +41,7 @@ import { cn } from "@reactive-resume/utils/style";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { useCurrentResume, useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { useConfirm } from "@/hooks/use-confirm";
|
||||
import { EASE_OUT_STRONG } from "@/libs/motion";
|
||||
import { atsFindingItemElementId } from "@/libs/resume/ats";
|
||||
import { getCompatibleMoveTargets, getSourceSectionTitle, moveItem } from "@/libs/resume/move-item";
|
||||
|
||||
@@ -169,6 +172,8 @@ type Props<T extends CustomSectionItem | SectionItemType> = {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
customSectionId?: string;
|
||||
// Injected by `AnimatePresence mode="popLayout"` to pop an exiting row out of the flow.
|
||||
ref?: Ref<HTMLLIElement>;
|
||||
};
|
||||
|
||||
export function SectionItem<T extends CustomSectionItem | SectionItemType>({
|
||||
@@ -177,28 +182,27 @@ export function SectionItem<T extends CustomSectionItem | SectionItemType>({
|
||||
title,
|
||||
subtitle,
|
||||
customSectionId,
|
||||
ref,
|
||||
}: Props<T>) {
|
||||
const confirm = useConfirm();
|
||||
const controls = useDragControls();
|
||||
const { openDialog } = useDialogStore();
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
// Returns the items array (and index) this row lives in, from either a custom or a built-in section.
|
||||
const findItem = (draft: ResumeData) => {
|
||||
const items = customSectionId
|
||||
? draft.customSections.find((section) => section.id === customSectionId)?.items
|
||||
: // Type assertion: when customSectionId is not provided, type is always a built-in SectionType
|
||||
draft.sections[type as SectionType].items;
|
||||
const index = items?.findIndex((_item) => _item.id === item.id) ?? -1;
|
||||
return index === -1 || !items ? null : { items, index };
|
||||
};
|
||||
|
||||
const onToggleVisibility = () => {
|
||||
updateResumeData((draft) => {
|
||||
if (customSectionId) {
|
||||
const section = draft.customSections.find((s) => s.id === customSectionId);
|
||||
if (!section) return;
|
||||
const index = section.items.findIndex((_item) => _item.id === item.id);
|
||||
if (index === -1) return;
|
||||
section.items[index].hidden = !section.items[index].hidden;
|
||||
} else {
|
||||
// Type assertion: when customSectionId is not provided, type is always a built-in SectionType
|
||||
const section = draft.sections[type as SectionType];
|
||||
if (!("items" in section)) return;
|
||||
const index = section.items.findIndex((_item) => _item.id === item.id);
|
||||
if (index === -1) return;
|
||||
section.items[index].hidden = !section.items[index].hidden;
|
||||
}
|
||||
const found = findItem(draft);
|
||||
if (found) found.items[found.index].hidden = !found.items[found.index].hidden;
|
||||
});
|
||||
};
|
||||
|
||||
@@ -227,38 +231,27 @@ export function SectionItem<T extends CustomSectionItem | SectionItemType>({
|
||||
if (!confirmed) return;
|
||||
|
||||
updateResumeData((draft) => {
|
||||
if (customSectionId) {
|
||||
const section = draft.customSections.find((s) => s.id === customSectionId);
|
||||
if (!section) return;
|
||||
const index = section.items.findIndex((_item) => _item.id === item.id);
|
||||
if (index === -1) return;
|
||||
section.items.splice(index, 1);
|
||||
} else {
|
||||
// Type assertion: when customSectionId is not provided, type is always a built-in SectionType
|
||||
const section = draft.sections[type as SectionType];
|
||||
if (!("items" in section)) return;
|
||||
const index = section.items.findIndex((_item) => _item.id === item.id);
|
||||
if (index === -1) return;
|
||||
section.items.splice(index, 1);
|
||||
}
|
||||
const found = findItem(draft);
|
||||
if (found) found.items.splice(found.index, 1);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Reorder.Item
|
||||
key={item.id}
|
||||
ref={ref}
|
||||
id={atsFindingItemElementId(item.id)}
|
||||
value={item}
|
||||
dragListener={false}
|
||||
dragControls={controls}
|
||||
initial={{ opacity: 0, y: -8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -8 }}
|
||||
transition={{ duration: 0.16, ease: "easeOut" }}
|
||||
className="group relative flex h-18 select-none border-b will-change-[transform,opacity]"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.12, ease: EASE_OUT_STRONG }}
|
||||
whileDrag={{ boxShadow: "0 4px 12px rgb(0 0 0 / 0.08)" }}
|
||||
className="group relative flex h-18 select-none border-b bg-background"
|
||||
>
|
||||
<div
|
||||
className="flex cursor-ns-resize touch-none items-center px-1.5 opacity-40 transition-[background-color,opacity] hover:bg-secondary/40 group-hover:opacity-100"
|
||||
className="flex cursor-grab touch-none items-center px-1.5 opacity-40 transition-[background-color,opacity] hover:bg-secondary/40 active:cursor-grabbing group-hover:opacity-100"
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault();
|
||||
controls.start(e);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ScrollArea } from "@reactive-resume/ui/components/scroll-area";
|
||||
import { Separator } from "@reactive-resume/ui/components/separator";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@reactive-resume/ui/components/tooltip";
|
||||
import { Copyright } from "@/components/ui/copyright";
|
||||
import { getScrollBehavior } from "@/features/resume/builder/section-recovery";
|
||||
import { getSectionIcon, getSectionTitle, rightSidebarSections } from "@/libs/resume/section";
|
||||
import { BuilderSidebarEdge } from "../../-components/edge";
|
||||
import { useBuilderSidebar } from "../../-store/sidebar";
|
||||
@@ -74,7 +75,7 @@ function SidebarEdge() {
|
||||
// Section ids are globally unique; document.getElementById reliably resolves the scroll target.
|
||||
document
|
||||
.getElementById(`sidebar-${section}`)
|
||||
?.scrollIntoView({ block: "start", inline: "nearest", behavior: "smooth" });
|
||||
?.scrollIntoView({ block: "start", inline: "nearest", behavior: getScrollBehavior() });
|
||||
},
|
||||
[toggleSidebar],
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { AtsFinding, AtsSeverity } from "@reactive-resume/resume/ats";
|
||||
import type { CSSProperties } from "react";
|
||||
import type { AtsCheckResult } from "@/features/ats-checker/run-ats-check";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Plural, Trans } from "@lingui/react/macro";
|
||||
@@ -18,6 +19,7 @@ import { AiReviewCard } from "@/features/ats-checker/ai-review/ai-review-card";
|
||||
import { AtsPdfReportView } from "@/features/ats-checker/report/report-view";
|
||||
import { blobToPdfFile, runAtsCheck } from "@/features/ats-checker/run-ats-check";
|
||||
import { useResumeData } from "@/features/resume/builder/draft";
|
||||
import { getScrollBehavior } from "@/features/resume/builder/section-recovery";
|
||||
import { createResumePdfBlob } from "@/features/resume/export/pdf-document";
|
||||
import {
|
||||
atsFindingItemElementId,
|
||||
@@ -118,7 +120,7 @@ function LiveLintTier() {
|
||||
|
||||
const item = target.itemId ? document.getElementById(atsFindingItemElementId(target.itemId)) : null;
|
||||
const destination = item ?? document.getElementById(`sidebar-${target.section}`);
|
||||
destination?.scrollIntoView({ block: "start", inline: "nearest", behavior: "smooth" });
|
||||
destination?.scrollIntoView({ block: "start", inline: "nearest", behavior: getScrollBehavior() });
|
||||
},
|
||||
[data, setCollapsed, toggleSidebar],
|
||||
);
|
||||
@@ -146,8 +148,8 @@ function LiveLintTier() {
|
||||
|
||||
<div className="h-1.5 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary transition-[width] duration-300"
|
||||
style={{ width: `${Math.round((passedRules / totalRules) * 100)}%` }}
|
||||
className="h-full translate-x-(--fill) rounded-full bg-primary transition-[translate] duration-200 ease-out-strong rtl:-translate-x-(--fill)"
|
||||
style={{ "--fill": `${(passedRules / totalRules) * 100 - 100}%` } as CSSProperties}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -17,6 +17,7 @@ import { LevelTypeCombobox } from "@/components/level/combobox";
|
||||
import { LevelDisplay } from "@/components/level/display";
|
||||
import { useCurrentResume, useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { useSyncFormValues } from "@/hooks/use-sync-form-values";
|
||||
import { EASE_OUT_STRONG } from "@/libs/motion";
|
||||
import { useAppForm } from "@/libs/tanstack-form";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
|
||||
@@ -179,21 +180,21 @@ function QuickColorCircle({ color, active, onSelect, className, ...props }: Quic
|
||||
onClick={() => onSelect(color)}
|
||||
className={cn(
|
||||
"relative flex size-8 items-center justify-center rounded-md bg-transparent",
|
||||
"scale-100 transition-transform hover:scale-120 hover:bg-secondary/80 active:scale-95",
|
||||
"transition-[scale,background-color] duration-150 ease-out-strong hover:bg-secondary/80 active:scale-[0.97]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div style={{ backgroundColor: color }} className="size-6 shrink-0 rounded-md" />
|
||||
|
||||
<AnimatePresence>
|
||||
<AnimatePresence initial={false}>
|
||||
{active && (
|
||||
<m.div
|
||||
initial={{ scale: 0.95, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.95, opacity: 0 }}
|
||||
transition={{ duration: 0.16, ease: "easeOut" }}
|
||||
className="absolute inset-0 flex size-8 items-center justify-center will-change-transform"
|
||||
transition={{ duration: 0.16, ease: EASE_OUT_STRONG }}
|
||||
className="absolute inset-0 flex size-8 items-center justify-center"
|
||||
>
|
||||
<div className="size-4 rounded-md bg-foreground" />
|
||||
</m.div>
|
||||
|
||||
@@ -41,6 +41,7 @@ import { Switch } from "@reactive-resume/ui/components/switch";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { templates } from "@/dialogs/resume/template/data";
|
||||
import { useCurrentResume, useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { EASE_OUT_STRONG } from "@/libs/motion";
|
||||
import { resolveLayoutSectionTitle } from "./title";
|
||||
import { filterVisibleLayoutSectionIds } from "./visibility";
|
||||
|
||||
@@ -270,7 +271,9 @@ export function LayoutPages() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DragOverlay>{activeId ? <LayoutItemContent id={activeId} isDragging isOverlay /> : null}</DragOverlay>
|
||||
<DragOverlay dropAnimation={{ duration: 150, easing: `cubic-bezier(${EASE_OUT_STRONG})` }}>
|
||||
{activeId ? <LayoutItemContent id={activeId} isOverlay /> : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
@@ -617,10 +620,10 @@ function LayoutItemContent({
|
||||
data-overlay={isOverlay ? "true" : undefined}
|
||||
data-dragging={isDragging ? "true" : undefined}
|
||||
className={cn(
|
||||
"group/item flex cursor-grab touch-none select-none items-center gap-x-2 rounded-md border border-border bg-background px-2 py-1.5 font-medium text-sm transition-all duration-200 ease-out",
|
||||
"group/item flex cursor-grab touch-none select-none items-center gap-x-2 rounded-md border border-border bg-background px-2 py-1.5 font-medium text-sm transition-[background-color,border-color] duration-150",
|
||||
"hover:bg-secondary/40 active:cursor-grabbing active:border-primary/60 active:bg-secondary/40",
|
||||
"data-[overlay=true]:cursor-grabbing data-[overlay=true]:border-primary/60 data-[overlay=true]:bg-background",
|
||||
"data-[dragging=true]:cursor-grabbing data-[dragging=true]:border-primary/60 data-[dragging=true]:bg-background",
|
||||
"data-[overlay=true]:cursor-grabbing data-[overlay=true]:border-primary/60 data-[overlay=true]:bg-background data-[overlay=true]:shadow-md",
|
||||
"data-[dragging=true]:cursor-grabbing data-[dragging=true]:opacity-40",
|
||||
className,
|
||||
)}
|
||||
{...rest}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { Layout, usePanelRef } from "react-resizable-panels";
|
||||
import Cookies from "js-cookie";
|
||||
import { useCallback } from "react";
|
||||
import { useMediaQuery, useWindowSize } from "usehooks-ts";
|
||||
import { useWindowSize } from "usehooks-ts";
|
||||
import { create } from "zustand/react";
|
||||
import { useIsMobile } from "@reactive-resume/ui/hooks/use-mobile";
|
||||
|
||||
type PanelImperativeHandle = ReturnType<typeof usePanelRef>;
|
||||
|
||||
@@ -102,7 +103,7 @@ type UseBuilderSidebarReturn = {
|
||||
};
|
||||
|
||||
export function useBuilderSidebar(): UseBuilderSidebarReturn {
|
||||
const isMobile = useMediaQuery("(max-width: 767px)", { initializeWithValue: false });
|
||||
const isMobile = useIsMobile();
|
||||
const { width } = useWindowSize();
|
||||
|
||||
const { maxSidebarSize, minSidebarSize, collapsedSidebarSize, expandSize, groupResizeBehavior } =
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { BuilderLayout } from "./-store/sidebar";
|
||||
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
import { useEffect } from "react";
|
||||
import { useMediaQuery } from "usehooks-ts";
|
||||
import { useIsMobile } from "@reactive-resume/ui/hooks/use-mobile";
|
||||
import { useBuilderResumeUpdateSubscription, useResumeCleanup, useResumeStore } from "@/features/resume/builder/draft";
|
||||
import { orpc } from "@/libs/orpc/client";
|
||||
import { createNoindexFollowMeta } from "@/libs/seo";
|
||||
@@ -73,7 +73,7 @@ function RouteComponent() {
|
||||
|
||||
function BuilderLayoutShell({ initialLayout }: { initialLayout: BuilderLayout }) {
|
||||
// Single breakpoint (below `md`) switches between the desktop resizable panels and the mobile tabbed shell.
|
||||
const isMobile = useMediaQuery("(max-width: 767px)", { initializeWithValue: false });
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
if (isMobile) return <MobileBuilderShell />;
|
||||
return <DesktopBuilderShell initialLayout={initialLayout} />;
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import Cookies from "js-cookie";
|
||||
import z from "zod";
|
||||
|
||||
const SIDEBAR_COOKIE_NAME = "sidebar_state";
|
||||
|
||||
export function getDashboardSidebarState() {
|
||||
return Cookies.get(SIDEBAR_COOKIE_NAME) !== "false";
|
||||
}
|
||||
|
||||
export function setDashboardSidebarState(value: boolean) {
|
||||
const parsed = z.boolean().parse(value);
|
||||
Cookies.set(SIDEBAR_COOKIE_NAME, parsed.toString());
|
||||
}
|
||||
@@ -40,6 +40,7 @@ import { getInitials } from "@reactive-resume/utils/string";
|
||||
import { Copyright } from "@/components/ui/copyright";
|
||||
import { useCommandPaletteStore } from "@/features/command-palette/store";
|
||||
import { UserDropdownMenu } from "@/features/user/dropdown-menu";
|
||||
import { EASE_OUT_STRONG } from "@/libs/motion";
|
||||
|
||||
type SidebarItem = {
|
||||
icon: React.ReactNode;
|
||||
@@ -120,11 +121,11 @@ function SidebarItemList({ items }: SidebarItemListProps) {
|
||||
{items.map((item) => (
|
||||
<SidebarMenuItem key={item.href}>
|
||||
<SidebarMenuButton
|
||||
title={i18n.t(item.label)}
|
||||
tooltip={i18n.t(item.label)}
|
||||
render={
|
||||
<Link to={item.href} activeProps={{ className: "bg-sidebar-accent" }}>
|
||||
{item.icon}
|
||||
<span className="shrink-0 transition-[margin,opacity] duration-200 ease-in-out group-data-[collapsible=icon]:-ms-8 group-data-[collapsible=icon]:opacity-0">
|
||||
<span className="shrink-0 transition-opacity duration-150 ease-out-strong group-data-[collapsible=icon]:opacity-0 group-data-instant/sidebar-wrapper:transition-none">
|
||||
{i18n.t(item.label)}
|
||||
</span>
|
||||
</Link>
|
||||
@@ -144,12 +145,14 @@ function SidebarSearchButton() {
|
||||
|
||||
return (
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton title={label} tooltip={label} onClick={() => setOpen(true)}>
|
||||
<SidebarMenuButton tooltip={label} onClick={() => setOpen(true)}>
|
||||
<MagnifyingGlassIcon />
|
||||
<span className="flex-1 text-start transition-[margin,opacity] duration-200 ease-in-out group-data-[collapsible=icon]:-ms-8 group-data-[collapsible=icon]:opacity-0">
|
||||
<span className="flex-1 text-start transition-opacity duration-150 ease-out-strong group-data-[collapsible=icon]:opacity-0 group-data-instant/sidebar-wrapper:transition-none">
|
||||
{label}
|
||||
</span>
|
||||
<Kbd className="transition-opacity duration-200 ease-in-out group-data-[collapsible=icon]:opacity-0">⌘K</Kbd>
|
||||
<Kbd className="transition-opacity duration-150 ease-out-strong group-data-[collapsible=icon]:opacity-0 group-data-instant/sidebar-wrapper:transition-none">
|
||||
⌘K
|
||||
</Kbd>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
@@ -209,14 +212,14 @@ export function DashboardSidebar() {
|
||||
<UserDropdownMenu>
|
||||
{({ session }) => (
|
||||
<SidebarMenuButton className="h-auto gap-x-3 group-data-[collapsible=icon]:p-1!">
|
||||
<Avatar className="size-8 shrink-0 transition-all group-data-[collapsible=icon]:size-6">
|
||||
<Avatar className="size-8 shrink-0 group-data-[collapsible=icon]:size-6">
|
||||
<AvatarImage src={session.user.image ?? undefined} />
|
||||
<AvatarFallback className="group-data-[collapsible=icon]:text-[0.5rem]">
|
||||
{getInitials(session.user.name)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div className="transition-[margin,opacity] duration-200 ease-in-out group-data-[collapsible=icon]:-ms-8 group-data-[collapsible=icon]:opacity-0">
|
||||
<div className="transition-opacity duration-150 ease-out-strong group-data-[collapsible=icon]:opacity-0 group-data-instant/sidebar-wrapper:transition-none">
|
||||
<p className="font-medium">{session.user.name}</p>
|
||||
<p className="text-muted-foreground text-xs">{session.user.email}</p>
|
||||
</div>
|
||||
@@ -230,11 +233,9 @@ export function DashboardSidebar() {
|
||||
{state === "expanded" && (
|
||||
<m.div
|
||||
key="copyright"
|
||||
className="will-change-[transform,opacity]"
|
||||
initial={{ y: 12, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ y: 12, opacity: 0 }}
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1, transition: { duration: 0.2, ease: EASE_OUT_STRONG } }}
|
||||
exit={{ opacity: 0, transition: { duration: 0.08 } }}
|
||||
>
|
||||
<Copyright className="wrap-break-word shrink-0 whitespace-normal p-2" />
|
||||
</m.div>
|
||||
|
||||
@@ -87,7 +87,7 @@ function RouteComponent() {
|
||||
void navigate({ replace: true, search: (prev: Search) => ({ ...prev, create: false }) });
|
||||
}, [create, navigate]);
|
||||
|
||||
const { data: applications } = useQuery(applicationsListQueryOptions());
|
||||
const { data: applications, isPending } = useQuery(applicationsListQueryOptions());
|
||||
const { data: allTags } = useQuery(orpc.applications.tags.queryOptions());
|
||||
|
||||
useEffect(() => {
|
||||
@@ -116,7 +116,8 @@ function RouteComponent() {
|
||||
|
||||
const archivedCount = (applications ?? []).filter((app) => app.archived).length;
|
||||
|
||||
const isEmpty = (applications?.length ?? 0) === 0;
|
||||
// While loading render neither the empty state nor the board; a failed load falls back to the empty state.
|
||||
const isEmpty = !applications?.length;
|
||||
|
||||
const setUrlSearch = (patch: Partial<Search>) => void navigate({ search: (prev: Search) => ({ ...prev, ...patch }) });
|
||||
|
||||
@@ -148,7 +149,7 @@ function RouteComponent() {
|
||||
|
||||
<Separator />
|
||||
|
||||
{isEmpty ? (
|
||||
{isPending ? null : isEmpty ? (
|
||||
<EmptyState onAdd={() => setAddOpen(true)} onImport={() => setImportOpen(true)} />
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -12,7 +12,7 @@ type BaseCardProps = React.ComponentProps<"div"> & {
|
||||
|
||||
export function BaseCard({ title, description, tags, className, children, ...props }: BaseCardProps) {
|
||||
return (
|
||||
<CometCard translateDepth={3} rotateDepth={6}>
|
||||
<CometCard>
|
||||
<div
|
||||
{...props}
|
||||
className={cn(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user