mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-10-03 18:23:47 +10:00
refactor(web): delete code only tests used and reuse shared locale and colour helpers
This commit is contained in:
@@ -1,56 +0,0 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { fireEvent, render } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { CometCard } from "./comet-card";
|
||||
|
||||
describe("CometCard", () => {
|
||||
it("renders its children inside the perspective wrapper", () => {
|
||||
const { getByText } = render(
|
||||
<CometCard>
|
||||
<span>card body</span>
|
||||
</CometCard>,
|
||||
);
|
||||
expect(getByText("card body")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("merges custom className into the wrapper", () => {
|
||||
const { container } = render(
|
||||
<CometCard className="extra-class">
|
||||
<span>x</span>
|
||||
</CometCard>,
|
||||
);
|
||||
const wrapper = container.firstChild as HTMLElement;
|
||||
expect(wrapper.className).toContain("extra-class");
|
||||
expect(wrapper.className).toContain("perspective-distant");
|
||||
expect(wrapper.className).toContain("transform-3d");
|
||||
});
|
||||
|
||||
it("renders a glare overlay positioned absolutely with mix-blend-overlay", () => {
|
||||
const { container } = render(
|
||||
<CometCard>
|
||||
<span>x</span>
|
||||
</CometCard>,
|
||||
);
|
||||
const glare = container.querySelector("[class*='mix-blend-overlay']") as HTMLElement | null;
|
||||
expect(glare).not.toBeNull();
|
||||
expect(glare?.className).toContain("pointer-events-none");
|
||||
});
|
||||
|
||||
it("does not throw when pointer moves over / leaves the card", () => {
|
||||
const { container } = render(
|
||||
<CometCard>
|
||||
<span>x</span>
|
||||
</CometCard>,
|
||||
);
|
||||
|
||||
const tiltable = container.querySelector("[class*='rounded-md']") as HTMLElement;
|
||||
expect(tiltable).toBeTruthy();
|
||||
|
||||
expect(() => {
|
||||
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 +0,0 @@
|
||||
import type React 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 = {
|
||||
glareOpacity?: number;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
// Critically damped: the tilt follows the pointer without wobbling past it.
|
||||
const tiltSpring = { stiffness: 300, damping: 30 };
|
||||
|
||||
export const CometCard = ({ glareOpacity = 0.4, className, children }: Props) => {
|
||||
const reduceMotion = useReducedMotion();
|
||||
|
||||
const x = useSpring(useMotionValue(0), tiltSpring);
|
||||
const y = useSpring(useMotionValue(0), tiltSpring);
|
||||
|
||||
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%)`;
|
||||
|
||||
// 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 handlePointerLeave = () => {
|
||||
x.set(0);
|
||||
y.set(0);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("perspective-distant transform-3d", className)}>
|
||||
<m.div
|
||||
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}
|
||||
|
||||
{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,21 +0,0 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { render } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Spotlight } from "./spotlight";
|
||||
|
||||
describe("Spotlight", () => {
|
||||
it("renders a non-pointer-events overlay container", () => {
|
||||
const { container } = render(<Spotlight />);
|
||||
const wrapper = container.firstChild as HTMLElement;
|
||||
expect(wrapper.className).toContain("pointer-events-none");
|
||||
expect(wrapper.className).toContain("absolute");
|
||||
});
|
||||
|
||||
it("renders both left and right beam groups by default", () => {
|
||||
const { container } = render(<Spotlight />);
|
||||
// Outer wrapper > two animated beam containers
|
||||
const beamGroups = container.firstChild?.childNodes;
|
||||
expect(beamGroups?.length).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -1,44 +0,0 @@
|
||||
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%)";
|
||||
|
||||
// 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 = () => (
|
||||
<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={`${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>
|
||||
);
|
||||
@@ -26,9 +26,9 @@ import { PopoverHeader, PopoverTitle, PopoverTrigger } from "@reactive-resume/ui
|
||||
import { toast } from "@reactive-resume/ui/components/toast";
|
||||
import { Toggle } from "@reactive-resume/ui/components/toggle";
|
||||
import { isDarkColor } from "@reactive-resume/utils/color";
|
||||
import { isRTL } from "@reactive-resume/utils/locale";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { usePrompt } from "@/hooks/use-prompt";
|
||||
import { isRTL } from "@/libs/locale";
|
||||
import { ColorPicker } from "./color-picker";
|
||||
import { ParagraphIndent } from "./paragraph-indent";
|
||||
import { defaultHighlightColor, resolveHighlightToolbarState } from "./rich-input.utils";
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { i18n } from "@lingui/core";
|
||||
import { I18nProvider } from "@lingui/react";
|
||||
|
||||
vi.stubGlobal("__APP_VERSION__", "9.9.9");
|
||||
|
||||
const { Copyright } = await import("./copyright");
|
||||
|
||||
beforeAll(() => {
|
||||
i18n.loadAndActivate({ locale: "en", messages: {} });
|
||||
});
|
||||
|
||||
const renderCopyright = (props?: React.ComponentProps<typeof Copyright>) =>
|
||||
render(
|
||||
<I18nProvider i18n={i18n}>
|
||||
<Copyright {...props} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
describe("Copyright", () => {
|
||||
it("renders the MIT license link", () => {
|
||||
renderCopyright();
|
||||
const link = screen.getByRole("link", { name: "MIT" });
|
||||
expect(link.getAttribute("href")).toBe("https://github.com/reactive-resume/reactive-resume/blob/main/LICENSE");
|
||||
expect(link.getAttribute("rel")).toBe("noopener noreferrer");
|
||||
});
|
||||
|
||||
it("renders the Amruth Pillai attribution link", () => {
|
||||
renderCopyright();
|
||||
const link = screen.getByRole("link", { name: "Amruth Pillai" });
|
||||
expect(link.getAttribute("href")).toBe("https://amruthpillai.com");
|
||||
});
|
||||
|
||||
it("includes the app version string", () => {
|
||||
renderCopyright();
|
||||
// The version is wrapped in <bdi> for RTL isolation, so it is its own text node.
|
||||
expect(screen.getByText("9.9.9")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("merges custom className into the wrapper", () => {
|
||||
const { container } = renderCopyright({ className: "extra-class" });
|
||||
const wrapper = container.firstChild as HTMLElement;
|
||||
expect(wrapper.className).toContain("extra-class");
|
||||
expect(wrapper.className).toContain("text-ink-3");
|
||||
});
|
||||
|
||||
it("opens external links in a new tab", () => {
|
||||
renderCopyright();
|
||||
for (const link of screen.getAllByRole("link")) {
|
||||
expect(link.getAttribute("target")).toBe("_blank");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,50 +0,0 @@
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
|
||||
type Props = React.ComponentProps<"div">;
|
||||
|
||||
export function Copyright({ className, ...props }: Props) {
|
||||
return (
|
||||
<div className={cn("text-ink-3/80 text-xs leading-relaxed", className)} {...props}>
|
||||
<p>
|
||||
<Trans>
|
||||
Licensed under{" "}
|
||||
<a
|
||||
href="https://github.com/reactive-resume/reactive-resume/blob/main/LICENSE"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline underline-offset-2"
|
||||
>
|
||||
MIT
|
||||
</a>
|
||||
.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<Trans comment="Tagline shown in app footer/about area">By the community, for the community.</Trans>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<Trans>
|
||||
A passion project by{" "}
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="https://amruthpillai.com"
|
||||
className="font-medium underline underline-offset-2"
|
||||
>
|
||||
Amruth Pillai
|
||||
</a>
|
||||
.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<p className="mt-4">
|
||||
<Trans comment="App version label in footer; includes semantic version variable">
|
||||
Reactive Resume v<bdi>{__APP_VERSION__}</bdi>
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -134,7 +134,6 @@ export function DuplicateResumeDialog({ data }: DialogProps<"resume.duplicate">)
|
||||
toast.add({ type: "success", description: t`Your resume has been duplicated.`, id: toastId });
|
||||
closeDialog();
|
||||
|
||||
if (!data.shouldRedirect) return;
|
||||
void navigate({ to: "/builder/$resumeId", params: { resumeId: id } });
|
||||
},
|
||||
onError: (error) => {
|
||||
|
||||
@@ -15,7 +15,7 @@ export type DialogSchema =
|
||||
}
|
||||
| {
|
||||
type: "resume.duplicate";
|
||||
data: { id: string; name: string; slug: string; tags: string[]; shouldRedirect?: boolean };
|
||||
data: { id: string; name: string; slug: string; tags: string[] };
|
||||
};
|
||||
|
||||
export type DialogType = DialogSchema["type"];
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Application } from "./types";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getNextStep, isOverdue } from "./next-step";
|
||||
import { getNextStep } from "./next-step";
|
||||
|
||||
const NOW = new Date("2026-09-28T12:00:00Z");
|
||||
const day = (offset: number) => new Date(NOW.getTime() + offset * 86_400_000);
|
||||
@@ -47,7 +47,6 @@ describe("getNextStep", () => {
|
||||
});
|
||||
const late = getNextStep(application({ followUpAt: day(-1) }), NOW);
|
||||
expect(late).toMatchObject({ kind: "follow-up", overdue: true });
|
||||
expect(isOverdue(late)).toBe(true);
|
||||
});
|
||||
|
||||
it("suggests a follow-up after ten days without a reply, and counts the wait before that", () => {
|
||||
|
||||
@@ -56,9 +56,6 @@ export function getNextStep(application: NextStepSource, now = new Date()): Next
|
||||
return { kind: "waiting", days };
|
||||
}
|
||||
|
||||
/** Whether a next step needs attention now: an overdue follow-up, or no reply for too long. */
|
||||
export const isOverdue = (step: NextStep) => step.kind === "no-reply" || (step.kind === "follow-up" && step.overdue);
|
||||
|
||||
export type NextStepText = { icon: IconName; title: string; sub: string; tone: "normal" | "warn" | "muted" };
|
||||
|
||||
const formatWhen = (date: Date, locale: string, withTime: boolean) =>
|
||||
|
||||
@@ -4,8 +4,9 @@ import { useLingui } from "@lingui/react";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { ArrowUpRightIcon, CheckIcon, DownloadSimpleIcon, PlusIcon } from "@phosphor-icons/react";
|
||||
import { useState } from "react";
|
||||
import { isLocale, isRTL } from "@reactive-resume/utils/locale";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { changeLocale, isLocale, isRTL, localeMap, resolveLocale } from "@/libs/locale";
|
||||
import { changeLocale, localeMap, resolveLocale } from "@/libs/locale";
|
||||
import { section, sectionHeading, sectionText, sectionTitle, textLink, wrap } from "./classes";
|
||||
import "./languages-showcase.css";
|
||||
|
||||
|
||||
@@ -181,14 +181,6 @@ function getResumeQueryKey(id: string): QueryKey {
|
||||
return orpc.resume.getById.queryOptions({ input: { id } }).queryKey as QueryKey;
|
||||
}
|
||||
|
||||
function cloneResumeData(data: ResumeData): ResumeData {
|
||||
return structuredClone(data);
|
||||
}
|
||||
|
||||
function cloneResume(resume: Resume): Resume {
|
||||
return { ...resume, data: cloneResumeData(resume.data) };
|
||||
}
|
||||
|
||||
export function isEditableElementFocused(): boolean {
|
||||
if (typeof document === "undefined") return false;
|
||||
const element = document.activeElement as HTMLElement | null;
|
||||
@@ -261,7 +253,7 @@ async function flushResumeSave(id: string) {
|
||||
if (!runtime || runtime.isSaving || !runtime.pendingResume) return;
|
||||
|
||||
const submitted = runtime.pendingResume;
|
||||
const submittedData = cloneResumeData(submitted.data);
|
||||
const submittedData = structuredClone(submitted.data);
|
||||
runtime.pendingResume = undefined;
|
||||
runtime.isSaving = true;
|
||||
|
||||
@@ -292,7 +284,7 @@ async function flushResumeSave(id: string) {
|
||||
|
||||
if (!runtime.pendingResume && currentResume?.id === submitted.id && !isEqual(currentResume.data, submittedData)) {
|
||||
runtime.syncResume.cancel();
|
||||
runtime.pendingResume = cloneResume(currentResume);
|
||||
runtime.pendingResume = structuredClone(currentResume);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -319,7 +311,7 @@ async function flushResumeSave(id: string) {
|
||||
|
||||
function queueResumeSave(resume: Resume) {
|
||||
const runtime = getRuntime(resume.id);
|
||||
runtime.pendingResume = cloneResume(resume);
|
||||
runtime.pendingResume = structuredClone(resume);
|
||||
runtime.hasPendingLocalChanges = true;
|
||||
runtime.saveFailed = false;
|
||||
void flushResumeSave(resume.id);
|
||||
@@ -804,7 +796,7 @@ export function savePendingChanges(id: string): boolean | Promise<boolean> {
|
||||
if (!runtime?.hasPendingLocalChanges || current?.id !== id) return true;
|
||||
|
||||
runtime.syncResume.cancel();
|
||||
runtime.pendingResume = cloneResume(current);
|
||||
runtime.pendingResume = structuredClone(current);
|
||||
useResumeStore.getState().setSaveStatus("saving");
|
||||
|
||||
return new Promise<boolean>((resolve) => {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { produce } from "immer";
|
||||
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
|
||||
import { contrastOnWhite } from "@reactive-resume/utils/color";
|
||||
import {
|
||||
ACCENTS,
|
||||
applyFontPairing,
|
||||
applyTextSize,
|
||||
contrastOnWhite,
|
||||
darkenForWhite,
|
||||
fitToPages,
|
||||
hexToRgba,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import type { WritableDraft } from "immer";
|
||||
import { contrastOnWhite } from "@reactive-resume/utils/color";
|
||||
|
||||
type Data = ResumeData | WritableDraft<ResumeData>;
|
||||
/** The design a resume and a letter share. */
|
||||
@@ -117,18 +118,6 @@ export function rgbaToHex(color: string): string | null {
|
||||
.toUpperCase()}`;
|
||||
}
|
||||
|
||||
function luminance(hex: string) {
|
||||
const value = Number.parseInt(HEX.exec(hex)?.[1] ?? "000000", 16);
|
||||
const [r, g, b] = [(value >> 16) & 255, (value >> 8) & 255, value & 255].map((channel) => {
|
||||
const c = channel / 255;
|
||||
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
||||
});
|
||||
return 0.2126 * (r ?? 0) + 0.7152 * (g ?? 0) + 0.0722 * (b ?? 0);
|
||||
}
|
||||
|
||||
/** WCAG contrast ratio of a colour against white. */
|
||||
export const contrastOnWhite = (hex: string) => 1.05 / (luminance(hex) + 0.05);
|
||||
|
||||
export const isValidHex = (hex: string) => HEX.test(hex.trim());
|
||||
|
||||
/** The same hue, darkened step by step until it reads on white (at least 4.6:1). */
|
||||
|
||||
@@ -13,6 +13,7 @@ import { Input } from "@reactive-resume/ui/components/input";
|
||||
import { SegmentedControl, SegmentedControlItem } from "@reactive-resume/ui/components/segmented-control";
|
||||
import { Slider } from "@reactive-resume/ui/components/slider";
|
||||
import { SwitchRow } from "@reactive-resume/ui/components/switch";
|
||||
import { contrastOnWhite } from "@reactive-resume/utils/color";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
import { getLocaleOptions } from "@/features/locale/locale-options";
|
||||
@@ -23,7 +24,6 @@ import {
|
||||
applyFontPairing,
|
||||
applyMargins,
|
||||
applyTextSize,
|
||||
contrastOnWhite,
|
||||
darkenForWhite,
|
||||
FONT_PAIRINGS,
|
||||
hexToRgba,
|
||||
|
||||
@@ -13,14 +13,7 @@ import { useEditorStore } from "../store";
|
||||
|
||||
/** The visible text of a passage's HTML, for the card. */
|
||||
const passageText = (html: string) =>
|
||||
html
|
||||
.replace(/<[^>]*>/g, "")
|
||||
.replace(/ /g, " ")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/&/g, "&")
|
||||
.trim();
|
||||
new DOMParser().parseFromString(html, "text/html").body.textContent?.replaceAll("\u00a0", " ").trim() ?? "";
|
||||
|
||||
/** Applies proposals to the resume as one undo step; the toast's Undo takes them back, and they show as pending again. */
|
||||
export function acceptResumeProposals(proposals: readonly Proposal[]) {
|
||||
|
||||
@@ -7,10 +7,10 @@ import { EditorView } from "@codemirror/view";
|
||||
import { compileStylesheet } from "@reactive-resume/resume/stylesheet";
|
||||
import { collectCompiledColorTokens } from "./color-tokens";
|
||||
import {
|
||||
completionLabels,
|
||||
compositionAwareDocumentListener,
|
||||
copySourceToClipboard,
|
||||
createSemanticCssEditorExtensions,
|
||||
getSemanticCssCompletionLabels,
|
||||
getSemanticCssHoverDocumentation,
|
||||
mapCompilerDiagnostics,
|
||||
} from "./editor-extensions";
|
||||
@@ -63,14 +63,14 @@ afterEach(() => {
|
||||
|
||||
describe("Semantic CSS editor extensions", () => {
|
||||
it("uses only Semantic CSS registries and the current resume for completion", async () => {
|
||||
const selectorLabels = await getSemanticCssCompletionLabels("", 0, metadata);
|
||||
const propertyLabels = await getSemanticCssCompletionLabels("section {\n\tco", 13, metadata);
|
||||
const selectorLabels = await completionLabels("", 0, metadata);
|
||||
const propertyLabels = await completionLabels("section {\n\tco", 13, metadata);
|
||||
const variableSource = "resume { --brand-accent: #f00; color: var(--br";
|
||||
const variableLabels = await getSemanticCssCompletionLabels(variableSource, variableSource.length, metadata);
|
||||
const variableLabels = await completionLabels(variableSource, variableSource.length, metadata);
|
||||
// The cursor has to sit at the end of the typed prefix; mid-token it reads as a selector context.
|
||||
const systemSource = "--resume-";
|
||||
const systemLabels = await getSemanticCssCompletionLabels(systemSource, systemSource.length, metadata);
|
||||
const directiveLabels = await getSemanticCssCompletionLabels("@", 1, metadata);
|
||||
const systemLabels = await completionLabels(systemSource, systemSource.length, metadata);
|
||||
const directiveLabels = await completionLabels("@", 1, metadata);
|
||||
|
||||
expect(selectorLabels).toEqual(
|
||||
expect.arrayContaining([
|
||||
@@ -97,9 +97,9 @@ describe("Semantic CSS editor extensions", () => {
|
||||
const borderStyleSource = "section { border-style: d";
|
||||
const fontSizeSource = "section { font-size: 1";
|
||||
|
||||
const displayLabels = getSemanticCssCompletionLabels(displaySource, displaySource.length, metadata);
|
||||
const borderStyleLabels = getSemanticCssCompletionLabels(borderStyleSource, borderStyleSource.length, metadata);
|
||||
const fontSizeLabels = getSemanticCssCompletionLabels(fontSizeSource, fontSizeSource.length, metadata);
|
||||
const displayLabels = completionLabels(displaySource, displaySource.length, metadata);
|
||||
const borderStyleLabels = completionLabels(borderStyleSource, borderStyleSource.length, metadata);
|
||||
const fontSizeLabels = completionLabels(fontSizeSource, fontSizeSource.length, metadata);
|
||||
|
||||
expect(displayLabels).toEqual(expect.arrayContaining(["flex", "none", "inherit"]));
|
||||
expect(displayLabels).not.toEqual(expect.arrayContaining(["portrait", "dashed", "pt"]));
|
||||
@@ -111,7 +111,7 @@ describe("Semantic CSS editor extensions", () => {
|
||||
|
||||
it.each(borderShorthands)("offers complete %s shorthand values instead of bare units", (property) => {
|
||||
const source = `section { ${property}: `;
|
||||
const labels = getSemanticCssCompletionLabels(source, source.length, metadata);
|
||||
const labels = completionLabels(source, source.length, metadata);
|
||||
|
||||
expect(labels).toEqual(expect.arrayContaining(["1pt dotted", "1pt dashed", "1pt solid"]));
|
||||
expect(labels).not.toEqual(expect.arrayContaining(["pt", "px", "in", "mm", "cm", "%", "vw", "vh", "em", "rem"]));
|
||||
@@ -135,7 +135,7 @@ describe("Semantic CSS editor extensions", () => {
|
||||
templateParts: ['timeline"marker\n'],
|
||||
} as const;
|
||||
|
||||
const labels = getSemanticCssCompletionLabels("", 0, unsafeMetadata);
|
||||
const labels = completionLabels("", 0, unsafeMetadata);
|
||||
|
||||
expect(labels).toEqual(
|
||||
expect.arrayContaining([
|
||||
|
||||
@@ -93,7 +93,7 @@ function declarationProperty(source: string, position: number): string | undefin
|
||||
return property || undefined;
|
||||
}
|
||||
|
||||
function completionLabels(source: string, position: number, metadata: SemanticCssEditorMetadata): string[] {
|
||||
export function completionLabels(source: string, position: number, metadata: SemanticCssEditorMetadata): string[] {
|
||||
switch (completionKind(source, position)) {
|
||||
case "directive":
|
||||
return [...directives];
|
||||
@@ -118,14 +118,6 @@ function completionLabels(source: string, position: number, metadata: SemanticCs
|
||||
}
|
||||
}
|
||||
|
||||
export function getSemanticCssCompletionLabels(
|
||||
source: string,
|
||||
position: number,
|
||||
metadata: SemanticCssEditorMetadata,
|
||||
): readonly string[] {
|
||||
return completionLabels(source, position, metadata);
|
||||
}
|
||||
|
||||
export function getSemanticCssHoverDocumentation(
|
||||
label: string,
|
||||
metadata: SemanticCssEditorMetadata,
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
@import "@reactive-resume/ui/globals.css";
|
||||
|
||||
@keyframes spotlight-drift {
|
||||
to {
|
||||
translate: var(--spotlight-drift);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-prompt-marquee {
|
||||
to {
|
||||
transform: translateX(-33.333%);
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import Cookies from "js-cookie";
|
||||
import { changeLocale, formatRelativeTime, isLocale, resolveLocale } from "./locale";
|
||||
import { isLocale } from "@reactive-resume/utils/locale";
|
||||
import { changeLocale, formatRelativeTime, resolveLocale } from "./locale";
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
@@ -64,10 +65,10 @@ describe("formatRelativeTime", () => {
|
||||
expect(formatRelativeTime("2026-01-02T11:59:45Z", formatter)).toBe("now");
|
||||
});
|
||||
|
||||
it("uses the requested fallback for an invalid date", () => {
|
||||
it("reads an invalid date as now", () => {
|
||||
const formatter = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
|
||||
|
||||
expect(formatRelativeTime("invalid", formatter, "")).toBe("");
|
||||
expect(formatRelativeTime("invalid", formatter)).toBe("now");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -3,12 +3,9 @@ import type { Locale } from "@reactive-resume/utils/locale";
|
||||
import { i18n } from "@lingui/core";
|
||||
import { msg } from "@lingui/core/macro";
|
||||
import Cookies from "js-cookie";
|
||||
import { isRTL, localeSchema } from "@reactive-resume/utils/locale";
|
||||
|
||||
export { isRTL };
|
||||
import { defaultLocale, isLocale } from "@reactive-resume/utils/locale";
|
||||
|
||||
const storageKey = "locale";
|
||||
const defaultLocale: Locale = "en-US";
|
||||
const messageLoaders = import.meta.glob<{ messages: Messages }>("../../locales/*.po");
|
||||
const relativeTimeDivisions: Array<{ amount: number; unit: Intl.RelativeTimeFormatUnit }> = [
|
||||
{ amount: 31_536_000_000, unit: "year" },
|
||||
@@ -77,18 +74,14 @@ export const localeMap = {
|
||||
"zu-ZA": msg`Zulu`,
|
||||
} satisfies Record<Locale, MessageDescriptor>;
|
||||
|
||||
export function isLocale(locale: string): locale is Locale {
|
||||
return localeSchema.safeParse(locale).success;
|
||||
}
|
||||
|
||||
export const resolveLocale = (locale: string): Locale => {
|
||||
return isLocale(locale) ? locale : defaultLocale;
|
||||
};
|
||||
|
||||
export function formatRelativeTime(value: Date | string, formatter: Intl.RelativeTimeFormat, invalidFallback?: string) {
|
||||
export function formatRelativeTime(value: Date | string, formatter: Intl.RelativeTimeFormat) {
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
const diffMs = date.getTime() - Date.now();
|
||||
if (Number.isNaN(diffMs)) return invalidFallback ?? formatter.format(0, "second");
|
||||
if (Number.isNaN(diffMs)) return formatter.format(0, "second");
|
||||
|
||||
const division = relativeTimeDivisions.find((candidate) => Math.abs(diffMs) >= candidate.amount);
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Mock locale module so getLocaleMessages returns a known mapping
|
||||
@@ -54,38 +53,3 @@ describe("createSectionTitleResolverForLocale", () => {
|
||||
expect(title.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useSectionTitleResolver", () => {
|
||||
it("returns null when no locale is provided", async () => {
|
||||
const { useSectionTitleResolver } = await import("./section-title-locale");
|
||||
|
||||
const { result } = renderHook(() => useSectionTitleResolver(undefined));
|
||||
expect(result.current).toBeNull();
|
||||
});
|
||||
|
||||
it("loads a resolver when a locale is provided", async () => {
|
||||
const { useSectionTitleResolver } = await import("./section-title-locale");
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ locale }: { locale: string | undefined }) => useSectionTitleResolver(locale),
|
||||
{
|
||||
initialProps: { locale: "en-US" as string | undefined },
|
||||
},
|
||||
);
|
||||
|
||||
// Initially null while the resolver is loading async.
|
||||
expect(result.current).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
// Flush microtasks so the async resolver settles.
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(typeof result.current).toBe("function");
|
||||
|
||||
// Switching to undefined clears the resolver.
|
||||
rerender({ locale: undefined });
|
||||
expect(result.current).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { SectionTitleResolver } from "@reactive-resume/pdf/section-title";
|
||||
import { setupI18n } from "@lingui/core";
|
||||
import { useEffect, useReducer } from "react";
|
||||
import { getLocaleMessages, resolveLocale } from "@/libs/locale";
|
||||
import { createSectionTitleResolver } from "./section-title";
|
||||
|
||||
@@ -23,30 +22,3 @@ export const createSectionTitleResolverForLocale = (localeParam: string) => {
|
||||
|
||||
return resolver;
|
||||
};
|
||||
|
||||
export const useSectionTitleResolver = (locale?: string) => {
|
||||
const [resolver, dispatchResolver] = useReducer(
|
||||
(_state: SectionTitleResolver | null, nextResolver: SectionTitleResolver | null) => nextResolver,
|
||||
null,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!locale) {
|
||||
dispatchResolver(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
dispatchResolver(null);
|
||||
void createSectionTitleResolverForLocale(locale).then((nextResolver) => {
|
||||
if (!cancelled) dispatchResolver(nextResolver);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [locale]);
|
||||
|
||||
return resolver;
|
||||
};
|
||||
|
||||
@@ -2,14 +2,18 @@
|
||||
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import { i18n } from "@lingui/core";
|
||||
import { isValidElement } from "react";
|
||||
import { getSectionIcon, getSectionTitle, leftSidebarSections, rightSidebarSections } from "./section";
|
||||
import { getSectionTitle } from "./section";
|
||||
|
||||
beforeAll(() => {
|
||||
i18n.loadAndActivate({ locale: "en", messages: {} });
|
||||
});
|
||||
|
||||
const ALL_SECTIONS = [...leftSidebarSections, ...rightSidebarSections, "cover-letter"] as const;
|
||||
const ALL_SECTIONS = [
|
||||
...["picture", "basics", "summary", "profiles", "experience", "education", "projects", "skills", "languages"],
|
||||
...["interests", "awards", "certifications", "publications", "volunteer", "references", "custom", "template"],
|
||||
...["layout", "sharing", "statistics", "typography", "design", "styles", "page", "notes", "export", "information"],
|
||||
"cover-letter",
|
||||
] as Parameters<typeof getSectionTitle>[0][];
|
||||
|
||||
describe("getSectionTitle", () => {
|
||||
it("returns a non-empty string for every known sidebar section", () => {
|
||||
@@ -25,40 +29,3 @@ describe("getSectionTitle", () => {
|
||||
expect(new Set(titles).size).toBe(titles.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSectionIcon", () => {
|
||||
it("returns a React element for every known sidebar section", () => {
|
||||
for (const section of ALL_SECTIONS) {
|
||||
const icon = getSectionIcon(section);
|
||||
expect(isValidElement(icon), section).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("forwards icon props such as className", () => {
|
||||
const icon = getSectionIcon("skills", { className: "custom-class" });
|
||||
const props = (icon as React.ReactElement).props as { className?: string };
|
||||
expect(props.className).toContain("custom-class");
|
||||
// Always merges the shrink-0 baseline class.
|
||||
expect(props.className).toContain("shrink-0");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sidebar section collections", () => {
|
||||
it("expose the documented left-sidebar set", () => {
|
||||
expect(leftSidebarSections).toContain("picture");
|
||||
expect(leftSidebarSections).toContain("basics");
|
||||
expect(leftSidebarSections).toContain("experience");
|
||||
expect(leftSidebarSections).toContain("custom");
|
||||
});
|
||||
|
||||
it("expose the documented right-sidebar set", () => {
|
||||
expect(rightSidebarSections).toContain("template");
|
||||
expect(rightSidebarSections).toContain("design");
|
||||
expect(rightSidebarSections).toContain("export");
|
||||
});
|
||||
|
||||
it("do not overlap (every section belongs to exactly one sidebar)", () => {
|
||||
const overlap = leftSidebarSections.filter((s) => (rightSidebarSections as readonly string[]).includes(s));
|
||||
expect(overlap).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,38 +1,6 @@
|
||||
import type { IconProps } from "@phosphor-icons/react";
|
||||
import type { SectionType } from "@reactive-resume/schema/resume/data";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import {
|
||||
ArticleIcon,
|
||||
BooksIcon,
|
||||
BriefcaseIcon,
|
||||
CertificateIcon,
|
||||
ChartLineIcon,
|
||||
CodeSimpleIcon,
|
||||
CompassToolIcon,
|
||||
DiamondsFourIcon,
|
||||
DownloadIcon,
|
||||
EnvelopeSimpleIcon,
|
||||
FootballIcon,
|
||||
GraduationCapIcon,
|
||||
HandHeartIcon,
|
||||
ImageIcon,
|
||||
InfoIcon,
|
||||
LayoutIcon,
|
||||
MessengerLogoIcon,
|
||||
NotepadIcon,
|
||||
PaintBrushBroadIcon,
|
||||
PaletteIcon,
|
||||
PhoneIcon,
|
||||
ReadCvLogoIcon,
|
||||
ShareFatIcon,
|
||||
StarIcon,
|
||||
TextTIcon,
|
||||
TranslateIcon,
|
||||
TrophyIcon,
|
||||
UserIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { match } from "ts-pattern";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
|
||||
export type LeftSidebarSection = "picture" | "basics" | "summary" | SectionType | "custom";
|
||||
|
||||
@@ -54,39 +22,6 @@ export type RightSidebarSection =
|
||||
|
||||
export type SidebarSection = LeftSidebarSection | RightSidebarSection;
|
||||
|
||||
export const leftSidebarSections: LeftSidebarSection[] = [
|
||||
"picture",
|
||||
"basics",
|
||||
"summary",
|
||||
"profiles",
|
||||
"experience",
|
||||
"education",
|
||||
"projects",
|
||||
"skills",
|
||||
"languages",
|
||||
"interests",
|
||||
"awards",
|
||||
"certifications",
|
||||
"publications",
|
||||
"volunteer",
|
||||
"references",
|
||||
"custom",
|
||||
] as const;
|
||||
|
||||
export const rightSidebarSections: RightSidebarSection[] = [
|
||||
"template",
|
||||
"layout",
|
||||
"sharing",
|
||||
"statistics",
|
||||
"typography",
|
||||
"design",
|
||||
"styles",
|
||||
"page",
|
||||
"notes",
|
||||
"export",
|
||||
"information",
|
||||
] as const;
|
||||
|
||||
export const getSectionTitle = (type: SidebarSection | CustomOnlyType): string => {
|
||||
return (
|
||||
match(type)
|
||||
@@ -127,46 +62,3 @@ export const getSectionTitle = (type: SidebarSection | CustomOnlyType): string =
|
||||
.exhaustive()
|
||||
);
|
||||
};
|
||||
|
||||
export const getSectionIcon = (type: SidebarSection | CustomOnlyType, props?: IconProps): React.ReactNode => {
|
||||
const iconProps = { ...props, className: cn("shrink-0", props?.className) };
|
||||
|
||||
return (
|
||||
match(type)
|
||||
// Left Sidebar Sections
|
||||
.with("picture", () => <ImageIcon {...iconProps} />)
|
||||
.with("basics", () => <UserIcon {...iconProps} />)
|
||||
.with("summary", () => <ArticleIcon {...iconProps} />)
|
||||
.with("profiles", () => <MessengerLogoIcon {...iconProps} />)
|
||||
.with("experience", () => <BriefcaseIcon {...iconProps} />)
|
||||
.with("education", () => <GraduationCapIcon {...iconProps} />)
|
||||
.with("projects", () => <CodeSimpleIcon {...iconProps} />)
|
||||
.with("skills", () => <CompassToolIcon {...iconProps} />)
|
||||
.with("languages", () => <TranslateIcon {...iconProps} />)
|
||||
.with("interests", () => <FootballIcon {...iconProps} />)
|
||||
.with("awards", () => <TrophyIcon {...iconProps} />)
|
||||
.with("certifications", () => <CertificateIcon {...iconProps} />)
|
||||
.with("publications", () => <BooksIcon {...iconProps} />)
|
||||
.with("volunteer", () => <HandHeartIcon {...iconProps} />)
|
||||
.with("references", () => <PhoneIcon {...iconProps} />)
|
||||
.with("custom", () => <StarIcon {...iconProps} />)
|
||||
|
||||
// Custom Section Types (not in main sidebar)
|
||||
.with("cover-letter", () => <EnvelopeSimpleIcon {...iconProps} />)
|
||||
|
||||
// Right Sidebar Sections
|
||||
.with("template", () => <DiamondsFourIcon {...iconProps} />)
|
||||
.with("layout", () => <LayoutIcon {...iconProps} />)
|
||||
.with("typography", () => <TextTIcon {...iconProps} />)
|
||||
.with("design", () => <PaletteIcon {...iconProps} />)
|
||||
.with("styles", () => <PaintBrushBroadIcon {...iconProps} />)
|
||||
.with("page", () => <ReadCvLogoIcon {...iconProps} />)
|
||||
.with("notes", () => <NotepadIcon {...iconProps} />)
|
||||
.with("sharing", () => <ShareFatIcon {...iconProps} />)
|
||||
.with("statistics", () => <ChartLineIcon {...iconProps} />)
|
||||
.with("export", () => <DownloadIcon {...iconProps} />)
|
||||
.with("information", () => <InfoIcon {...iconProps} />)
|
||||
|
||||
.exhaustive()
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import { i18n } from "@lingui/core";
|
||||
import { I18nProvider } from "@lingui/react";
|
||||
import { useAppForm } from "./tanstack-form";
|
||||
|
||||
beforeAll(() => {
|
||||
i18n.loadAndActivate({ locale: "en", messages: {} });
|
||||
});
|
||||
|
||||
function TestForm() {
|
||||
const form = useAppForm({
|
||||
defaultValues: {
|
||||
website: { url: "https://example.com", label: "Example", inlineLink: false },
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<I18nProvider i18n={i18n}>
|
||||
<form.AppField
|
||||
name="website"
|
||||
validators={{ onChange: ({ value }) => (value.url ? undefined : "Website is required") }}
|
||||
>
|
||||
{(field) => <field.WebsiteField label="Website" hideLabelButton formItemClassName="website-field" />}
|
||||
</form.AppField>
|
||||
</I18nProvider>
|
||||
);
|
||||
}
|
||||
|
||||
describe("registered resume fields", () => {
|
||||
it("preserves labels, layout classes, attributes, values, and errors", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { container } = render(<TestForm />);
|
||||
|
||||
expect(screen.getByText("Website")).toBeInTheDocument();
|
||||
expect(container.querySelector(".website-field")).toBeInTheDocument();
|
||||
const website = screen.getByRole("textbox", { name: "Website" });
|
||||
expect(website).toHaveValue("example.com");
|
||||
expect(screen.getByLabelText("Website")).toBe(website);
|
||||
expect(container.querySelector(".website-field button")).not.toBeInTheDocument();
|
||||
await user.click(screen.getByText("Website", { selector: "label" }));
|
||||
expect(website).toHaveFocus();
|
||||
|
||||
await user.clear(website);
|
||||
|
||||
expect(screen.getByText("Website is required")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,139 +1,10 @@
|
||||
import type { Website } from "@reactive-resume/schema/resume/data";
|
||||
import type * as React from "react";
|
||||
import { createFormHook, createFormHookContexts } from "@tanstack/react-form";
|
||||
import { FormControl, FormDescription, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
|
||||
import { Input } from "@reactive-resume/ui/components/input";
|
||||
import { InputGroupInput } from "@reactive-resume/ui/components/input-group";
|
||||
import { URLInput } from "@/components/input/url-input";
|
||||
|
||||
type FieldFrameProps = {
|
||||
label?: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
formItemClassName?: string;
|
||||
};
|
||||
|
||||
type TextFieldProps = FieldFrameProps &
|
||||
Omit<React.ComponentProps<typeof Input>, "children" | "defaultValue" | "name" | "onBlur" | "onChange" | "value">;
|
||||
|
||||
type InputGroupTextFieldProps = FieldFrameProps &
|
||||
Omit<
|
||||
React.ComponentProps<typeof InputGroupInput>,
|
||||
"children" | "defaultValue" | "name" | "onBlur" | "onChange" | "value"
|
||||
>;
|
||||
|
||||
type NumberFieldProps = FieldFrameProps &
|
||||
Omit<
|
||||
React.ComponentProps<typeof Input>,
|
||||
"children" | "defaultValue" | "name" | "onBlur" | "onChange" | "type" | "value"
|
||||
>;
|
||||
|
||||
type WebsiteFieldProps = {
|
||||
label: React.ReactNode;
|
||||
formItemClassName?: string;
|
||||
hideLabelButton: boolean;
|
||||
};
|
||||
|
||||
const { fieldContext, formContext, useFieldContext } = createFormHookContexts();
|
||||
|
||||
function TextField({ label, description, formItemClassName, ...props }: TextFieldProps) {
|
||||
const field = useFieldContext<string>();
|
||||
|
||||
const hasError = field.state.meta.isTouched && field.state.meta.errors.length > 0;
|
||||
|
||||
return (
|
||||
<FormItem hasError={hasError} className={formItemClassName}>
|
||||
{label ? <FormLabel>{label}</FormLabel> : null}
|
||||
<FormControl
|
||||
render={
|
||||
<Input
|
||||
{...props}
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(event) => field.handleChange(event.target.value)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
{description ? <FormDescription>{description}</FormDescription> : null}
|
||||
</FormItem>
|
||||
);
|
||||
}
|
||||
|
||||
function InputGroupTextField({ label, description, formItemClassName, ...props }: InputGroupTextFieldProps) {
|
||||
const field = useFieldContext<string>();
|
||||
|
||||
const hasError = field.state.meta.isTouched && field.state.meta.errors.length > 0;
|
||||
|
||||
return (
|
||||
<FormItem hasError={hasError} className={formItemClassName}>
|
||||
{label ? <FormLabel>{label}</FormLabel> : null}
|
||||
<FormControl
|
||||
render={
|
||||
<InputGroupInput
|
||||
{...props}
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(event) => field.handleChange(event.target.value)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
{description ? <FormDescription>{description}</FormDescription> : null}
|
||||
</FormItem>
|
||||
);
|
||||
}
|
||||
|
||||
function NumberField({ label, description, formItemClassName, ...props }: NumberFieldProps) {
|
||||
const field = useFieldContext<number>();
|
||||
const hasError = field.state.meta.isTouched && field.state.meta.errors.length > 0;
|
||||
const value = Number.isFinite(field.state.value) ? field.state.value : "";
|
||||
|
||||
return (
|
||||
<FormItem hasError={hasError} className={formItemClassName}>
|
||||
{label ? <FormLabel>{label}</FormLabel> : null}
|
||||
<FormControl
|
||||
render={
|
||||
<Input
|
||||
{...props}
|
||||
type="number"
|
||||
name={field.name}
|
||||
value={value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(event) => field.handleChange(event.target.valueAsNumber)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
{description ? <FormDescription>{description}</FormDescription> : null}
|
||||
</FormItem>
|
||||
);
|
||||
}
|
||||
|
||||
function WebsiteField({ label, formItemClassName, hideLabelButton }: WebsiteFieldProps) {
|
||||
const field = useFieldContext<Website>();
|
||||
const hasError = field.state.meta.isTouched && field.state.meta.errors.length > 0;
|
||||
|
||||
return (
|
||||
<FormItem hasError={hasError} className={formItemClassName}>
|
||||
<FormLabel>{label}</FormLabel>
|
||||
<FormControl
|
||||
render={
|
||||
<URLInput
|
||||
value={field.state.value}
|
||||
onChange={(value) => field.handleChange(value)}
|
||||
hideLabelButton={hideLabelButton}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
</FormItem>
|
||||
);
|
||||
}
|
||||
// Forms render their fields with `form.Field`; no shared field components are registered.
|
||||
const { fieldContext, formContext } = createFormHookContexts();
|
||||
|
||||
export const { useAppForm, withForm } = createFormHook({
|
||||
fieldComponents: { InputGroupTextField, NumberField, TextField, WebsiteField },
|
||||
fieldComponents: {},
|
||||
fieldContext,
|
||||
formComponents: {},
|
||||
formContext,
|
||||
|
||||
@@ -19,6 +19,7 @@ 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";
|
||||
import { isRTL } from "@reactive-resume/utils/locale";
|
||||
import { BreakpointIndicator } from "@/components/layout/breakpoint-indicator";
|
||||
import { DonationToast } from "@/components/ui/donation-toast";
|
||||
import { DialogManager } from "@/dialogs/manager";
|
||||
@@ -26,7 +27,6 @@ import { CommandPalette } from "@/features/command-palette";
|
||||
import { ThemeProvider } from "@/features/theme/provider";
|
||||
import { ConfirmDialogProvider } from "@/hooks/use-confirm";
|
||||
import { PromptDialogProvider } from "@/hooks/use-prompt";
|
||||
import { isRTL } from "@/libs/locale";
|
||||
import { loadRootContext } from "@/libs/root-context";
|
||||
|
||||
type RouterContext = {
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { i18n } from "@lingui/core";
|
||||
import { I18nProvider } from "@lingui/react";
|
||||
|
||||
vi.stubGlobal("__APP_VERSION__", "9.9.9");
|
||||
|
||||
// The footer module evaluates `socialLinks = [{ label: t`...`, ... }]` at module
|
||||
// scope. That `t` call needs an activated locale BEFORE the import, so do that
|
||||
// here instead of in beforeAll.
|
||||
i18n.loadAndActivate({ locale: "en", messages: {} });
|
||||
|
||||
const { Footer } = await import("./footer");
|
||||
|
||||
const renderFooter = () =>
|
||||
render(
|
||||
<I18nProvider i18n={i18n}>
|
||||
<Footer />
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
describe("Footer", () => {
|
||||
it("renders Resources and Community link group headings", () => {
|
||||
renderFooter();
|
||||
expect(screen.getByText("Resources")).toBeInTheDocument();
|
||||
expect(screen.getByText("Community")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the documented resource links", () => {
|
||||
const { container } = renderFooter();
|
||||
const text = container.textContent ?? "";
|
||||
for (const label of ["Documentation", "Sponsorships", "Source Code", "Changelog"]) {
|
||||
expect(text, label).toContain(label);
|
||||
}
|
||||
});
|
||||
|
||||
it("renders the documented community links", () => {
|
||||
const { container } = renderFooter();
|
||||
const text = container.textContent ?? "";
|
||||
for (const label of ["Report an issue", "Translations", "Subreddit", "Discord"]) {
|
||||
expect(text, label).toContain(label);
|
||||
}
|
||||
});
|
||||
|
||||
it("renders social media icon links to GitHub, LinkedIn, and X", () => {
|
||||
const { container } = renderFooter();
|
||||
const hrefs = Array.from(container.querySelectorAll<HTMLAnchorElement>("a")).map((a) => a.href);
|
||||
expect(hrefs.some((h) => h.includes("github.com/reactive-resume/reactive-resume"))).toBe(true);
|
||||
expect(hrefs.some((h) => h.includes("linkedin.com/in/amruthpillai"))).toBe(true);
|
||||
expect(hrefs.some((h) => h.includes("x.com/KingOKings"))).toBe(true);
|
||||
});
|
||||
|
||||
it("includes Reactive Resume version copy via Copyright", () => {
|
||||
renderFooter();
|
||||
// The version is wrapped in <bdi> for RTL isolation, so it is its own text node.
|
||||
expect(screen.getByText("9.9.9")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,140 +0,0 @@
|
||||
import type { Icon } from "@phosphor-icons/react";
|
||||
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 { 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;
|
||||
label: string;
|
||||
};
|
||||
|
||||
type FooterLinkGroupProps = {
|
||||
title: string;
|
||||
links: FooterLinkItem[];
|
||||
};
|
||||
|
||||
type SocialLink = {
|
||||
url: string;
|
||||
label: string;
|
||||
icon: Icon;
|
||||
};
|
||||
|
||||
const getResourceLinks = (): FooterLinkItem[] => [
|
||||
{ url: "https://docs.rxresu.me", label: t`Documentation` },
|
||||
{ url: "https://opencollective.com/reactive-resume/donate", label: t`Sponsorships` },
|
||||
{ url: "https://github.com/reactive-resume/reactive-resume", label: t`Source Code` },
|
||||
{ url: "https://docs.rxresu.me/changelog", label: t`Changelog` },
|
||||
];
|
||||
|
||||
const getCommunityLinks = (): FooterLinkItem[] => [
|
||||
{ url: "https://github.com/reactive-resume/reactive-resume/issues", label: t`Report an issue` },
|
||||
{ url: "https://crowdin.com/project/reactive-resume", label: t`Translations` },
|
||||
{ url: "https://reddit.com/r/reactiveresume", label: t`Subreddit` },
|
||||
{ url: "https://discord.gg/aSyA5ZSxpb", label: t`Discord` },
|
||||
];
|
||||
|
||||
const socialLinks: SocialLink[] = [
|
||||
{ url: "https://github.com/reactive-resume/reactive-resume", label: t`GitHub`, icon: GithubLogoIcon },
|
||||
{ url: "https://linkedin.com/in/amruthpillai", label: t`LinkedIn`, icon: LinkedinLogoIcon },
|
||||
{ url: "https://x.com/KingOKings", label: t`X (Twitter)`, icon: XLogoIcon },
|
||||
];
|
||||
|
||||
export function Footer() {
|
||||
return (
|
||||
<m.footer
|
||||
id="footer"
|
||||
className="p-4 pb-8 md:p-8 md:pb-12"
|
||||
initial={{ opacity: 0 }}
|
||||
whileInView={{ opacity: 1 }}
|
||||
viewport={{ once: true }}
|
||||
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 */}
|
||||
<div className="space-y-4 sm:col-span-2 lg:col-span-1">
|
||||
<BrandIcon variant="logo" className="size-10" />
|
||||
|
||||
<div className="space-y-2">
|
||||
<h2 className="font-semibold text-lg tracking-tight">Reactive Resume</h2>
|
||||
<p className="max-w-xs text-ink-3 text-sm leading-relaxed">
|
||||
<Trans>
|
||||
A free and open-source resume builder that makes it easy to create, update, and share your resume.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Social Links */}
|
||||
<div className="flex items-center gap-2 pt-2">
|
||||
{socialLinks.map((social) => (
|
||||
<Button
|
||||
key={social.label}
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
nativeButton={false}
|
||||
render={
|
||||
<a
|
||||
href={social.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={`${social.label} (${t`opens in new tab`})`}
|
||||
>
|
||||
<social.icon aria-hidden="true" size={18} />
|
||||
</a>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Resources Column */}
|
||||
<FooterLinkGroup title={t`Resources`} links={getResourceLinks()} />
|
||||
|
||||
{/* Community Column */}
|
||||
<FooterLinkGroup title={t`Community`} links={getCommunityLinks()} />
|
||||
|
||||
{/* Copyright Column */}
|
||||
<div className="space-y-4 sm:col-span-2 lg:col-span-1">
|
||||
<Copyright />
|
||||
</div>
|
||||
</div>
|
||||
</m.footer>
|
||||
);
|
||||
}
|
||||
|
||||
function FooterLinkGroup({ title, links }: FooterLinkGroupProps) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h2 className="font-medium text-ink-3 text-sm tracking-tight">{title}</h2>
|
||||
|
||||
<ul className="space-y-3">
|
||||
{links.map((link) => (
|
||||
<FooterLink key={link.url} url={link.url} label={link.label} />
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FooterLink({ url, label }: FooterLinkItem) {
|
||||
return (
|
||||
<li className="relative">
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
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-accent after:content-[''] hover:text-ink 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>
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -145,7 +145,7 @@ function DocumentMenuItems({ onOpenDialog }: DocumentMenuItemsProps) {
|
||||
<Icon name="edit" />
|
||||
<Trans>Rename…</Trans>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => openDialog("resume.duplicate", { id, name, slug, tags, shouldRedirect: true })}>
|
||||
<DropdownMenuItem onClick={() => openDialog("resume.duplicate", { id, name, slug, tags })}>
|
||||
<Icon name="content_copy" />
|
||||
<Trans>Duplicate</Trans>
|
||||
</DropdownMenuItem>
|
||||
|
||||
Reference in New Issue
Block a user